21 Mar 2026

feedDjango community aggregator: Community blog posts

Human.json

I have seen more and more people talk about human.json lately and I think it is a pretty neat idea. From what I can tell it checks all the boxes I would expect from a protocol like this.

The fact that it relies on browser extensions right now makes sense, but might become a limiting factor in future. Or the number of extensions needs to go up beyond the two easy ones and come to mobile as well. I am not sure this will be going anywhere beyond a few enthusiastic people, but you never know.

Implementing the protocol was not much work, which is expected considering it only consists of two required values and an optional list of two more values. If you want to add it to your Django based site, I packaged everything up and you can find it on PyPI.

Should you use the package? Eh, that is not an easy question. From a supply chain perspective I would say "no". It is only a few lines of code. But you never know how the protocol will evolve, so things might look more complicated in a month. I will do my best to keep up with the protocol and not ship crypto miners.

I am still not a fan of Python packaging, but I have to admit uv makes it kind of bearable despite still not being without little gotchas.

21 Mar 2026 5:05pm GMT

Wagtail Routable Pages and Layout Configuration

If you are familiar with Wagtail CMS for Django, you know that you can create Wagtail pages and control their content and layout with blocks inside of stream fields. But what if you have entries coming from normal Django models through a routable page? In this article, I will explore how you can control the dynamic layout of a detail view in a routable page.

Routable pages in Wagtail are dynamic pages of your CMS page tree that can have their own URL subpaths and views. You can use them for filtered list and detail views, multi-step forms, multiple formats for the same data, etc. Here I will show you a routable ArticleIndexPage with a list and detail views for Article instances rendering the detail views based on the block layout in a detail_layout stream field.

Wagtail detail layout configuration

1. Project Setup

Create a Wagtail project myproject and articles app:

pip install wagtail
wagtail start myproject
cd myproject
python manage.py startapp articles

Add to INSTALLED_APPS in your Django project settings:

INSTALLED_APPS = [
    ...
    "wagtail.contrib.routable_page",  # required for RoutablePage
    "myproject.apps.articles",
]

2. File Structure

The articles app:

myproject/apps/articles/
├── __init__.py
├── apps.py
├── models.py          # Article, Category, ArticleIndexPage
├── blocks.py          # All StreamField block definitions
└── admin.py           # Register Article and Category in Django admin

The articles templates:

myproject/templates/articles/
├── article_list.html           # List view
├── article_detail.html         # Detail view
└── blocks/
    ├── cover_image_block.html
    ├── description_block.html
    └── related_articles_block.html

3. Models

myproject/apps/articles/models.py

Create the Category and Article Django models, and the ArticleIndexPage routable Wagtail page with article list and detail views:

from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db import models
from django.shortcuts import get_object_or_404
from django.utils.translation import gettext_lazy as _

from wagtail.admin.panels import FieldPanel, ObjectList, TabbedInterface
from wagtail.contrib.routable_page.models import RoutablePageMixin, path
from wagtail.fields import StreamField
from wagtail.models import Page

from .blocks import article_detail_layout_blocks


class Category(models.Model):
    name = models.CharField(max_length=100, verbose_name=_("name"))
    slug = models.SlugField(unique=True, verbose_name=_("slug"))

    class Meta:
        verbose_name = _("category")
        verbose_name_plural = _("categories")

    def __str__(self):
        return self.name


class Article(models.Model):
    title = models.CharField(max_length=255, verbose_name=_("title"))
    slug = models.SlugField(unique=True, verbose_name=_("slug"))
    category = models.ForeignKey(
        Category,
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="articles",
        verbose_name=_("category"),
    )
    cover_image = models.ForeignKey(
        "wagtailimages.Image",
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name="+",
        verbose_name=_("cover image"),
    )
    description = models.TextField(blank=True, verbose_name=_("description"))
    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("created at"))

    class Meta:
        verbose_name = _("article")
        verbose_name_plural = _("articles")

    def __str__(self):
        return self.title


class ArticleIndexPage(RoutablePageMixin, Page):
    """
    A single Wagtail page that owns:
      - /articles/          → paginated list of all Articles
      - /articles/<slug>/   → detail view for one Article

    The StreamField is edited once in the Wagtail admin and
    defines the layout for every detail view.
    """

    articles_per_page = models.IntegerField(default=10, verbose_name=_("articles per page"))

    detail_layout = StreamField(
        article_detail_layout_blocks(),
        blank=True,
        use_json_field=True,
        verbose_name=_("detail layout"),
        help_text=_(
            "Configure the layout for all article detail pages. "
            "Add, remove, and reorder blocks to change what appears "
            "on every article detail view."
        ),
    )

    # TabbedInterface gives List View and Detail View their own tabs.
    # promote_panels and settings_panels must be added explicitly here
    # because edit_handler takes full ownership of the admin UI structure.
    edit_handler = TabbedInterface([
        ObjectList(Page.content_panels + [FieldPanel("articles_per_page")], heading=_("List View")),
        ObjectList([FieldPanel("detail_layout")], heading=_("Detail View")),
        ObjectList(Page.promote_panels, heading=_("SEO / Promote")),
        ObjectList(Page.settings_panels, heading=_("Settings")),
    ])

    class Meta:
        verbose_name = _("article index page")
        verbose_name_plural = _("article index pages")

    @path("")
    def article_list(self, request):
        all_articles = Article.objects.select_related("category", "cover_image").order_by("-created_at")
        paginator = Paginator(all_articles, self.articles_per_page)
        page_number = request.GET.get("page")

        try:
            articles = paginator.page(page_number)
        except PageNotAnInteger:
            articles = paginator.page(1)
        except EmptyPage:
            articles = paginator.page(paginator.num_pages)

        return self.render(
            request,
            context_overrides={"articles": articles, "paginator": paginator},
            template="articles/article_list.html",
        )

    @path("<slug:article_slug>/")
    def article_detail(self, request, article_slug):
        article = get_object_or_404(
            Article.objects.select_related("category", "cover_image"),
            slug=article_slug,
        )
        return self.render(
            request,
            context_overrides={"article": article},
            template="articles/article_detail.html",
        )

4. StreamField Blocks

myproject/apps/articles/blocks.py

Create Wagtail stream-field blocks for the cover image, description, and the related articles of an actual article. Each block can have some settings on how to represent the content of the block.

from django.utils.translation import gettext_lazy as _

from wagtail import blocks


class CoverImageBlock(blocks.StructBlock):
    aspect_ratio = blocks.ChoiceBlock(
        choices=[
            ("16-9", _("16:9 Widescreen")),
            ("4-3", _("4:3 Standard")),
            ("1-1", _("1:1 Square")),
            ("3-1", _("3:1 Banner")),
        ],
        default="16-9",
        label=_("Aspect ratio"),
        help_text=_("Controls the cropping of the cover image."),
    )

    class Meta:
        template = "articles/blocks/cover_image_block.html"
        icon = "image"
        label = _("Cover Image")


class DescriptionBlock(blocks.StructBlock):
    max_lines = blocks.IntegerBlock(
        min_value=0,
        default=0,
        label=_("Maximum lines"),
        help_text=_("Clamp the description to this many lines. Set to 0 to show all."),
        required=False,
    )

    class Meta:
        template = "articles/blocks/description_block.html"
        icon = "pilcrow"
        label = _("Description")


class RelatedArticlesBlock(blocks.StructBlock):
    sort_order = blocks.ChoiceBlock(
        choices=[
            ("newest", _("Newest first")),
            ("oldest", _("Oldest first")),
            ("title_asc", _("Title A → Z")),
            ("title_desc", _("Title Z → A")),
        ],
        default="newest",
        label=_("Sort order"),
        help_text=_("Order in which related articles are listed."),
    )

    def get_context(self, value, parent_context=None):
        context = super().get_context(value, parent_context=parent_context)
        article = (parent_context or {}).get("article")
        if not article or not article.category_id:
            context["related_articles"] = []
            return context

        from .models import Article

        sort_map = {
            "newest": "-created_at",
            "oldest": "created_at",
            "title_asc": "title",
            "title_desc": "-title",
        }

        context["related_articles"] = (
            Article.objects.select_related("category", "cover_image")
            .filter(category=article.category)
            .exclude(pk=article.pk)
            .order_by(sort_map.get(value["sort_order"], "-created_at"))[:3]
        )
        return context

    class Meta:
        template = "articles/blocks/related_articles_block.html"
        icon = "list-ul"
        label = _("Related Articles")


def article_detail_layout_blocks():
    """
    Returns the list of (name, block) tuples used in ArticleIndexPage.detail_layout.
    Defined as a function so models.py can import it without circular issues.
    """
    return [
        ("cover_image", CoverImageBlock()),
        ("description", DescriptionBlock()),
        ("related_articles", RelatedArticlesBlock()),
    ]

The RelatedArticlesBlock here also has a customized context where we pass related_articles variable with 3 other articles of the same category sorted by the sorting order defined in the block.

5. Templates

articles/article_list.html

This will be the template for the paginated article list. Later you could augment it with a search form and filters.

{% extends "base.html" %}
{% load wagtailcore_tags wagtailimages_tags i18n wagtailroutablepage_tags %}

{% block content %}
<main class="article-index">
  <h1>{{ page.title }}</h1>
  <ul class="article-list">
    {% for article in articles %}
      <li class="article-card">
        {% if article.cover_image %}{% image article.cover_image width-400 as img %}
          <img src="{{ img.url }}" alt="{{ article.title }}">
        {% endif %}
        <h2>
          <a href="{% routablepageurl page "article_detail" article.slug %}">{{ article.title }}</a>
        </h2>
        {% if article.category %}<span class="badge">{{ article.category.name }}</span>{% endif %}
        <p>{{ article.description|truncatewords:30 }}</p>
      </li>
    {% empty %}
      <li>{% trans "No articles yet." %}</li>
    {% endfor %}
  </ul>
  {% if articles.has_other_pages %}
    <nav class="pagination" aria-label="{% trans 'Article pagination' %}">
      {% if articles.has_previous %}
        <a href="?page={{ articles.previous_page_number }}">{% trans "← Previous" %}</a>
      {% endif %}
      <span>{% blocktrans with num=articles.number total=articles.paginator.num_pages %}Page {{ num }} of {{ total }}{% endblocktrans %}</span>
      {% if articles.has_next %}
        <a href="?page={{ articles.next_page_number }}">{% trans "Next →" %}</a>
      {% endif %}
    </nav>
  {% endif %}
</main>
{% endblock %}

articles/article_detail.html

The detail page would use the {% include_block page.detail_layout with article=article page=page %} to pass the article to the context of each block:

{% extends "base.html" %}
{% load i18n wagtailcore_tags wagtailroutablepage_tags %}

{% block content %}
<article class="article-detail">
  <header>
    <h1>{{ article.title }}</h1>
    {% if article.category %}<span class="badge">{{ article.category.name }}</span>{% endif %}
  </header>
  {% include_block page.detail_layout with article=article page=page %}
  <p>
    <a href="{% routablepageurl page "article_list" %}">{% trans "← Back to all articles" %}</a>
  </p>
</article>
{% endblock %}

articles/blocks/cover_image_block.html

Cover image block would show the article cover image with the aspect ratio set in the block:

{% load wagtailimages_tags %}

{% if article.cover_image %}
  <div class="cover-image cover-image--{{ value.aspect_ratio }}">
    {% image article.cover_image width-1200 as img %}
    <img src="{{ img.url }}" alt="{{ article.title }}">
  </div>
{% endif %}

articles/blocks/description_block.html

Description block would hide the article description text overflow based on the max lines set in the block:

<section class="article-description">
  <p{% if value.max_lines > 0 %} class="line-clamp" style="-webkit-line-clamp: {{ value.max_lines }};"{% endif %}>
    {{ article.description }}
  </p>
</section>

articles/blocks/related_articles_block.html

The related articles block would list the related articles as defined in the extra context of the block:

{% load i18n wagtailimages_tags wagtailroutablepage_tags %}

{% if related_articles %}
  <section class="related-articles">
    <h2>{% trans "Related Articles" %}</h2>
    <ul class="related-articles__list">
      {% for rel in related_articles %}
        <li class="related-card">
          {% if rel.cover_image %}{% image rel.cover_image width-400 as img %}
            <img src="{{ img.url }}" alt="{{ rel.title }}">
          {% endif %}
          <div class="related-card__body">
            {% if rel.category %}<span class="badge">{{ rel.category.name }}</span>{% endif %}
            <h3>
              <a href="{% routablepageurl page "article_detail" rel.slug %}">{{ rel.title }}</a>
            </h3>
            <p>{{ rel.description|truncatewords:20 }}</p>
          </div>
        </li>
      {% endfor %}
    </ul>
  </section>
{% endif %}

6. Django Admin Registration

articles/admin.py

Let's not forget to register admin views for the categories and articles so that we can add some data there:

from django.contrib import admin
from .models import Article, Category


@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
    list_display = ("name", "slug")
    prepopulated_fields = {"slug": ("name",)}


@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
    list_display = ("title", "category", "created_at")
    list_filter = ("category",)
    search_fields = ("title", "description")
    prepopulated_fields = {"slug": ("title",)}

7. Migrations and Initial Data

python manage.py makemigrations articles
python manage.py migrate
python manage.py createsuperuser
python manage.py runserver

8. Wagtail Admin Setup

  1. Open http://localhost:8000/cms/ and log in.
  2. In the Pages explorer, create an Article Index Page as a child of the root page.
    • Set the Slug to articles.
  3. On the List View tab, set Articles per page (e.g. 24).
  4. On the Detail View tab, open the Detail Layout StreamField and add blocks in your preferred order:
    • Cover Image - choose an aspect ratio.
    • Description - optionally set a maximum line count to clamp long descriptions.
    • Related Articles - choose the sort order for the three related articles shown.
  5. Publish the page.
  6. In the Django admin (/django-admin/), create some Categories and Articles with cover images and descriptions.
  7. Visit http://localhost:8000/articles/ for the paginated list.
  8. Click any article to see the detail view rendered using the StreamField layout you configured in step 4.

Final words

Using stream fields we can render not only editorial content, for example, images or rich-text descriptions, but also dynamic content based on values from other models and/or the context of the given template.

The approach illustrated in this article allows us to create Wagtail pages where content editors have freedom to adjust the layouts of the pages or insert blocks, such as ads or info texts, into specific places based on real-time events.

21 Mar 2026 5:00pm GMT

20 Mar 2026

feedDjango community aggregator: Community blog posts

How to Show a Waitlist Until Your Wagtail Site Is Ready

This year, I want to bring my centralized gamified donation platform www.make-impact.org to life (at least technically). Earlier I had the version I was developing separate from the waiting list, but I decided to merge them and have a switch between the waitlist and an early preview.

This allows me to have no data duplication, the possibility to create user accounts immediately, and saves hosting and maintenance costs.

This guide walks through a pattern that lets you ship a temporary waitlist page while your Wagtail site is still being built, with the ability to show your progress to chosen people. If you are building a Software as a Service (SaaS) or a web platform with Django, this article is for you.

Waitlist

The Concept

A custom start page view will check for a specific cookie value. If it is unset, the visitor will be redirected to a waitlist form at /waitlist/. If it is set, the visitor will be served the Wagtail home page.

All views under development will have a decorator that checks the cookie value and redirects to the start page if it is unset.

There will be a special view at /preview-access/ with a passphrase form that allows the visitor to gain preview access by setting the mentioned cookie. This view will also allow preview access to be deactivated.

These are the steps to implement this:

1. Generate and store two secrets

You will need two secret values, either set manually or generated with a cryptographically secure random generator (e.g. Python's secrets module):

>>> import secrets
>>> print(secrets.token_urlsafe(16))  # passphrase
dI5nGNftZOBx8m-r0m6glg
>>> print(secrets.token_hex(32))      # cookie token
c1b7a76e3ad5cbfb1657fa4e9885a3c8baa6a5a869f49a136abd0e873a9be9ee

Add both to environment variables or a secrets file untracked by Git, and load them in the Django project settings:

# myproject/settings/_base.py
PREVIEW_ACCESS_PASSPHRASE = get_secret("PREVIEW_ACCESS_PASSPHRASE")
PREVIEW_ACCESS_TOKEN = get_secret("PREVIEW_ACCESS_TOKEN")

The get_secret() here is my custom function to retrieve a secret from the secrets source.

2. Create the access-control decorator

Create myproject/apps/misc/decorators.py. Every protected view will import from here.

# myproject/apps/misc/decorators.py
from functools import wraps

from django.conf import settings
from django.shortcuts import redirect


def preview_access_required(view_func):
    @wraps(view_func)
    def wrapper(request, *args, **kwargs):
        if request.COOKIES.get("preview_access") == settings.PREVIEW_ACCESS_TOKEN:
            return view_func(request, *args, **kwargs)
        return redirect("misc:home_page")
    return wrapper

The decorator compares the cookie against the opaque unguessable token from settings, so unless the token value is known, a random attacker cannot gain access by setting the cookie manually in DevTools.

3. Create the passphrase form

Create myproject/apps/misc/forms.py. The form will have a single required password field. Validation will reject any value that does not match the setting.

# myproject/apps/misc/forms.py
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _


class PreviewAccessForm(forms.Form):
    passphrase = forms.CharField(
        label=_("Passphrase"),
        widget=forms.PasswordInput(
            attrs={"autocomplete": "current-password"}
        ),
        required=True,
    )

    def clean_passphrase(self):
        value = self.cleaned_data["passphrase"]
        if value != settings.PREVIEW_ACCESS_PASSPHRASE:
            raise forms.ValidationError(
                _("Incorrect passphrase.")
            )
        return value

4. Build the cookie toggle view

Point your browser to /preview-access/. When access is off it shows a passphrase form; when access is on it shows a disable button.

# myproject/apps/misc/views.py
from django.conf import settings
from django.shortcuts import redirect, render

from .forms import PreviewAccessForm


def preview_access(request):
    has_access = request.COOKIES.get("preview_access") == settings.PREVIEW_ACCESS_TOKEN

    if request.method == "POST":
        if has_access:
            response = redirect("misc:home_page")
            response.delete_cookie("preview_access")
            return response

        form = PreviewAccessForm(request.POST)
        if form.is_valid():
            response = redirect("misc:home_page")
            response.set_cookie(
                "preview_access",
                settings.PREVIEW_ACCESS_TOKEN,
                httponly=True,
                samesite="Strict",
            )
            return response
    else:
        form = PreviewAccessForm()

    return render(
        request, 
        "preview_access/preview_access.html", 
        {"has_access": has_access, "form": form}
    )

Key points: - Disabling never requires the passphrase - the cookie is already proof of prior access. - The cookie is set with httponly=True (not readable by JavaScript) and samesite="Strict" (not sent on cross-site requests). - The cookie value is the opaque token, not "1", so it cannot be guessed.

The template renders the passphrase input only when not has_access, and shows field-level errors from the form if the passphrase is wrong.

5. Wrap the Wagtail catch-all with the decorator

Replace the default Wagtail catch-all route handler with a thin wrapper that enforces the same cookie check.

# myproject/apps/misc/views.py
from myproject.apps.misc.decorators import preview_access_required
from wagtail.views import serve as wagtail_serve


@preview_access_required
def serve_wagtail_page(request, path=""):
    return wagtail_serve(request, path)

Without this, a visitor who knows any Wagtail page URL could bypass the gate by typing it directly into the browser.

6. Build the proxy home page view

This view is the only entry point to the site. It decides what every visitor sees first.

# myproject/apps/misc/views.py
from django.conf import settings

from wagtail.models import Site
from wagtail.views import serve as wagtail_serve


def home_page(request):
    if request.COOKIES.get("preview_access") == settings.PREVIEW_ACCESS_TOKEN:
        # serve Wagtail directly
        site = Site.find_for_request(request)
        return wagtail_serve(request, "")  

    # redirect to waiting list
    return redirect("waiting_list")

Key point: the waiting_list view and a Wagtail Site and page must exist and be matched to the request domain before wagtail_serve is called.

7. Wire up the URLs

Django project URL rules:

# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import re_path

from wagtail.coreutils import WAGTAIL_APPEND_SLASH

from myproject.apps.misc import views as misc_views

if WAGTAIL_APPEND_SLASH:
    wagtail_serve_pattern = r"^((?:[\w\-]+/)*)$"
else:
    wagtail_serve_pattern = r"^([\w\-/]*)$"

urlpatterns += i18n_patterns(
    # ... all your other app URLs above ...

    # Catch-all - must be last
    re_path(
        wagtail_serve_pattern,
        misc_views.serve_wagtail_page,
        name="wagtail_serve"
    ),
)

The misc app URLs:

# myproject/apps/misc/urls.py
from django.urls import path

from . import views

app_name = "misc"

urlpatterns = [
    path("", views.home_page, name="home_page"),
    path("preview-access/", views.preview_access, name="preview_access"),
]

The waiting_list app URLs:

# myproject/apps/waiting_list/urls.py
from django.urls import path

from . import views

urlpatterns = [
    path("waitlist/", views.show_waiting_list_form, name="waiting_list"),
]

8. Protect every other app view

Import and apply @preview_access_required to every view that belongs to the real site. Class-based views can be wrapped at assignment time:

from myproject.apps.misc.decorators import preview_access_required

# Function-based view
@preview_access_required
def event_list(request): 
    ...

# Class-based view
event_list = preview_access_required(
    EventListView.as_view()
)

Waiting-list views, API views, social authentication views, and static/legal pages (/imprint/, /privacy/, etc.) must not receive this decorator - they need to remain publicly accessible.

Final words

You get a lot of benefits from this setup. The waitlist measures demand for your website while you are still building. Invited test users can evaluate your progress at any time. While you are developing the website, you do not necessarily need multiple servers. Launching later is also easier - no hassle or delays with domain IP updates and SSL certificates.

20 Mar 2026 5:00pm GMT