All articles

Django 10 min read AgiCAD studio

Moving Sixteen Hardcoded Pages Into Database Rows Without Losing One

Sixteen blog posts lived as Django templates with their own view functions. Here is the method we used to turn them into rows without losing a paragraph or a URL.

Sixteen loose pages being filed neatly into a sixteen-drawer cabinet while a developer ticks each off

For a while this blog ran on two systems at once. Sixteen posts were hardcoded Django templates, each with its own view function and URL entry, listed on the index from a generated file called blog_data.py. Newer posts were database rows. Both were "the blog".

The symptom

The symptom was not an exception in a log. A one-word typo fix in a legacy post required editing a template, committing and deploying. Adding a cover image to one was impossible: the index rendered legacy posts from blog_data.py, and that file had no image field at all.

That is the real cost of content living in code: not slow, not insecure, simply not editable by the only mechanism the site has for editing content.

So all sixteen went into rows, the view functions and URL entries were deleted, blog_data.py was removed, and the old templates were archived. What follows is the method, because "content living in code needs to become data" is a job we have done on enough Django and Python projects to have a checklist for it.

Inventory from the URL map, not the filesystem

The instinct is to list the template directory and count files. Do not. The filesystem tells you which templates exist, not which URLs the public can reach, and those are two different sets. One of the sixteen had a template filename that did not match the URL segment it was served at; had we generated slugs from filenames - the obvious one-liner - that post would have moved and the old URL would have started returning 404 to every inbound link.

The authoritative list is the URL configuration. Walk it, resolve every pattern pointing at a view function you are about to delete, and record the path each one actually answers. That path becomes the slug, so one generic detail view answers exactly the addresses the sixteen per-post views did.

Capture a BEFORE snapshot

You cannot prove you lost nothing unless you wrote down what was there. Before touching a line we fetch every public URL, extract the visible text as a list of blocks, and store it as JSON. Django's test client goes through the real resolver, views and templates, in-process, with no server to start. One gotcha: it sends Host: testserver, which a production ALLOWED_HOSTS rejects with a 400 before your view runs. Every URL comes back 400 and the snapshot looks fine because it is uniformly empty. Send a host the settings allow.

#!/usr/bin/env python
"""Snapshot every public URL's text, and diff two snapshots.

    python snapshot.py capture before.json /blog/ /blog/some-post/
    python snapshot.py diff before.json after.json
"""
import json
import os
import re
import sys

BLOCK_RE = re.compile(
    r'<(p|h2|h3|li|blockquote|pre)\b[^>]*>(.*?)</\1>', re.S | re.I)
TAG_RE = re.compile(r'<[^>]+>')
WS_RE = re.compile(r'\s+')


def blocks(html):
    """Every visible text block, tags stripped and whitespace normalised."""
    out = []
    for _, inner in BLOCK_RE.findall(html):
        text = WS_RE.sub(' ', TAG_RE.sub(' ', inner)).strip()
        if text:
            out.append(text)
    return out


def capture(urls):
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'agicad.settings')
    import django
    django.setup()
    from django.test import Client

    # The test client sends Host: testserver, which a production
    # ALLOWED_HOSTS will reject with a 400. Send a host the settings allow.
    host = os.environ.get('SNAPSHOT_HOST', 'localhost')
    client = Client()
    snap = {}
    for url in urls:
        response = client.get(url, follow=True, HTTP_HOST=host)
        body = response.content.decode('utf-8', 'replace')
        snap[url] = {'status': response.status_code, 'blocks': blocks(body)}
        print('%s %s %d blocks' % (response.status_code, url, len(snap[url]['blocks'])))
    return snap


def diff(before, after):
    problems = []
    for url, old in before.items():
        new = after.get(url)
        if new is None:
            problems.append('%s: missing from the after snapshot' % url)
            continue
        if new['status'] != old['status']:
            problems.append('%s: status %s -> %s' % (url, old['status'], new['status']))
        lost = [b for b in old['blocks'] if b not in new['blocks']]
        for b in lost:
            problems.append('%s: lost block %r' % (url, b[:80]))
    return problems


def load(path):
    with open(path, encoding='utf-8') as fh:
        return json.load(fh)


if __name__ == '__main__':
    mode, target = sys.argv[1], sys.argv[2]
    if mode == 'capture':
        with open(target, 'w', encoding='utf-8') as fh:
            json.dump(capture(sys.argv[3:]), fh, indent=1, sort_keys=True)
        sys.exit(0)
    before = load(target)
    found = diff(before, load(sys.argv[3]))
    for problem in found:
        print('FAIL', problem)
    print('%d URL(s) compared, %d problem(s)' % (len(before), len(found)))
    sys.exit(1 if found else 0)

Comparing block lists rather than raw HTML matters. Raw HTML changes for innocent reasons - a wrapper div, whitespace, a CSRF token. Extracted text changes when you lose a paragraph.

Write the importer as an idempotent command

Make it a management command, not a data migration and not a script you throw away. It has to survive running twice, on a fresh database, after a rollback and in CI - which means a stable business key. The slug is that key; an auto-increment id is not, because ids differ between laptop, staging and production.

The clobber question

Every importer has to answer one question, and most answer it by accident: once a human can also edit the row, who wins?

Our answer is a fingerprint. On every write the importer stores a SHA-256 digest of the field values it wrote. On the next run it re-digests the row's current values. A match means nobody has touched the row, so the file wins. A mismatch means a human edited it, so the importer skips it with a warning rather than destroying somebody's work. --force exists for when you mean it.

The two obvious alternatives are worse. Comparing the file's mtime fails because a git checkout rewrites mtimes: after every deploy the importer decides its files are newer than every row and clobbers every edit since the last release. Comparing updated_at fails for the opposite reason - every importer run bumps it, so it cannot distinguish the importer's own write from a human's.

Two details make the digest trustworthy: the field names go into the hash alongside the values, so moving a value between fields changes it; and the field order is declared once and fixed.

"""Import content/articles/*.html into Article rows, idempotently."""
import hashlib
import os
from datetime import date

from django.conf import settings
from django.core.management.base import BaseCommand

from website.models import Article

#: The fields the importer owns. The ORDER is part of the fingerprint.
MANAGED_FIELDS = ('title', 'category', 'body_html', 'published_at')


class ArticleError(ValueError):
    """The source file is not usable."""


def parse_article(text, filename='<string>'):
    """(front matter dict, body html)."""
    stripped = text.lstrip(' \t\r\n')
    if not stripped.startswith('<!--'):
        raise ArticleError('%s: file must start with an <!-- ... --> header' % filename)
    end = stripped.find('-->')
    if end == -1:
        raise ArticleError('%s: header is never closed with -->' % filename)

    header, body = stripped[4:end], stripped[end + 3:].strip()
    meta = {'category': 'Engineering'}
    for lineno, raw in enumerate(header.splitlines(), start=2):
        line = raw.strip()
        if not line:
            continue
        if ':' not in line:
            raise ArticleError('%s line %d: expected "key: value"' % (filename, lineno))
        key, value = line.split(':', 1)
        meta[key.strip().lower()] = value.strip()

    for key in ('title', 'slug', 'publish_date'):
        if not meta.get(key):
            raise ArticleError('%s: missing required header key %r' % (filename, key))
    if not body:
        raise ArticleError('%s: body is empty' % filename)

    try:
        y, m, d = (int(part) for part in meta['publish_date'].split('-'))
        meta['publish_date'] = date(y, m, d)
    except (TypeError, ValueError):
        raise ArticleError('%s: publish_date must be YYYY-MM-DD' % filename)
    return meta, body


def article_to_fields(meta, body):
    return {
        'title': meta['title'],
        'category': meta['category'],
        'body_html': body,
        'published_at': meta['publish_date'],
    }


def fingerprint(values):
    """Stable digest of the managed field values. Names are hashed too."""
    h = hashlib.sha256()
    for name in MANAGED_FIELDS:
        h.update(name.encode('utf-8'))
        h.update(b'\x00')
        h.update(str(values[name]).encode('utf-8'))
        h.update(b'\x1e')
    return h.hexdigest()


def row_fingerprint(row):
    return fingerprint({name: getattr(row, name) for name in MANAGED_FIELDS})


class Command(BaseCommand):
    help = 'Upsert content/articles/*.html into Article rows (idempotent).'

    def add_arguments(self, parser):
        parser.add_argument('--dry-run', action='store_true')
        parser.add_argument('--force', action='store_true')

    def handle(self, *args, **options):
        directory = os.path.join(settings.BASE_DIR, 'content', 'articles')
        dry_run, force = options['dry_run'], options['force']
        created = updated = unchanged = skipped = failed = 0

        if not os.path.isdir(directory):
            self.stderr.write('no such directory: %s' % directory)
            return

        seen = {}
        for name in sorted(os.listdir(directory)):
            if not name.endswith('.html'):
                continue
            path = os.path.join(directory, name)
            with open(path, encoding='utf-8') as fh:
                text = fh.read()
            try:
                meta, body = parse_article(text, name)
            except ArticleError as exc:
                failed += 1
                self.stderr.write('ERROR %s' % exc)
                continue

            slug = meta['slug']
            if slug in seen:
                failed += 1
                self.stderr.write('ERROR %s: slug %r is %s\'s'
                                  % (name, slug, seen[slug]))
                continue
            seen[slug] = name

            fields = article_to_fields(meta, body)
            wanted = fingerprint(fields)
            row = Article.objects.filter(slug=slug).first()

            if row is None:
                created += 1
                self.stdout.write('create %s' % slug)
                if not dry_run:
                    Article.objects.create(slug=slug, source_file=name,
                                           fingerprint=wanted, **fields)
                continue

            if not row.source_file:
                skipped += 1
                self.stderr.write('WARNING %s: slug %r was authored in the admin'
                                  % (name, slug))
                continue

            current = row_fingerprint(row)
            if current == wanted and row.fingerprint == wanted:
                unchanged += 1
                continue
            if row.fingerprint and current != row.fingerprint and not force:
                skipped += 1
                self.stderr.write('WARNING %s: row %r edited by hand since the last '
                                  'import; skipping (--force overwrites)'
                                  % (name, slug))
                continue

            updated += 1
            self.stdout.write('update %s' % slug)
            if not dry_run:
                for key, value in fields.items():
                    setattr(row, key, value)
                row.source_file = name
                row.fingerprint = wanted
                row.save()

        self.stdout.write('%screated=%d updated=%d unchanged=%d skipped=%d errors=%d'
                          % ('[dry-run] ' if dry_run else '',
                             created, updated, unchanged, skipped, failed))

Note the third case in handle(): a row with an empty source_file was authored in the admin and is never touched. An importer that owns rows it did not create will eventually eat something. We ran the digest logic on its own before trusting it with content:

digest       0dd2dca46699ab68df95961f32bfbf5b6f368fd1edcbaf4913c8476816039d2a
reordered    23e32955dacf2530258c1fdea7c2abd6c21acf2eaab3a696a2daec881904834b
order counts True
round-trip   True
human edit   False <- False: edit detected
swapped      True <- names are hashed

Prove parity, then delete the old path

After the import, snapshot again and diff. On the real migration that meant fetching all eighteen post URLs before and after: zero lost blocks, every URL still a 200. Here is the harness, run against a clean snapshot and then a deliberately damaged one so we know the diff fails when it should:

$ PYTHONPATH=. venv/bin/python snapshot.py capture before.json \
      /about/ /services/ /contact/ /web-app-development/
200 /about/ 87 blocks
200 /services/ 110 blocks
200 /contact/ 40 blocks
200 /web-app-development/ 144 blocks

$ venv/bin/python snapshot.py diff before.json after.json
4 URL(s) compared, 0 problem(s)

$ venv/bin/python snapshot.py diff before.json after-with-a-deliberate-regression.json
FAIL /contact/: lost block 'case studies'
FAIL /services/: status 200 -> 404
4 URL(s) compared, 2 problem(s)

Then, in the same change and not in a follow-up ticket, delete the old path: view functions, URL entries, generated index data. Leaving both alive because "we can clean it up later" is how you get two sources of truth again. Archive the old templates rather than deleting them - they are the only record of what a page used to look like if the diff ever disagrees with you.

One bug the migration fixed for free

Post bodies are stored as raw HTML and rendered with |safe, never passed through the template engine. That fixed a live bug: one post contained GitHub Actions examples, and while it was a template the engine ate ${{ secrets.PROD_HOST }} as a variable expression and rendered the line as host: $. Content is not a template - the moment an author can write braces, running their text through a template engine is a rendering bug waiting to happen.

The consequence we accepted is worth stating plainly: nothing in the repo validates the HTML in that column. The admin is the only editor and it is superuser-only, so the trust boundary is "anyone who can log in as a superuser can already do worse". With untrusted authors that answer is wrong and you want sanitisation on save.

When not to do this

If the content never changes and has nobody to edit it - a legal notice, a single landing page - a template is fine. Better than fine: it is version-controlled and diffable, which a database row is not. A migration you do not need is pure risk with no payoff.

The trigger is not "this content is in a template". It is "somebody who is not a developer needs to change it, and cannot".

If you have an archive stuck in code and a deploy standing between your client and a typo fix, that is the kind of work we take on across our development services. Start a project.

We ship and run our own products. We build yours the same way.

Start a project

Join the article list

We are collecting addresses now and will email new articles once the newsletter starts. No spam, unsubscribe any time.

Hour Blocks from $180reply within two business days Start a project