If you knew how many websites I maintained, you would want them all to look about the same too.
So there is a template now. A whole Django site that nobody will ever visit. I took the two sites I already had, kept the parts that were identical in both, and made that a third thing that exists only to be copied.
Here is what is identical.
The page layout. The navigation bar. The footer. The contact form. The icons, in all six sizes a phone might ask for.
How it keeps its logs. How it gets built. How it gets onto a server.
None of that is a decision anymore. It was made twice already.
And here is everything that changes between one site and the next.
Four lines. They sit at the top of settings.py, above all the framework noise, so they are the first thing you see when you open the file.
SITE_FQDN = "example.com"
SITE_NAME = "Example Website"
ADMIN_EMAIL = "sage@example.com"
GOOGLE_TAG_MANAGER = "GTM-XXXXXX"
The domain. The name of the business. The address that receives whatever a stranger types into the contact form. And the analytics identifier, so the traffic gets counted in the right place.
Everything downstream is computed off those four. The names the site will answer to. The address its mail goes out from. The words in the browser tab.
And a small function hands the business name to every page before it renders.
So the footer is not a footer I wrote for this client. It is the footer, reading line two.
def container_context(request):
return {
"GOOGLE_TAG_MANAGER" : settings.GOOGLE_TAG_MANAGER,
"site_name" : settings.SITE_NAME,
}
©2024–{% now "Y" %} {{ site_name }}. All Rights Reserved.
Four constants. Not a configuration system.
Then you go and write the actual website, which was always going to be the different part.
The template is the floor.
It was never meant to be the ceiling.
The Enormous Amount That Does Not Change
Welcome back.
If you knew how many websites I maintained, you would want them all to look about the same too.
Today the shop has a template. A complete Django site that nobody will ever visit, sitting in the repository next to the real ones, existing only to be copied.
And the interesting thing about a template is not the part that changes between sites. It is the enormous amount that does not.
It Was Not Designed
Start with where it came from, because it was not designed.
There were already two client sites running. Somebody sat down with both of them, took the parts that were identical in each, and made those parts into a third thing.
That is a very different act from writing a template up front. Up front, you are guessing at what will be shared. After two, you are not guessing. You can see it twice.
The old rule of thumb says do not abstract until the third occurrence. This is the third occurrence, and it arrived before its site did.
Four Lines
So what actually changes, site to site?
Four lines. They sit at the top of settings.py, above all the framework noise, so they are the first thing you see when you open the file.
SITE_FQDN = "example.com"
SITE_NAME = "Example Website"
ADMIN_EMAIL = "sage@example.com"
GOOGLE_TAG_MANAGER = "GTM-XXXXXX"
The domain. The display name of the business. The address that receives contact form submissions. And an analytics identifier.
Everything downstream is computed off those four. The list of hostnames the site will answer to is built from the domain. The address outgoing mail is sent from is built from the domain. The words in the browser tab are built from the display name. The notification email goes to the admin address.
ALLOWED_HOSTS = [SITE_FQDN]
email = EmailMessage(
subject=f"Contact Us Form Submission ({settings.SITE_FQDN})",
from_email=f"noreply@{settings.SITE_FQDN}",
to=[settings.ADMIN_EMAIL],
)
And a small function hands the display name and the analytics identifier to every page before it renders. Which is why the footer is not a footer somebody wrote for one client. It is the footer, reading line two.
def container_context(request):
return {
"GOOGLE_TAG_MANAGER" : settings.GOOGLE_TAG_MANAGER,
"site_name" : settings.SITE_NAME,
}
<title>{% block title %}Default Title{% endblock title %} | {{ site_name }}</title>
©2024–{% now "Y" %} {{ site_name }}. All Rights Reserved.
Not a Configuration System
Four constants. Not a configuration system.
That distinction is doing more work than it looks like. A configuration system means a schema, a loader, validation, documentation, and a second place to look when something is wrong.
Four assignments at the top of a file that the framework was going to read anyway means opening the file and reading down until the comments stop. There is no second place. There is nothing to learn.
Impossible to Leave In by Accident
Those four values start out filled in, which raises the obvious question of what they are filled in with.
For a while, they were the first client's real ones. The real domain, the real company name, a real person's real email address, and a live analytics container. Which is not a decision so much as a consequence of how the template was made, by deletion, out of a working site. The constants are what was left standing after everything below them came out.
Today all four become placeholders. The domain is example.com. The name is Example Website. The contact address is at the example domain. The analytics identifier keeps the exact shape of a real one, with its distinctive part replaced by capital X's.
And that shape is the point. A placeholder's job is to be impossible to leave in by accident. If a new site reaches the internet with its domain still set to example.com, that is visible on the first page load, to anybody, immediately. It cannot survive quietly.
The version that keeps a real value is not safer. It is a site that works perfectly and reports somebody else's traffic.
Two Words
Now the part that stays the same. Start with what you see first.
A brand new site's home page says two words. New site.
<div class="display-4">New site</div>
Not a hero image, not sample copy, not three columns of placeholder text about our mission. The front page is the single thing every site is guaranteed to replace, so the template does not pretend to have written it.
But the page it says those two words on is complete. There is a shared layout underneath it with a navigation bar, a footer, the icons in all six sizes a phone might ask for, and the manifest file that tells an operating system what to call the site when somebody saves it to a home screen.
<link rel="shortcut icon" href="{% static 'ico/favicon.ico' %}" type="image/x-icon" />
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'ico/apple-touch-icon.png' %}">
<link rel="icon" type="image/png" sizes="32x32" href="{% static 'ico/favicon-32x32.png' %}">
<link rel="icon" type="image/png" sizes="16x16" href="{% static 'ico/favicon-16x16.png' %}">
<link rel="manifest" href="{% static 'ico/site.webmanifest' %}">
{
"name": "",
"short_name": "",
"icons": [
{"src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png"},
{"src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png"}
],
"display": "standalone"
}
Born Dark
And the whole thing is dark.
That is declared once, as an attribute on the opening tag of the shared layout. Every site copied from this template is born dark. Turning one light means editing a single attribute, in one file, and every page in that site changes together.
<html lang="en" data-bs-theme="dark">
Which is exactly where you want a decision like that to live. Not repeated per page, not toggled per component, not negotiated. One attribute, one place, one answer.
Dark is good, and more to the point, dark is a defensible default that nobody has to argue about on day one.
The stylesheet underneath it is not the full download of a front end framework. It is assembled from an explicit list of imports. The reset layer, buttons, forms, the navigation bar, tables, transitions, and the alert strips.
@import "../../build/bootstrap/scss/reboot";
@import "../../build/bootstrap/scss/buttons";
@import "../../build/bootstrap/scss/forms";
@import "../../build/bootstrap/scss/navbar";
@import "../../build/bootstrap/scss/tables";
@import "../../build/bootstrap/scss/transitions";
@import "../../build/bootstrap/scss/alert";
So the compiled result contains what the site actually uses and not the rest of somebody's catalogue.
Which means the defaults are not merely present, they are small. Good enough to start building on, and cheap enough that starting on them is not something you will want to undo later.
The Piece I Would Defend Hardest
Then there is the contact form, and this is the piece I would defend hardest.
Every site this shop builds has one. They all look the same, they all do the same thing, and they all ask the same question.
Five Fields
Five fields. First name. Surname. Email address. The message. And a riddle.
first_name = forms.CharField(label="First Name")
last_name = forms.CharField(label="Last Name")
email = forms.EmailField(label="Email Address", validators=[EmailValidator])
message = forms.CharField(widget=forms.Textarea(),
label="How can I help you?")
antispam = forms.CharField(
label = "How do two deaf people speak at night?",
required=False,
)
Neon Sign Language
The riddle is: how do two deaf people speak at night?
The answer is neon sign language.
def clean_antispam(self):
data = self.cleaned_data["antispam"].lower()
if data != "neon sign language":
raise forms.ValidationError("Wrong! The answer is 'neon sign language' - try again!", code="invalid")
return data
For a while the answer was printed on the page directly above the box, in highlighter yellow, which made it a formality rather than a check. Today the hint is gone.
Get it wrong, and the site tells you the answer in the error message, after you have already tried. Wrong. The answer is neon sign language. Try again.
Which sounds like giving up on the whole idea, and is not, because the thing this check exists to stop never reads the error. A script fills every box with garbage, submits, gets rejected, and moves on to the next domain. The only creature that ever sees that sentence is a person who tried and missed.
It is a bot check that is rude to machines and kind to humans, which is the correct way round and almost never how these are built.
The riddle field also sits at the very end of the layout, after the submit button, rather than in the natural reading order between the contact details and the message. A person filling the form in reaches the message, writes it, sees the button, and then finds one more small thing below it. A person who is going to answer a riddle is going to answer it wherever it is.
"message",
Div(
Submit(
"submit",
"Send Message",
css_class="btn btn-lg btn-primary text-uppercase fw-bold cta-submit",
),
css_class="d-grid d-xxl-flex justify-content-xxl-end",
),
"antispam",
Every Check Lives on Its Field
The validation is the part that makes it feel finished.
Every check lives on its field, as a cleaning method the framework calls by name. First and last name have to be at least two characters, and the rejection messages have a voice: that is not your first name. That is not your surname.
def clean_first_name(self):
data = self.cleaned_data["first_name"]
if len(data) < 2:
raise forms.ValidationError("That is not your first name.")
return data
def clean_last_name(self):
data = self.cleaned_data["last_name"]
if len(data) < 2:
raise forms.ValidationError("That is not your surname.")
return data
Putting the checks there rather than in the submission handler buys a specific behaviour. When something is wrong, the framework redraws the form with everything the visitor typed still sitting in it, and pins each complaint underneath the field that caused it.
Compare that with where this started. Failing the check meant a blank white page with one sentence on it. Passing it meant a different blank white page with a different sentence on it. No navigation, no styling, no form, and whatever you had written was gone. Two exits, both of them a door onto a car park.
Now nothing throws you out. Everything happens on the page you were already on.
The email field is declared as an email field rather than as an ordinary text box, which is one word and buys three things at once. Format checking before anything is submitted. The right keyboard on a phone. And the browser's own address autofill.
email = forms.EmailField(label="Email Address", validators=[EmailValidator])
It also happens to be where the confirmation message gets sent, so a malformed address there used to become a delivery failure one line later, on a form that was otherwise perfectly happy.
Dispatch Successful
And then the banners, which are how every site in this fleet says anything at all to a visitor.
The shared layout carries a block at the top of the content area that walks any pending messages and draws each one as a coloured strip. Send a message that goes through, and the strip reads: dispatch successful. Which is the voice of the whole place. It reads like a terminal reporting that an operation completed.
messages.success(request, "Dispatch successful.")
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} d-flex align-items-center" role="alert">
There is a small table in the settings mapping the framework's message levels onto the styling framework's colour names, because the two vocabularies do not match and only one of them can win in the class attribute. It is a translation table between two naming schemes, kept in the one place both of them can see.
MESSAGE_TAGS = {
messages.DEBUG: 'info',
messages.INFO: 'info',
messages.SUCCESS: 'success',
messages.WARNING: 'warning',
messages.ERROR: 'danger',
}
Each strip gets an icon chosen by level. An octagon for errors, a diamond for warnings, a check in a circle for success.
And every one of those icons carries a written label that is invisible on screen and read out by screen readers. Error, colon. Warning, colon. Success, colon.
<i class="bi flex-shrink-0 me-2 bi-exclamation-octagon-fill" role="img" aria-label="Error:"></i>
<i class="bi flex-shrink-0 me-2 bi-exclamation-diamond-fill" role="img" aria-label="Warning:"></i>
<i class="bi flex-shrink-0 me-2 bi-check-circle-fill" role="img" aria-label="Success:"></i>
Because an icon is a picture and a colour is a colour, and neither of them says anything out loud. Without that label, somebody browsing by ear hears the message text and has no idea whether they were just congratulated or warned.
The strips sit inside the main content container, above the page's own content and below the navigation bar. Not floating over the page, not in a corner, not fading out after four seconds.
A strip of colour that stays put needs none of that, and it works with scripting switched off.
Without Ever Knowing It
Underneath the visible parts, the template carries the operational decisions too, and those are the ones a new site benefits from without ever knowing it.
Third party code lives in its own directory, one file each, included by name. Which means the shared layout is entirely markup this project wrote, and everything in that folder is code it did not. You can answer the question of what runs in a visitor's browser by listing one directory.
{% include 'gtm-head.html' %}
{% include 'gtm-body.html' %}
{% include 'bootstrap.html' %}
Logging is configured before there is anything to log. A rotating file, so it cannot grow until the disk is full. A format with the severity, the timestamp, and the module name on the front of every line, because an entry with no timestamp is a sentence with no answer to when. And the framework's inherited loggers switched off, which is a statement of ownership: what arrives in that file is what this file asked for.
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
'formatters': {
'timestamped': {
'format': '{levelname} {asctime} {module} {message}',
'style': '{',
},
},
"handlers": {
"file": {
"level": "WARNING",
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "django.log",
'formatter': 'timestamped',
},
},
"root": {
"handlers": ["file"],
"level": "WARNING",
},
"loggers": {
"django": {
"handlers": ["file"],
"level": "WARNING",
"propagate": False,
},
},
}
Mail in development does not leave the building. It prints to the terminal. You can test a contact form all afternoon without sending anybody forty emails.
And the settings file is one file with two modes, chosen by a single environment variable.
In Development
In development: debugging on, any hostname accepted, a throwaway key written in plain sight, mail to the console.
In Production
In production: debugging off, the hostname restricted to the site's real domain, real delivery, and the signing key read out of the environment by direct lookup, with no fallback value.
if os.environ.get("DEBUG_MODE", "False") == "True":
DEBUG = True
ALLOWED_HOSTS = ["*"]
SECRET_KEY = "thisisinsecure"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
else:
load_dotenv(dotenv_path=BASE_DIR / "_config" / "prod.env")
DEBUG = False
ALLOWED_HOSTS = [SITE_FQDN]
SECRET_KEY = os.environ["SECRET_KEY"]
EMAIL_BACKEND = "django_ses.SESBackend"
That last one is deliberate. A lookup with a helpful default would let a site start successfully with a known key, serve traffic, and sign session cookies with a value that is sitting in the source code. A lookup without one fails at import, immediately, naming the thing that is missing. It is the single place where a helpful default is the dangerous option.
Two Files That Contain Nothing
There are also two files in the template that contain nothing.
One line each, and the line is the one at the top of every shell script that just says this is a shell script. One of them runs during a deploy. The other runs when a developer opens the project.
#!/bin/bash
Both are wired into sequences that were previously sealed shut. Right now they do nothing, for every site.
But the next site that needs one strange thing done at deploy time has somewhere to put it. Somewhere that runs at a known moment, and does not require reaching into the machinery that every other site shares.
Nobody has to fork anything. They fill in a blank.
Look at What Is Not
And now the part that defines a template better than any list of what is in it. Look at what is not.
No content types. No pricing page. No platforms, integrations, or resources sections. No rows in the database. No customisation of the admin panel beyond what the framework ships with.
The second client's site has all of that, and none of it made the cut.
Which tells you what somebody decided a website is, at minimum. A page layout, a navigation bar, a footer, a contact form, a set of icons, and four constants.
Everything richer than that is one client's requirement rather than a shared foundation. If the third site needs content types, it will copy them out of the second site the same way this template was copied out of the first.
Both Directions
One last thing, and it is the change that makes all of the above keep paying.
The template stopped being a snapshot. When the banner block was built, it was built in the template and in the site that needed it. When the icon manifest paths were corrected, both copies were fixed and so was the source.
So improvements move in both directions now. Down into new sites when they are created, and up out of whichever site happens to be ahead.
Copy a folder. Change four lines. You get dark, a navigation bar, a footer that already knows the company's name, icons at every size, a contact form that keeps your words when it rejects them, a riddle, a coloured strip that says dispatch successful, logs with timestamps, and two empty files waiting for whatever this one turns out to need.
None of that is the website. All of it is the part you were going to build anyway, before you got to the part that is actually this client's.
The template is the floor. It was never meant to be the ceiling.