Settings¶
Everything a project owner can configure lives in a single VANTAGE dict in Django
settings.
Typos are errors¶
A key absent from the recognised set is a typo, and it is refused at once — both when a
setting is first read and by manage.py check (vantage.E002):
>>> VANTAGE = {"SITE_TTLE": "Acme"}
ImproperlyConfigured: Unrecognised key(s) in the VANTAGE setting:
'SITE_TTLE' is not a Vantage setting. Did you mean 'SITE_TITLE'?
Values with a fixed set of choices are checked too (vantage.E003):
>>> VANTAGE = {"THEME": "sepia"}
ImproperlyConfigured: VANTAGE['THEME'] must be one of light, dark, system; got 'sepia'.
So are the two values rendered into every page's inline stylesheet, where a typo would
otherwise fail every render, the sign-in page included: BRAND_COLOR must be a hex
colour (vantage.E004) and BRAND_RADIUS a CSS length (vantage.E005), by the same
validators the settings screen applies to what it stores.
Where a value comes from¶
The database layer sits underneath attribute access rather than beside it, so every
existing read picks up a change made in the panel without knowing a database exists. It
is lazy (nothing touches the database at import or at ready()), failure-tolerant (a
missing table resolves silently to the file settings, which is what makes the migration
that creates the table possible to run), and cached per request.
Not every key can be changed from the UI — see what the settings screen deliberately cannot change. The UI column below says which ones can.
Branding¶
| Key | Default | UI | Effect |
|---|---|---|---|
SITE_TITLE |
"Django Vantage" |
✓ | Name in the sidebar, the page heading and the browser tab |
SITE_ACRONYM |
derived from SITE_TITLE |
✓ | Two-letter mark for the collapsed sidebar. "Acme Corporation" → AC |
LOGO |
None |
✓ | Static path or resolved URL, shown in the sidebar |
LOGO_DARK |
falls back to LOGO |
✓ | Used in the dark theme |
FAVICON |
None |
✓ | Browser tab icon |
BRAND_COLOR |
"#6366f1" |
✓ | The one colour the whole palette is derived from |
BRAND_RADIUS |
"0.5rem" |
✓ | --radius. Validated as a CSS length |
THEME |
"system" |
✓ | "light", "dark" or "system" |
THEME_TOGGLE |
True |
✓ | Whether users may switch theme. False pins everyone to THEME |
CUSTOM_CSS |
None |
— | A stylesheet loaded after Vantage's own |
LOGIN_IMAGE |
None |
✓ | Image beside the sign-in form, when you mount Vantage's auth views |
Details: Theming.
Discovery¶
| Key | Default | Effect |
|---|---|---|
AUTO_REGISTER |
True |
Register every eligible model automatically |
EXCLUDE |
[] |
App labels ("sessions") or model labels ("blog.Draft") to skip |
INCLUDE_ONLY |
None |
When set, only these are registered and EXCLUDE is ignored |
Both accept app labels and model labels, matched case-insensitively.
Auto-registration always skips abstract models and auto-created M2M through-tables, plus
a fixed list of framework plumbing: sessions.session, contenttypes.contenttype,
auth.permission, admin.logentry, and Vantage's own tables. auth.User and
auth.Group are deliberately not on that list — they are among the most useful
things to administer.
Vantage's own models are excluded for reasons rather than tidiness: a generic CRUD form
over SiteConfiguration would route around the superuser gate on the settings screen,
an editable audit log is not an audit log, and a generic form over a saved view routes
around every permission rule the feature has.
Proxy models are kept: they are a normal way to give a model a second, differently-configured presence in the panel.
Auto-registration runs after panel.py discovery and never clobbers an existing
registration.
Navigation¶
| Key | Default | Effect |
|---|---|---|
NAV |
None |
Declare the sidebar instead of deriving it |
NAV_UNLISTED |
"append" |
What happens to registered models NAV does not mention: "append" or "hide" |
DASHBOARD |
None |
Dotted path to a Dashboard subclass |
With NAV unset, navigation is derived: registering a model is enough to make it
reachable, grouped by app, with groups sorted by the lowest order of any panel in them
and items sorted alphabetically.
Setting NAV switches to declared:
VANTAGE = {
"NAV": [
{"route": "vantage:dashboard", "label": "Overview", "icon": "layout-dashboard"},
{"group": "Commerce", "items": [
{"model": "shop.Order", "badge": "live"},
{"model": "shop.Product", "label": "Catalogue"},
"shop.Category",
]},
{"group": "System", "items": [
{"route": "vantage:settings", "label": "Settings", "icon": "sliders"},
{"page": "schema", "label": "Schema", "icon": "share-2"},
]},
{"group": "Elsewhere", "icon": "shield", "items": [
{"url": "https://status.example.com", "label": "Status"},
]},
],
}
Each item needs exactly one of:
| Key | Points at |
|---|---|
model |
"app_label.ModelName", matched case-insensitively |
page |
A registered page's slug |
route |
A URL name, with optional args |
url |
A literal URL; external is inferred from the scheme |
Plus optional label, icon and badge. A bare string is shorthand for
{"model": "..."}. A top-level item outside any group renders without a heading — the
usual shape for a Dashboard link. A group takes group (or label), icon and
collapsed.
Three properties:
Declaring a nav can reorder, rename and hide — it can never grant access. Every entry still passes through the permission layer, so an entry for a model the user cannot open does not render.
Unmentioned models still appear by default, so registering a new model never makes
it silently unreachable. Set NAV_UNLISTED = "hide" for a strict sidebar.
A malformed entry raises; an unresolvable one does not. A typo is something the
author wants to hear about, and manage.py check reports it at startup
(vantage.E020, vantage.E021, vantage.W020) rather than leaving it to a request.
But a sidebar that 500s because someone lost a permission is a poor trade for
strictness, so an item that cannot be shown is omitted.
Behaviour¶
| Key | Default | UI | Effect |
|---|---|---|---|
PERMISSIONS |
None |
— | Dotted path to a PanelPermissions subclass |
SETTINGS_PERMISSION |
None |
— | Who may open the settings screen. None means superusers only |
LIST_PREFS_PERMISSION |
None |
— | Who may save a list's preferences for everyone. None means superusers only |
READ_ONLY |
False |
✓ | True, or a string that doubles as the explanation shown to users |
ENVIRONMENT |
None |
✓ | A banner across the top of every page |
AUDIT_LOG |
True |
— | Record what people do in the panel |
COMMAND_PALETTE |
True |
✓ | The ⌘K palette |
DEFAULT_PER_PAGE |
50 |
✓ | Rows per list page, unless a panel sets list_per_page |
RELATION_PICKER |
"always" |
— | "always" or "threshold". Whether a single-valued relation gets the searchable picker regardless of row count, or only above 50 — see Relations and the search threshold |
IMPORT_MAX_BYTES |
5242880 (5 MiB) |
— | Largest file the CSV importer will read |
IMPORT_MAX_ROWS |
5000 |
— | Most rows the CSV importer will read |
The login throttle, rate limits and upload ceiling are behaviour keys too, documented with their defaults in Security — see Two settings layers for why their defaults live there.
SETTINGS_PERMISSION and LIST_PREFS_PERMISSION each accept a permission string or a
callable taking the request:
VANTAGE = {
"SETTINGS_PERMISSION": "vantage.change_siteconfiguration",
"LIST_PREFS_PERMISSION": lambda request: request.user.groups.filter(name="ops").exists(),
}
Neither is editable from the UI. SETTINGS_PERMISSION in particular would be a
self-grant.
READ_ONLY is enforced in the permission layer, not in the views, so it covers every
write path including ones added later — and it is checked before the superuser
bypass. See Read-only mode.
Multi-tenancy¶
Off by default. Setting TENANT_MODEL is the only thing that turns any of it on — see
Multi-tenancy for the full design (fail-closed checks, resolvers, the
middleware requirement, and how writes are protected, not only reads).
| Key | Default | Effect |
|---|---|---|
TENANT_MODEL |
None |
The model every other registered model is scoped to, "app_label.ModelName". Every model must either resolve a path to it or be declared exempt once this is set |
TENANT_RESOLVER |
None |
Dotted path to a TenantResolver subclass or any callable taking a request. None means vantage.tenancy.UserAttributeResolver |
TENANT_FIELD |
"tenant" |
The attribute the default resolver reads off the user, and the conventional column name on tenant-owned models |
TENANT_SCOPE |
{} |
{"app.model": "lookup__path"} for anything inference cannot see on its own, and {"app.model": None} for a model that is deliberately shared across every tenant |
TENANT_EXEMPT |
[] |
Models every tenant shares — reference data, mostly |
TENANT_UNSCOPED |
"deny" |
What happens to a registered model with no path to the tenant and no exemption. "deny" shows no rows and is a manage.py check error (vantage.E060); "allow" shows every tenant's rows and downgrades the check to a warning (vantage.W060) |
None of the six are editable from the panel's own UI, deliberately: a tenant boundary is not a preference, and a signed-in user narrowing or widening it from inside the panel would not be a boundary.
VANTAGE = {"TENANT_MODEL": "accounts.Organisation"}
MIDDLEWARE = [
...,
"django.contrib.auth.middleware.AuthenticationMiddleware",
"vantage.tenancy.TenantMiddleware",
]
vantage.tenancy.TenantMiddleware is not optional once TENANT_MODEL is set —
manage.py check refuses to pass without it (vantage.E065), because a resolver
without the request published to it fails closed rather than resolving nothing.
Background jobs¶
A bulk action estimated at more than JOB_THRESHOLD_SECONDS of work is written as a
job row and worked off the request instead of run inline — see
Background jobs for the routing rule, the chunking and
retry behaviour, and the backends it is tried against.
| Key | Default | Effect |
|---|---|---|
JOBS |
True |
Whether large bulk actions may be offloaded at all. False always runs in the request, whatever the estimate |
JOB_THRESHOLD_SECONDS |
2.0 |
The line between running in the request and writing a job row, in seconds of estimated work |
JOB_CHUNK_SIZE |
500 |
Rows per transaction while a job runs |
JOB_MAX_STORED_ERRORS |
1000 |
Most per-row errors kept for the downloadable report |
JOB_RETENTION_DAYS |
30 |
How long a finished job's row is kept before it is swept |
JOB_QUEUE |
"default" |
Queue name passed to django.tasks or RQ. Celery uses its own routing instead |
Scale¶
What a list page does once the table outgrows the sample data. Everything is fast at 200 rows; these keys are about the other end.
| Key | Default | Effect |
|---|---|---|
LIST_COUNT_CAP |
10000 |
Rows counted exactly before the page reports 10,000+. A page-depth budget, not a data limit |
LIST_COUNT_ESTIMATES |
True |
Above the cap, replace 10,000+ with a planner estimate where one is available |
LIST_ESTIMATE_CACHE_SECONDS |
300 |
How long an estimate is cached |
LIST_KEYSET_THRESHOLD |
50000 |
Offset depth above which keyset paging is used. 0 disables it |
LIST_STATEMENT_TIMEOUT_MS |
10000 |
Per-statement ceiling for list-page queries. 0 disables it |
COUNT(*) on Postgres is always a full relation scan under MVCC, and it costs that
whether the page is showing 25 rows or two million. Details, and the reasoning behind
each number: Deployment.
Plugins¶
| Key | Default | Effect |
|---|---|---|
PLUGINS |
None |
An allowlist. When set, only these plugins load |
PLUGINS_EXCLUDE |
[] |
A denylist |
Both are matched against the entry-point name or the plugin's own name. Installing a
package must be reversible without uninstalling it, particularly when the reason for
turning it off is that it is broken. See Extending.
The complete list¶
Every key in vantage.conf.DEFAULTS, and nothing else — tests/test_release.py fails
if the two drift apart:
SITE_TITLE SITE_ACRONYM LOGO LOGO_DARK FAVICON
BRAND_COLOR BRAND_RADIUS THEME THEME_TOGGLE
CUSTOM_CSS LOGIN_IMAGE
AUTO_REGISTER EXCLUDE INCLUDE_ONLY
NAV NAV_UNLISTED DASHBOARD
PLUGINS PLUGINS_EXCLUDE
PERMISSIONS SETTINGS_PERMISSION LIST_PREFS_PERMISSION
READ_ONLY ENVIRONMENT AUDIT_LOG COMMAND_PALETTE
DEFAULT_PER_PAGE RELATION_PICKER
LOGIN_THROTTLE LOGIN_THROTTLE_LIMIT LOGIN_THROTTLE_WINDOW
LOGIN_THROTTLE_CACHE LOGIN_THROTTLE_IP_HEADER
RATE_LIMIT RATE_LIMIT_CACHE RATE_LIMITS UPLOAD_MAX_BYTES
IMPORT_MAX_BYTES IMPORT_MAX_ROWS
TENANT_MODEL TENANT_RESOLVER TENANT_FIELD
TENANT_SCOPE TENANT_EXEMPT TENANT_UNSCOPED
JOBS JOB_THRESHOLD_SECONDS JOB_CHUNK_SIZE
JOB_MAX_STORED_ERRORS JOB_RETENTION_DAYS JOB_QUEUE
LIST_COUNT_CAP LIST_COUNT_ESTIMATES LIST_ESTIMATE_CACHE_SECONDS
LIST_KEYSET_THRESHOLD LIST_STATEMENT_TIMEOUT_MS
Reading a setting in your own code¶
Always through that object, never settings.VANTAGE["SITE_TITLE"] directly: this layer
supplies the defaults, derives values, applies the database overrides and rejects typos.
Asking for a key that is not a Vantage setting raises AttributeError.
The cache is invalidated by Django's setting_changed signal, so override_settings
works as expected in tests.
Django settings that matter¶
| Setting | Why |
|---|---|
LOGIN_URL |
Where anonymous visitors are sent. Point it at Vantage's auth views or your own |
LOGIN_REDIRECT_URL |
Where they land afterwards |
MEDIA_ROOT / MEDIA_URL |
Required for logo uploads from the settings screen |
CACHES |
A dummy backend disables facet-count caching; the system page warns |
STATIC_ROOT |
collectstatic needs it in production |
LANGUAGE_CODE |
An RTL language flips the layout automatically |
AUTHENTICATION_BACKENDS |
Where django-guardian or django-rules is plugged in |
See also¶
- How configuration resolves — the order this dict is read in
relative to a
SiteConfigurationrow and a panel's own Python - Theming — branding in detail, and the settings screen
- Permissions —
PERMISSIONS,READ_ONLY,AUDIT_LOG - Multi-tenancy — the
TENANT_*family in full - Lists — the
JOB_*family, and what triggers an offload - Deployment — which of these to check before going live