Pluggable first-run setup wizard for Django. On a fresh install, redirects every request to a configurable sequence of setup steps. Ships with one built-in step (create the first superuser); your project plugs in whatever extras it needs (database seed, tenant config, integration tokens, …).
Most Django projects need a first-run experience: someone has to create the first admin user and configure a handful of project-specific settings. The existing ecosystem covers half of this:
django-formtools— multi-step forms, but no "fresh install" detection or middleware redirect.django-setup-configuration— Maykin Media's YAML-driven config, no UI.django-initial-setup— abandoned since 2020, Django 3.1.
This package is the missing piece: a plugin-based registry of setup steps with middleware that redirects to the next incomplete one, so a fresh install walks an admin through configuration in the browser.
- Fresh-install detection — middleware redirects every request to the next incomplete setup step until the wizard finishes.
- Plugin registry — each step is a
SetupStepsubclass registered inAppConfig.ready(); ordered by anorderinteger. - Built-in admin user step — creates the first superuser via a
UserCreationFormadapted to yourAUTH_USER_MODEL, then logs them in. - Replaceable steps —
registry.unregister("admin_user")then register your own form/template if the built-in doesn't fit. - Access control —
requires_authentication/requires_superuserflags per step; custom logic viais_accessible(request). - Configurable skip rules —
FIRST_RUN_WIZARD_SKIP_PREFIXES/FIRST_RUN_WIZARD_SKIP_SUBSTRINGSto keep/metrics/,/healthz/, etc. out of the redirect loop.
uv add django-first-run-wizardpip install django-first-run-wizardsettings.py:
INSTALLED_APPS = [
# ...
"django.contrib.auth",
"django.contrib.contenttypes",
"first_run_wizard",
]
MIDDLEWARE = [
# ...
"django.contrib.auth.middleware.AuthenticationMiddleware",
"first_run_wizard.middleware.FirstRunWizardMiddleware", # AFTER auth
"django.contrib.messages.middleware.MessageMiddleware",
]urls.py:
from django.urls import include, path
urlpatterns = [
path("setup/", include("first_run_wizard.urls", namespace="first_run_wizard")),
# ...
]That's enough to get the built-in admin step working: visit any URL on
a fresh install → redirected to /setup/step/admin_user/ → fill in the
form → user is created, logged in, redirected to /.
Subclass SetupStep. Register it from an AppConfig.ready() hook.
# myproject/onboarding/steps.py
from first_run_wizard import SetupStep
from myproject.tenants.forms import TenantSetupForm
from myproject.tenants.models import Tenant
class CreateTenantStep(SetupStep):
name = "create_tenant" # unique slug; URL = /setup/step/create_tenant/
verbose_name = "Configure your organization"
order = 100 # runs after admin_user (order=0)
form_class = TenantSetupForm
template_name = "onboarding/create_tenant.html"
requires_superuser = True # only the just-created admin can run it
def is_complete(self):
return Tenant.objects.exists()# myproject/onboarding/apps.py
from django.apps import AppConfig
class OnboardingConfig(AppConfig):
name = "myproject.onboarding"
def ready(self):
from first_run_wizard import registry
from myproject.onboarding.steps import CreateTenantStep
registry.register(CreateTenantStep())The middleware now redirects in this order:
- Anonymous request, no users →
/setup/step/admin_user/(built-in). - After admin is created and logged in →
/setup/step/create_tenant/(your step, becauserequires_superuser=Trueand order=100). - When
Tenant.objects.exists()→ no more redirects; the site is live.
name = "admin_user",order = 0is_complete()checksget_user_model().objects.exists()- Form:
AdminUserCreationForm(subclass ofUserCreationFormadapted tosettings.AUTH_USER_MODEL) - On success: sets
is_staff=True, is_superuser=True, is_active=True, logs the user in viaModelBackend
To replace it (e.g. you want a different form / different model fields),
unregister and register your own in AppConfig.ready():
from first_run_wizard import registry
registry.unregister("admin_user")
registry.register(MyCustomAdminStep())| Attribute / method | Purpose |
|---|---|
name: str |
Unique slug. Used in URLs and registry lookup. |
verbose_name: str |
Human-readable label. |
order: int |
Lower = runs earlier. Default 100. |
form_class |
Django form / ModelForm class. |
template_name: str |
Template path. Should {% extends "first_run_wizard/base.html" %} (or your own base). |
requires_authentication: bool |
If True, anonymous users skip this step. |
requires_superuser: bool |
If True, only authenticated superusers see it. |
is_complete() |
Return True when the step is satisfied. Called on every request — keep it cheap. |
is_accessible(request) |
Override for custom access logic. Default: enforce requires_*. |
get_form_kwargs(request) |
Extra kwargs for form_class(**kwargs). |
get_context(request) |
Extra template context. |
on_complete(form, request) |
Hook after form.is_valid(). Default: form.save(). |
get_success_url(request) |
Redirect target after success. Default /. |
| Setting | Default | Purpose |
|---|---|---|
FIRST_RUN_WIZARD_SKIP_PREFIXES |
() |
Extra URL prefixes the middleware will not redirect (e.g. ("/metrics/", "/healthz/")). Always-on built-in defaults: /static/, /media/, /__debug__/, /admin/. |
FIRST_RUN_WIZARD_SKIP_SUBSTRINGS |
() |
Extra substrings (e.g. ("login", "logout")). Default migrate is always included. |
The wizard's own URLs (first_run_wizard:status and below) are
auto-skipped to avoid redirect loops.
The middleware's defaults are deliberately permissive: /static/,
/media/, /__debug__/, and /admin/ are skipped unconditionally,
which means a logged-in superuser can always reach Django admin — even
while project-specific wizard steps are still pending. Without this,
once admin_user was done but, say, create_tenant was not, the
superuser would be bounced back to the wizard on every /admin/* hit,
making admin effectively unreachable mid-setup.
FIRST_RUN_WIZARD_SKIP_PREFIXES extends (not replaces) the built-in
defaults. If your project mounts admin under a non-default URL, add
that prefix:
# settings.py
FIRST_RUN_WIZARD_SKIP_PREFIXES = ("/management/",) # custom admin pathMatching is path.startswith(prefix), so the prefix should include
trailing /. Anonymous visitors hitting /admin/login/ on a fresh
install with no users yet land on Django's default login screen — which
won't accept any credentials, since there are no users — so they
typically discover the wizard via the redirect from / instead.
| Django | 3.10 | 3.11 | 3.12 | 3.13 | 3.14 | Status |
|---|---|---|---|---|---|---|
| 5.2 LTS | ✓ | ✓ | ✓ | ✓ | ✓ | Active LTS (extended support Apr 2028) |
| 6.0 | — | — | ✓ | ✓ | ✓ | Mainstream Aug 2026, extended Apr 2027 |
Every ✓ above is covered by the CI matrix in .github/workflows/tests.yml.
The package ships with English source strings and a Polish translation
(pl). All user-facing strings — form labels, validation errors, step
verbose_names, and template content — go through Django's i18n
machinery (gettext_lazy / {% trans %} / {% blocktrans %}).
To activate translations in your project, make sure LocaleMiddleware
is in your MIDDLEWARE (between SessionMiddleware and
CommonMiddleware), USE_I18N = True, and your LANGUAGES list
includes pl:
MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", # ← add this
"django.middleware.common.CommonMiddleware",
# ...
]
LANGUAGES = [("en", "English"), ("pl", "Polski")]
USE_I18N = TrueThe browser's Accept-Language header then selects the active
translation. The bundled example/ project is wired this way.
The library and the bundled example/ project keep separate message
catalogs. makemessages scans the filesystem from your current
directory, so each catalog is extracted from its own subtree:
# Library strings — run from inside the package:
cd src/first_run_wizard
django-admin makemessages -l <lang> # e.g. de, fr, es, cs
# edit locale/<lang>/LC_MESSAGES/django.po
django-admin compilemessages# Example-project strings — run from inside example/:
cd example
python manage.py makemessages -l <lang>
# edit locale/<lang>/LC_MESSAGES/django.po
python manage.py compilemessagesDo not run makemessages from the repository root — it would
walk the entire tree, mix library and example strings into whichever
locale/ it writes to, and silently break the separation. CI has a
translations job that re-extracts both catalogs from sources and
fails the build if they drifted, so contamination is caught before
merge.
Requires the gettext toolchain (brew install gettext on macOS,
apt install gettext on Debian/Ubuntu). PRs adding more translations
are welcome.
git clone https://github.com/iplweb/django-first-run-wizard
cd django-first-run-wizard
uv sync --all-extras
uv run pytestMIT — see LICENSE.