IAN WALDRON IAN WALDRON

Flatpages In Django Part 2: Extending Flatpages

How to extend the Django flatpages app including a custom view to handle lookups by ID instead of URL.
September 9, 2023

Background

The flatpages app retrieves a page by matching a url with a url value stored with the FlatPage object. This is a very simple yet powerful solution to providing a lot of functionality without much set up. For url patterns that are dynamic in nature, this is an elegant solution. For example, the docs recommend tying in flatpages with the following url pattern (if you're not going to use middleware):


urlpatterns = [
    path("pages/", include("django.contrib.flatpages.urls")),
]

On the Django side, the above 'include' connects to:


urlpatterns = [
    path("", views.flatpage, name="django.contrib.flatpages.views.flatpage"),
]

This is a simple url pattern that accepts a single argument, "url." The url for each page will be constructed from this base.

If you're going to rely on this approach or the middleware, then I don't see any reason for changes to this implementation. In my case, I like to statically define the urls for basic, but import pages like about, privacy policy, and terms and conditions. Building off the above approach, this would look something like:


from django.contrib.flatpages.views import flatpage

urlpatterns = [
    path("pages/about/", flatpage, {"url": "/about/"}, name="about"),
    path("pages/privacy-policy/", flatpage, {"url": "/privacy-policy/"}, name="privacy-policy"),
    path("pages/terms-and-conditions/", flatpage, {"url": "/terms-and-conditions/"}, name="terms-and-conditions"),
]

Not the end of the world with just three patterns. But you can see how this approach would become error prone as the number of patterns grows as well as the complexity of the url parameter being passed to the pattern.

In my opinion, it's easier to work with an object's primary key as an argument than the url field's value. So, in this article, I'm going to demonstrate how to create a separate view to handle just that. With a primary key driven implementation, I want to be able to retrieve a FlatPage object like:


urlpatterns = [
    path("pages/about/", flatpage, {"obj_id": 1}, name="about"),
]

Let's get started.

Building a Basic View

When we work with flatpages, the view we usually use is:


from django.contrib.flatpages.views import flatpage

This is actually the first of two view functions that handle the request. The first function is the flatpage function mentioned above. It accepts "request" and "url" as it's two arguments and handles the lookup of the FlatPage object as well as a bit of projects to ensure leading and trailing slashes are present. The second function, "render_flatpage," handles an authentication check, loading of the template, and marks both the title and content fields safe so we don't have to use the "|safe" filter in our templates to be able to work with raw html. It then returns an HTMLReponse object.

What's great about having the logic broken into two parts is the configuration of a separate view function that works with a primary key instead of a url value is very simple. We avoid needed to replicate the logic of the second view function.

To create a view function that uses a primary key instead of a url as a lookup parameter, I'll use the "get_object_or_404" shortcut just as the original function does.


from django.contrib.flatpages.views import render_flatpage
from django.contrib.flatpages.models import FlatPage
from django.shortcuts import get_object_or_404

def flatpage_by_id(request, obj_id):
    f = get_object_or_404(FlatPage, pk=obj_id)
    return render_flatpage(request, f)

That's it. We're ready to use this with a statically encoded url like the following:


from django.urls import path
from my_project.utils.flatpages import flatpage_by_id

urlpatterns = [
    path("about/", flatpage_by_id, {"obj_id": 1}, name="about"),
]

Note, I chose to locate this simple function in a utils package. Because it's a simple, one-off function, it makes sense for my project to use a basic location like utils. But use whatever works best for your project.

The approach thus far has one limitation: How do we know what the primary key for each FlatPage project is? The Django admin site isn't going to display the primary key by default. We could use the Django shell to look up this value, but that's a lot of manual work. In the next section, I'll update the Admin portal to display this field.

Update Admin Site to Display Primary Keys

To update the Django admin site to display FlatPage primary keys so that we can easily lookup these values for use with our new view function, we need to follow three simple steps:

  • Step 1: Unregister FlatPage from admin.
  • Step 2: Build a new admin page.
  • Step 3: Register the new admin page.

Step 1: Unregister FlatPage From Admin

First, we need to create an admin.py file to contain this and the other two steps. Because I don't have a dedicated app, I'll place my admin.py in the project directory:


my_project/my_project/admin.py

Within our admin file, to de-register the existing FlagPage connection is as simple as:


from django.contrib import admin
from django.contrib.flatpages.models import FlatPage

admin.site.unregister(FlatPage)

With the above, the default flatpages admin page will no longer be visible. Note, you may need to restart your test server for the change to take effect.

Build a New Admin Page

With the old page out of the way, let's build a new one that includes all the existing fields plus the primary key. But first, here's what the original flatpages admin page looks like:


class FlatPageAdmin(admin.ModelAdmin):
    form = FlatpageForm
    fieldsets = (
        (None, {"fields": ("url", "title", "content", "sites")}),
        (
            _("Advanced options"),
            {
                "classes": ("collapse",),
                "fields": ("registration_required", "template_name"),
            },
        ),
    )
    list_display = ("url", "title")
    list_filter = ("sites", "registration_required")
    search_fields = ("url", "title")

We need to update the list_display attribute to include the primary key. Since it's defined as an immutable tuple, we'll have to redefine this attribute.


from django.contrib.flatpages.admin import FlatPageAdmin as OriginalFlatPageAdmin
from django.contrib.flatpages.models import FlatPage

class FlatPageAdmin(OriginalFlatPageAdmin):
    # extend the original class

    list_display = ("pk", "url", "title")

With that one change, we're ready to register the new admin page.

Register the New Admin Page

To register the new page, all we need to do is:


from django.contrib import admin
from django.contrib.flatpages.models import FlatPage

...
# FlatPageAdmin class defined above

admin.site.register(FlatPage, FlatPageAdmin)

Bringing all three steps together, we have:


from django.contrib import admin
from django.contrib.flatpages.models import FlatPage
from django.contrib.flatpages.admin import FlatPageAdmin as OriginalFlatPageAdmin


class FlatPageAdmin(OriginalFlatPageAdmin):

    list_display = ("pk", "url", "title")

admin.site.unregister(FlatPage)
admin.site.register(FlatPage, FlatPageAdmin)

Add Logging

With the above steps, we have a working solution and could easily stop here. But there's a small disconnect that's worth noting. Since we're not using the url field as a means of lookup, it's possible for the url value stored with the FlatPage object and the actual url to be inconsistent. If this were to occur, we may want to know but it's probably not something we want to interrupt execution for. So instead, I'll log a warning so that a system admin can inspect at a future date.

To implement logging, I'm going to import and instantiate a logger, check if the request's path and stored url value are different, then log the difference if one is detected.


import logging

from django.contrib.flatpages.views import render_flatpage
from django.contrib.flatpages.models import FlatPage
from django.shortcuts import get_object_or_404


logger = logging.getLogger(__name__)


def flatpage_by_id(request, obj_id):
    f = get_object_or_404(FlatPage, pk=obj_id)
    path = request.get_full_path()
    if f.url != path:
        logger.warning(f"The flatpage with url '{f.url}' doesn't match the request path '{path}'.")
    return render_flatpage(request, f)


With the above, if a FlatPage object is returned by the get_object_or_404 shortcut it will be passed to the downstream function render_flatpage no matter what. But if there's a discrepancy in urls, the issue will be logged so an admin can make the necessary correction.

Wrapping Up

If you're like me and find integers/primary keys easier to work with than strings/url components, then the implementation demonstrated here may be a convenient way to lookup a resource with a primary key instead of the native approach using the url. Furthermore, we updated the admin page to make it easier to determine a FlatPage object's primary key and added logging, so we're notified if and when inconsistencies between actual urls and store url values. While the Django flatpages app is a power tool right out of the box, don't hesitate to extend native functionality to better suite your needs and preferences.