django-consent-trail
Versioned, multilingual legal documents for Django — with timestamped proof of who accepted what, and when.
Status: alpha, private. The API may still change without notice.
Why another one
Two good packages already exist — django-termsandconditions and django-tos. Both were evaluated and run against Django 6 before this one was written. Two gaps drove the decision:
1. Neither has any concept of language. The only multilingual path is one slug per language, but acceptance is a foreign key to one specific row — so a user who accepts the terms in French and then switches the UI to English reads as never having accepted. That is wrong: you accept the document, not a translation of it.
consent_trail stores acceptance as the coordinates (doc_type, version).
Version numbers are shared across languages, so consent survives a language
switch.
2. Neither can publish a cosmetic edit without re-prompting everyone.
Fixing a typo would wall off every user at their next login. Here,
requires_reacceptance is a per-version flag: substantive change, everyone
re-accepts; typo fix, nobody is disturbed.
django-tos additionally enforces exactly one globally active document, so it
cannot hold a legal notice and a privacy policy and terms of use.
Install
pip install django-consent-trail
# settings.py
INSTALLED_APPS = [
...,
"consent_trail.apps.ConsentTrailConfig",
]
MIDDLEWARE = [
...,
# Last: getting a user through signup and 2FA matters more than a terms update.
"consent_trail.middleware.AcceptanceMiddleware",
]
# urls.py
path("legal/", include("consent_trail.urls")),
python manage.py migrate
Settings
Every one is optional.
| Setting | Default | What it does |
|---|---|---|
CONSENT_TRAIL_BASE_TEMPLATE |
"consent_trail/base.html" |
Template the public pages extend. Point it at your own layout. |
CONSENT_TRAIL_AUTHORITATIVE_LANGUAGE |
"fr" |
The language whose text legally prevails — served when the requested one has no published version, and named in the prevalence notice. |
CONSENT_TRAIL_IP_HEADER_NAME |
"HTTP_CF_CONNECTING_IP" |
Read this one. See below. |
CONSENT_TRAIL_CACHE_SECONDS |
300 |
Caches "which documents currently require consent" — the middleware runs on every request. |
CONSENT_TRAIL_EXCLUDE_SUPERUSERS |
True |
Never lock an admin out of their own site over a bad flag. |
CONSENT_TRAIL_EXEMPT_PREFIXES |
("/legal/", "/accounts/", ...) |
Paths the middleware must not intercept. |
CONSENT_TRAIL_SANITIZER |
"consent_trail.sanitizer.clean_html" |
Dotted path to f(str) -> str. |
CONSENT_TRAIL_ADMIN_EDITOR_CSS / _JS |
() |
Static paths for a rich-text editor in the admin. Empty = plain textarea. |
CONSENT_TRAIL_AUDIENCE_RESOLVER |
"consent_trail.audience.everyone" |
f(user) -> set[str]. See Targeting a subset of users. |
About the IP header
Behind a reverse proxy or a CDN, REMOTE_ADDR is the proxy, not the visitor.
Left unchanged, every consent record stores the same wrong address, which
makes the proof worthless the day it matters.
Set CONSENT_TRAIL_IP_HEADER_NAME to whatever your edge actually sends —
HTTP_CF_CONNECTING_IP behind Cloudflare, HTTP_X_FORWARDED_FOR behind most
others. Falls back to REMOTE_ADDR when the header is absent.
Only trust a forwarding header you control end to end: a client can forge one if requests can reach your app without passing through your proxy.
How this relates to Django's i18n
consent_trail complements django.utils.translation, it does not replace
it, and the two handle different things:
| Handled by | |
|---|---|
| Interface strings — buttons, labels, the prevalence notice | Django i18n, .po files |
| Document bodies — the actual contract text | This package, in the database |
Document text deliberately never goes through .po files. Three reasons:
- A
.pomsgid is a UI string. A contract is thousands of words with its own revision history —makemessageswould flood your catalogue and a single comma change would mark the whole thing fuzzy. - Legal text needs a version number and a date of entry into force,
independently of the code.
.pofiles have neither. - Machine-translating a contract is a real liability: a diverging translation can be held against you.
So each language is its own row, written and reviewed by a human, sharing one
version number with its siblings. A document with no version in the requested
language falls back to CONSENT_TRAIL_AUTHORITATIVE_LANGUAGE and displays a
prevalence notice saying which version is authoritative.
The notice names that language itself, written in the reader's own language — a
Japanese reader is told « only the ドイツ語 version is authoritative »
when you set "de". No language name is ever hardcoded: a legal notice that
says "French" while serving German terms is a statement you cannot defend.
This package ships no language list of its own. Which languages exist is
whatever LegalDocument rows you publish; which one is shown is whatever
LocaleMiddleware resolved from your settings.LANGUAGES. Nothing to keep in
sync with the host project.
Setting up i18n in the host project is unchanged: put the package's URLs inside
i18n_patterns() if you want language-prefixed paths, and the current
get_language() drives which version is served.
urlpatterns += i18n_patterns(
path("legal/", include("consent_trail.urls")),
)
Note: with
prefix_default_language=False, your default language has no URL prefix —/legal/cgu/rather than/en/legal/cgu/. That is Django's behaviour, not this package's.
The package's own UI strings
Its buttons and notices ship translated, in consent_trail/locale/. Django
merges every installed app's catalogue automatically, so there is nothing to
configure: pip install and the consent screen speaks your users' language.
If a wording does not suit you, override it instead of forking. Django merges
catalogues in this order — Django itself, then each app in INSTALLED_APPS, then
LOCALE_PATHS — and later wins. So putting the same msgid in your own
locale/fr/LC_MESSAGES/django.po beats ours, deterministically:
msgid "Our terms have changed"
msgstr "Nos conditions ont évolué"
Your makemessages will not pick up this package's strings, and that is correct
— they are not yours to maintain. Only override the handful you actually want to
reword.
Targeting a subset of users
Re-acceptance can be aimed at part of your user base — one organisation, one plan, one country — without this package knowing what those are.
# settings.py
CONSENT_TRAIL_AUDIENCE_RESOLVER = "myapp.legal.resolve_audience"
# myapp/legal.py
def resolve_audience(user):
return {f"org:{m.organization_id}" for m in user.memberships.all()}
Then set a document's audience to org:3f2b…. Leave audience empty — the
default — and the document applies to everyone, which covers the common
"make all users re-accept" case with no configuration at all.
A resolver that raises degrades to "no tags": targeted documents are skipped rather than trapping every user on the acceptance screen.
Consent at signup
With django-allauth:
ACCOUNT_SIGNUP_FORM_CLASS = "consent_trail.forms.ConsentForm"
Any other stack: call form.record_consent(user, request=request) once the
user row exists. Validation is server-side — an HTML required attribute only
stops a browser.
What users and admins get
- Acceptance screen — documents rendered inline in scrollable panes; the checkbox only enables once each has been scrolled to its end. Progressive enhancement: with JavaScript off the checkbox stays usable, because the server is what enforces consent. Never gate a legal requirement on client-side code alone.
/my-consents/— what this user accepted and when, superseded versions included. The proof belongs to them too.- Admin → Acceptances →
pending/— who has not accepted the current version, per document and per audience. Collecting consent is only half the job; being able to answer "who is missing?" is the other half.
Data model
LegalDocument doc_type, language, version, audience, title, body_html,
is_current, published_at,
requires_acceptance, requires_reacceptance
Acceptance user, doc_type, version, # coordinates, not a FK
language_shown, accepted_at, ip_address, user_agent
(cgu, fr, 3) and (cgu, en, 3) are the same document rendered twice.
Accepting version 3 satisfies both.
Policy fields belong to the version, not the translation — saving one language row propagates them to its siblings so they cannot drift apart.
Uniqueness is (doc_type, language, version, audience), so two organisations
can hold their own current version of the same document independently.
Security
- Bodies are sanitized with nh3 on write and on render.
- The allowlist is narrow on purpose: no
img, noiframe, noscript. A contract is text. - Redirect targets are validated with
url_has_allowed_host_and_scheme. The acceptance screen is seen by every user after an update, which makes it a prime phishing target. - The acceptance log is read-only in the admin. It is evidence.
Tests
python tests/manage.py test tests
License
MIT
Metadata
Release files for django-consent-trail 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_consent_trail-0.1.0.tar.gz | 43.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_consent_trail-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 97.0 kB
Release files / django_consent_trail-0.1.0.tar.gz
| Download URL | django_consent_trail-0.1.0.tar.gz |
|---|---|
| Size | 43.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b9c578b7bd46685c71a0180faf354cee2a44bfd5f2606cde2901262c190db749
|
|
BLAKE2b-256 checksum How to use checksums |
db641359cd442f974d94a078f082e0aa2c2bf6e1ac3ed6a8886b1b189f9f9987
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / django_consent_trail-0.1.0-py3-none-any.whl
| Download URL | django_consent_trail-0.1.0-py3-none-any.whl |
|---|---|
| Size | 53.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
622eb194348d191293d6cb880b1c3f4c0f460e2ff95e48e36f0212341af64e67
|
|
BLAKE2b-256 checksum How to use checksums |
415add91f635738acf742a1ae877647da0579ca02151d7c30f2c28dcd62b3702
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency log