django-dynamic-user
Swappable User/Profile/Setting data layer for a host Django project, as an installable app
package.
- Importable module:
dynamic_user. - PyPI distribution:
django-dynamic-user. npm package:@hjtdev/django-dynamic-user. - This app does not do authentication — no registration, login, JWT, or password reset. It is the
data layer a separate
auth-apppackage reaches throughget_user_model(), the same indirectiondjango.contrib.auth's own views use. - Requires another app package: No.
hjtdev-appkitis a real, versioned dependency (cache, pagination, permissions, error envelope,HttpClient/provider) — install and wire it before this app; the settings block below assumesREST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]already exists as a dict, and that appkit'sstandard_exception_handleris already the configuredEXCEPTION_HANDLER.
Installation — backend
uv add "django-dynamic-user>=1.0,<2.0"
Pinning an unreleased commit instead of a tagged release works too, via the git+subdirectory form:
uv add "git+https://github.com/HjtDev/django-dynamic-user.git@v1.1.0#subdirectory=backend"
Optional extras:
uv add "django-dynamic-user[celery]" # celery[redis] + django-celery-beat, for scheduled tasks
uv add "django-dynamic-user[avatar]" # Pillow via hjtdev-appkit[images], for AvatarMixin
Neither extra is required for the app to be fully functional — see "Recommended periodic
schedule" and the AvatarMixin row of the mixins table below.
Compatibility
- Python 3.13+ · Django 5.2–6.x · Django REST Framework 3.15+ · drf-spectacular 0.27+
hjtdev-appkit>=2.0,<3.0— a declared dependency, not optional.- Requires
django.contrib.contenttypes(present by default with the admin) — the one place this app touches it isChangeLogEntry, the concrete model behindHistoryMixin.log_change(). - OTP/OAuth-style authentication apps (v1.1.0). A separate auth app can create a user knowing
only a phone number or only an email —
email/phoneare both optional (at least one is required),usernameauto-generates when omitted, and Profile/Setting auto-provision regardless of which code path created the user. This works even for an auth app that writes fields directly and calls plain.save(), bypassingUserManagerentirely — see "Migrations" and theUSERNAME_*/USER_SELF_EDITABLE_FIELDSsettings below.
The two swappable-model settings
(Plus AUTH_USER_MODEL itself, Django's own top-level setting, reused rather than duplicated —
see the table below. "Two" counts the settings this package defines; three settings total govern
which concrete models are active.)
This app ships three swappable models, resolved the same way django.contrib.auth.get_user_model()
resolves AUTH_USER_MODEL:
| Setting | Django mechanism | Default if unset |
|---|---|---|
AUTH_USER_MODEL |
Django's own top-level setting | Not this app's to default — a host must always set this itself once any custom user model is involved |
DYNAMIC_USER_PROFILE_MODEL |
top-level, "app_label.ModelName" |
"dynamic_user.Profile" |
DYNAMIC_USER_SETTING_MODEL |
top-level, "app_label.ModelName" |
"dynamic_user.Setting" |
All three exist because Django's swappable_dependency() machinery expects a top-level setting
name, not a DYNAMIC_USER dict key — the same reason AUTH_USER_MODEL itself isn't nested
inside anything. dynamic_user.User/Profile/Setting are usable as-is (a host that wants zero
customization sets all three settings to dynamic_user.User/.Profile/.Setting, or omits the
two DYNAMIC_USER_* ones and lets them default), or a host subclasses any of the three
abstract bases to add project-specific fields with zero changes to this package's own code.
AUTH_USER_MODEL is a pre-first-migrate decision, not a runtime setting. Django resolves
USERNAME_FIELD/REQUIRED_FIELDS at class-definition time, and changing which concrete model
AUTH_USER_MODEL points at after the first migrate is the same unsupported operation it always
is in Django — decide before you run migrate for the first time, not after.
Worked subclassing example
Every extra field below round-trips over real HTTP in this package's own two-host playground
(playground/subclassed/) with zero package-level code changes — only the DYNAMIC_USER dict's
allowlists need to name the new field.
# core/models.py
from typing import ClassVar
from django.db import models
from dynamic_user.managers import UserManager
from dynamic_user.models import AbstractDynamicUser, AbstractProfile, AbstractSetting
class User(AbstractDynamicUser):
department = models.CharField(max_length=100, blank=True)
# Required — AbstractDynamicUser doesn't declare `objects` as inheritable in a way Django's
# migration state picks up automatically; every host subclass must re-declare it. Easy to
# omit; `createsuperuser`/`create_user` breaks with a cryptic error without it.
#
# The `ClassVar` annotation matters if mypy + django-stubs are configured (base-scaffold's
# own baseline is): a bare `objects = UserManager()` fails with "Cannot override class
# variable ... with instance variable" against the identical bare assignment on
# AbstractDynamicUser itself.
objects: ClassVar[UserManager] = UserManager()
class Profile(AbstractProfile):
tagline = models.CharField(max_length=200, blank=True)
class Setting(AbstractSetting):
theme = models.CharField(max_length=20, default="light")
None of these three declare Meta.swappable themselves — that attribute belongs to
dynamic_user's own default implementation, marking "this is the model a setting can swap away
from." A host's replacement model is simply whatever AUTH_USER_MODEL/
DYNAMIC_USER_PROFILE_MODEL/DYNAMIC_USER_SETTING_MODEL names — it needs no swappable
attribute of its own, exactly like any other project's custom AUTH_USER_MODEL.
# config/settings.py
INSTALLED_APPS += ["core"] # must be installed BEFORE "dynamic_user" is added, or Django's
# swappable-model resolution can't find it at migration time
# Still required even though every model below is subclassed — "dynamic_user" itself must stay
# in INSTALLED_APPS. core/models.py's own `from dynamic_user.models import AbstractDynamicUser,
# ...` also imports that module's unconditionally-defined CONCRETE User/Profile/Setting classes
# (the ones a default host uses as-is); Django's model metaclass requires their app to be
# installed to resolve an app_label, even though a subclassed host never uses them as the active
# models. Omitting this line crashes at the first `manage.py check` with: "Model class
# dynamic_user.models.User doesn't declare an explicit app_label and isn't in an application in
# INSTALLED_APPS."
INSTALLED_APPS += ["dynamic_user"]
AUTH_USER_MODEL = "core.User"
DYNAMIC_USER_PROFILE_MODEL = "core.Profile"
DYNAMIC_USER_SETTING_MODEL = "core.Setting"
DYNAMIC_USER = {
"USER_READ_FIELDS": ["id", "username", "name", "email", "phone", "is_active",
"date_joined", "department"],
"PROFILE_EDITABLE_FIELDS": ["bio", "is_public", "tagline"],
"SETTING_EDITABLE_FIELDS": ["language", "timezone", "notifications_enabled", "theme"],
}
A name in any *_FIELDS allowlist that doesn't exist on the resolved model is caught at
startup, not mid-request — see "System checks" below.
Settings — add to backend/config/settings.py
Copy this block verbatim. It is lifted directly from this package's own default-host playground
(playground/default/backend/config/settings.py), which boots on it unmodified — the same block
CI's readme-contract job (from v1.0.0 onward) will diff the code's real throttle scopes against.
Placement matters. This block does REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"].update(...), so
it must go after REST_FRAMEWORK is defined in settings.py — not at the
# ---- installed app packages get added here marker comment inside INSTALLED_APPS/
MIDDLEWARE, which comes first in a base-scaffold host and will NameError if pasted there.
Also confirm REST_FRAMEWORK carries a dict[str, Any] annotation (base-scaffold's own
APPKIT dict does; REST_FRAMEWORK may not) — an unannotated dict literal with mixed value
types makes DEFAULT_THROTTLE_RATES' inferred type too narrow for .update() under mypy +
django-stubs. And run ruff format after pasting — this exact block is not ruff format-clean
as shown (the .update({...}) call needs its dict argument un-hugged).
# ============================================================================================
# DYNAMIC_USER WIRING
# ============================================================================================
INSTALLED_APPS += ["dynamic_user"]
MIDDLEWARE += [] # none required
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"].update({
"dynamic_user_me": "60/min",
"dynamic_user_me_update": "20/min",
"dynamic_user_profile_update": "20/min",
"dynamic_user_setting_update": "20/min",
"dynamic_user_profiles_list": "60/min",
"dynamic_user_profile_retrieve": "60/min",
"dynamic_user_deletion_request": "10/min",
"dynamic_user_admin_users_list": "60/min",
"dynamic_user_admin_user_retrieve": "60/min",
"dynamic_user_admin_user_update": "30/min",
"dynamic_user_admin_user_create": "20/min",
"dynamic_user_admin_user_delete": "10/min",
"dynamic_user_admin_user_set_password": "10/min",
"dynamic_user_admin_profile_update": "30/min",
"dynamic_user_admin_setting_update": "30/min",
"dynamic_user_admin_deletions_list": "60/min",
"dynamic_user_admin_deletion_review": "20/min",
"dynamic_user_admin_deletion_finalize": "10/min",
"dynamic_user_admin_profiles_list": "60/min",
"dynamic_user_admin_profile_create": "20/min",
"dynamic_user_admin_profile_detail": "60/min",
"dynamic_user_admin_settings_list": "60/min",
"dynamic_user_admin_setting_create": "20/min",
"dynamic_user_admin_setting_detail": "60/min",
"dynamic_user_admin_deletion_request_detail": "60/min",
"dynamic_user_admin_deletion_request_create": "20/min",
"dynamic_user_admin_deletion_request_cancel": "20/min",
"dynamic_user_admin_change_log_list": "60/min",
"dynamic_user_admin_change_log_detail": "60/min",
"dynamic_user_admin_log_entries_list": "60/min",
"dynamic_user_admin_log_entry_detail": "60/min",
"dynamic_user_admin_groups_list": "60/min",
"dynamic_user_admin_group_detail": "60/min",
"dynamic_user_admin_permissions_list": "60/min",
})
# The package's own concrete models, used as-is. Every DYNAMIC_USER key below is optional with a
# documented default (see the table further down) — omit the whole dict for zero customization.
AUTH_USER_MODEL = "dynamic_user.User"
DYNAMIC_USER_PROFILE_MODEL = "dynamic_user.Profile"
DYNAMIC_USER_SETTING_MODEL = "dynamic_user.Setting"
# ============================================================================================
# END DYNAMIC_USER WIRING
# ============================================================================================
Rates shown above are this package's own playground defaults, not a hard requirement — tune them per host, just keep every scope name exact (they're literal strings, not derived from a helper).
DYNAMIC_USER settings — every key, with its default
All 24 keys are optional at the Python level; a host overrides only what it needs to change.
| Key | Default | Meaning |
|---|---|---|
USER_READ_FIELDS |
["id", "username", "name", "email", "phone", "is_active", "date_joined"] |
Fields on GET /me/ and the admin user read views (admin sees the full model regardless, except password) |
USER_EDITABLE_FIELDS |
["name", "phone"] |
Not currently consumed by any shipped route — GET /me/ is read-only and admin PATCH writes the full model. Kept for forward-compat with a future self-service /me/ PATCH; validated by dynamic_user.E005 regardless |
USER_LOCKED_FIELDS |
["username", "email", "is_staff", "is_superuser", "is_active"] |
Subtracted from USER_EDITABLE_FIELDS at build time even if a host also lists one of these there — belt-and-braces, deterministic |
USER_PUBLIC_FIELDS |
["id", "username"] |
Fields on the nested user block of a public profile response |
USER_PRIVILEGED_FIELDS |
["is_staff", "is_superuser", "is_active", "groups", "user_permissions"] |
The exact key set CanEscalatePrivilege gates on admin PATCH /{id}/. A host may only add to this set — the resolved value is always DEFAULT ∪ host's, never smaller |
PROFILE_READ_FIELDS |
["id", "bio", "is_public"] |
Fields on GET /me/profile/ |
PROFILE_EDITABLE_FIELDS |
["bio", "is_public"] |
Fields on PATCH /me/profile/ |
PROFILE_PUBLIC_FIELDS |
["id", "bio"] |
Fields on /profiles/, /profiles/{id}/ — deliberately minimal |
SETTING_READ_FIELDS |
["id", "language", "timezone", "notifications_enabled"] |
Fields on GET /me/setting/ |
SETTING_EDITABLE_FIELDS |
["language", "timezone", "notifications_enabled"] |
Fields on PATCH /me/setting/ |
PHONE_VALIDATORS |
[] |
Dotted callable paths, resolved lazily and cached on first use. Empty = no extra validation beyond Django's own field checks — no opinionated phone format ships by default |
NAME_VALIDATORS |
[] |
Same shape, for name |
ADMIN_REQUIRES_SUPERUSER |
False |
True tightens every admin gate from is_staff to is_superuser. Never loosens CanEscalatePrivilege or the deletion-finalize gate, either way |
AUTO_CREATE_PROFILE |
True |
Connects a post_save(created=True) receiver on the user model that get_or_creates a Profile row and sends profile_created |
AUTO_CREATE_SETTING |
True |
Same, for Setting/setting_created |
DELETION_MODE |
"hard_delete" |
"hard_delete" or "anonymize" |
DELETION_GRACE_PERIOD_DAYS |
14 |
finalize_at = requested_at + this many days, computed at request time |
DELETION_ANONYMIZE_FUNCTION |
None |
Dotted path to a callable (user) -> None, called by .finalize() when DELETION_MODE="anonymize". Required in that mode — fails closed (ImproperlyConfigured) rather than silently falling back to hard-delete |
DELETION_HISTORY_RETENTION_DAYS |
90 |
Default window tasks.purge_deletion_history uses when not passed an explicit older_than_days |
LAST_SEEN_UPDATE_SECONDS |
300 |
Minimum interval LastSeenMixin's update path (a host-wired hook, not a view this package ships) writes a new last_seen_at |
USER_SELF_EDITABLE_FIELDS |
["name"] |
v1.1.0. Fields PATCH /me/ accepts (minus USER_LOCKED_FIELDS). Separate from USER_EDITABLE_FIELDS on purpose — that key's default includes phone, which a self-service caller shouldn't be able to rewrite unverified on a host using phone as a login identifier. Add "phone" here if your host wants that |
USERNAME_AUTO_GENERATE |
True |
v1.1.0. False makes a missing username at save time raise instead of auto-generating one |
USERNAME_GENERATOR |
None |
v1.1.0. Dotted path to (model) -> str. Unset uses the built-in generator: USERNAME_PREFIX + 16 hex chars of secrets randomness |
USERNAME_PREFIX |
"user_" |
v1.1.0. Prefix for the built-in generator only |
A settings change never produces a migration diff — every one of these is resolved at call time,
never baked into a model's class attributes. The one exception, forced by Django itself, is
USERNAME_FIELD/REQUIRED_FIELDS on the user model's abstract base (see above).
System checks
Run automatically on manage.py check (and therefore migrate/runserver) — a misconfiguration
is a named startup error, never a mid-request crash and never a silent drop:
| Code | Catches |
|---|---|
dynamic_user.E001 |
DYNAMIC_USER_PROFILE_MODEL/DYNAMIC_USER_SETTING_MODEL not shaped "app_label.ModelName" |
dynamic_user.E002 |
One of those settings names a model that isn't installed |
dynamic_user.E003 |
DELETION_MODE is neither "hard_delete" nor "anonymize", or it's "anonymize" with no DELETION_ANONYMIZE_FUNCTION set |
dynamic_user.E004 |
The resolved DYNAMIC_USER_PROFILE_MODEL/DYNAMIC_USER_SETTING_MODEL does not subclass this app's AbstractProfile/AbstractSetting |
dynamic_user.E005 |
A name in any *_FIELDS allowlist (including USER_PRIVILEGED_FIELDS) that doesn't exist on the resolved model |
Required .env keys
None. Zero .env keys, required or optional, under any installed extra. This app configures
entirely through the DYNAMIC_USER dict plus the two top-level swappable-model settings above.
URL mounting — add to backend/config/urls.py
Two separate URLconfs, two separate namespaces — self-service and admin are never mounted together under one path:
from django.urls import include, path # `include` — easy to miss if urls.py only imported `path`
urlpatterns = [
...
path("api/v1/users/", include("dynamic_user.urls")),
path("api/v1/admin/users/", include("dynamic_user.urls_admin")),
]
Admin paths collapse to the basePath root — /api/v1/admin/users/42/, not
/api/v1/admin/users/users/42/.
Migrations
uv run python manage.py migrate dynamic_user
If you subclassed any of the three models, run your own app's makemigrations/migrate for that
app instead — dynamic_user's own migrations only apply when its concrete User/Profile/
Setting are actually in use. ChangeLogEntry (the model behind HistoryMixin) is not
swappable and always migrates with dynamic_user regardless.
Upgrading to v1.1.0. migrate picks up 0002_optional_identity automatically (widens
email/username, adds the email-or-phone CheckConstraint) — no data migration is needed,
since every pre-existing row already has a non-null email. If you subclassed
AbstractDynamicUser, run makemigrations once for your own app first (the constraint is
inherited from the abstract base). Then, once, backfill any Profile/Setting rows that predate
this install or an AUTO_CREATE_PROFILE/AUTO_CREATE_SETTING=False period:
uv run python manage.py backfill_user_relations --dry-run # see counts first
uv run python manage.py backfill_user_relations
Idempotent — safe to run more than once. --no-signals suppresses profile_created/
setting_created for the backfilled rows, useful if a receiver (e.g. a welcome email) shouldn't
fire for a bulk backfill.
Verifying the install
uv run python manage.py createsuperuser
uv run python manage.py runserver # or docker compose up --build
curl -u <username>:<password> http://localhost:8000/api/v1/users/me/ # 200
curl -u <username>:<password> http://localhost:8000/api/v1/admin/users/ # 200 (superuser)
Also check /api/schema/swagger-ui/ for a dynamic-user and a dynamic-user-admin tag group,
and /admin/ for the model entries under the sidebar (see "Suggested Jazzmin icons" below — no
further JAZZMIN_SETTINGS edits needed beyond the icons block). DRF's own default
DEFAULT_AUTHENTICATION_CLASSES (session + HTTP Basic) is enough to exercise the endpoints above
without installing anything else — a real host still wants its own auth-app or session-cookie
strategy for a browser-based login flow, which this package deliberately doesn't provide.
Endpoints
Self-service — dynamic_user.urls, basePath /api/v1/users
Every object here is resolved from request.user, never a URL-supplied id, except
GET /profiles/{id}/ — the one place this surface looks up someone else's (public) data.
| Method | Path | Permission | Throttle scope |
|---|---|---|---|
GET |
/me/ |
IsAuthenticated |
dynamic_user_me |
GET |
/me/profile/ |
IsAuthenticated |
dynamic_user_me |
PATCH |
/me/profile/ |
IsAuthenticated, IsProfileOwner |
dynamic_user_profile_update |
GET |
/me/setting/ |
IsAuthenticated |
dynamic_user_me |
PATCH |
/me/setting/ |
IsAuthenticated, IsProfileOwner |
dynamic_user_setting_update |
GET |
/profiles/ |
IsAuthenticated |
dynamic_user_profiles_list |
GET |
/profiles/{id}/ |
IsAuthenticated, IsPublicOrOwner |
dynamic_user_profile_retrieve |
POST GET DELETE |
/me/deletion-request/ |
IsAuthenticated |
dynamic_user_deletion_request |
PATCH |
/me/ |
IsAuthenticated |
dynamic_user_me_update |
GET /profiles/{id}/'s {id} is the target user's id, not the Profile row's own primary key.
A private profile 404s (not 403) for a non-owner. POST /me/deletion-request/ 409s if a
pending/approved request already exists; DELETE 409s if the caller's current request isn't
PENDING. v1.1.0: PATCH /me/ writes USER_SELF_EDITABLE_FIELDS (default: just name).
Admin — dynamic_user.urls_admin, basePath /api/v1/admin/users
Every view is gated by IsDynamicUserAdmin (is_staff, or is_superuser when
ADMIN_REQUIRES_SUPERUSER=True) at minimum.
| Method | Path | Extra gate | Throttle scope |
|---|---|---|---|
GET |
/ |
— | dynamic_user_admin_users_list |
POST |
/ |
CanEscalatePrivilege |
dynamic_user_admin_user_create |
GET |
/{id}/ |
— | dynamic_user_admin_user_retrieve |
PATCH |
/{id}/ |
CanEscalatePrivilege |
dynamic_user_admin_user_update |
DELETE |
/{id}/ |
superuser-only, always | dynamic_user_admin_user_delete |
POST |
/{id}/set-password/ |
superuser-only, always | dynamic_user_admin_user_set_password |
GET PATCH |
/{id}/profile/ |
— | dynamic_user_admin_profile_update |
GET PATCH |
/{id}/setting/ |
— | dynamic_user_admin_setting_update |
GET POST |
/profiles/ |
— | dynamic_user_admin_profiles_list / dynamic_user_admin_profile_create |
GET PATCH DELETE |
/profiles/{profile_id}/ |
— | dynamic_user_admin_profile_detail |
GET POST |
/settings/ |
— | dynamic_user_admin_settings_list / dynamic_user_admin_setting_create |
GET PATCH DELETE |
/settings/{setting_id}/ |
— | dynamic_user_admin_setting_detail |
GET |
/deletion-requests/ |
— | dynamic_user_admin_deletions_list |
POST |
/deletion-requests/ |
— | dynamic_user_admin_deletion_request_create |
GET |
/deletion-requests/{id}/ |
— | dynamic_user_admin_deletion_request_detail |
DELETE |
/deletion-requests/{id}/ |
— | dynamic_user_admin_deletion_request_cancel |
POST |
/deletion-requests/{id}/review/ |
— | dynamic_user_admin_deletion_review |
POST |
/deletion-requests/{id}/finalize/ |
superuser-only, always | dynamic_user_admin_deletion_finalize |
GET |
/change-log/ |
— | dynamic_user_admin_change_log_list |
GET DELETE |
/change-log/{id}/ |
DELETE: superuser-only, always |
dynamic_user_admin_change_log_detail |
GET |
/log-entries/ |
— (only wired if django.contrib.admin is installed) |
dynamic_user_admin_log_entries_list |
GET DELETE |
/log-entries/{id}/ |
DELETE: superuser-only, always |
dynamic_user_admin_log_entry_detail |
GET |
/groups/ |
— | dynamic_user_admin_groups_list |
GET |
/groups/{id}/ |
— | dynamic_user_admin_group_detail |
GET |
/permissions/ |
— | dynamic_user_admin_permissions_list |
v1.1.0 admin/API parity additions, all above: user create/delete/set-password; profile and
setting collections (/profiles/, /settings/, keyed by the row's own pk, distinct from the
existing per-user /{id}/profile///{id}/setting/ routes); deletion-request retrieve-by-id,
create-on-a-user's-behalf, and admin-cancel; a read-only ChangeLogEntry audit surface (superuser
delete only); a read-only surface for Django's own LogEntry (present only when
django.contrib.admin is installed); read-only groups/permissions so a dashboard can populate
the pickers behind PATCH /{id}/'s groups/user_permissions. Every admin-API write now also
writes a LogEntry row, matching what Django Admin itself already auto-logs.
The privilege-escalation gate. CanEscalatePrivilege runs only on admin PATCH /{id}/, is
never controlled by ADMIN_REQUIRES_SUPERUSER, and inspects the request body for the exact key
set {"is_active", "is_staff", "is_superuser", "groups", "user_permissions"} (the
USER_PRIVILEGED_FIELDS floor above, plus any host additions). If the body touches any of
those keys and request.user.is_superuser is not True, the entire request is rejected with
403 — never a silent per-field drop. password is excluded from every serializer this app
produces or accepts, unconditionally. POST /deletion-requests/{id}/finalize/,
DELETE /{id}/, POST /{id}/set-password/, DELETE /change-log/{id}/, and
DELETE /log-entries/{id}/ are all superuser-only regardless of ADMIN_REQUIRES_SUPERUSER —
each is either genuinely irreversible (hard-delete, password takeover) or would let a compromised
staff account erase the audit trail that would otherwise reveal it.
Signals emitted
Every payload is a bare id or primitive, never a model instance. sender for
profile_created/setting_created/profile_updated is the resolved class — filter with
@receiver(profile_created, sender=get_profile_model()), works correctly even under a swapped
model.
| Signal | Sender | Payload |
|---|---|---|
profile_created |
resolved Profile model | user_id: int |
setting_created |
resolved Setting model | user_id: int |
deletion_requested |
AccountDeletionRequest |
user_id: int, request_id: int, finalize_at: datetime |
deletion_reviewed |
AccountDeletionRequest |
request_id: int, status: str, reviewed_by_id: int | None |
deletion_finalized |
AccountDeletionRequest |
user_id: int (captured before a hard_delete removes the row), mode: str |
profile_updated |
resolved Profile model | user_id: int, changed_fields: list[str] — sent only when at least one field actually changed |
user_created |
resolved user model | user_id: int — v1.1.0. Unconditional (no AUTO_CREATE_*-style guard); connected last in apps.py's ready(), after both provisioning receivers, so a receiver can rely on Profile/Setting already existing |
user_updated |
resolved user model | user_id: int, changed_fields: list[str] — v1.1.0. Sent by UserService.update (PATCH /me/, admin PATCH /{id}/) |
setting_updated |
resolved Setting model | user_id: int, changed_fields: list[str] — v1.1.0. Setting changes are no longer silent |
user_deleted |
resolved user model | user_id: int, actor_id: int | None — v1.1.0. Sent by admin DELETE /{id}/, superuser-only |
user_password_set |
resolved user model | user_id: int, actor_id: int | None — v1.1.0. Sent by admin POST /{id}/set-password/, superuser-only. Carries no password material |
profile_created/setting_created only fire when AUTO_CREATE_PROFILE/AUTO_CREATE_SETTING
(both default True) are enabled and a row was actually created — not on every get_or_create.
Payload changes to any of the above are a MAJOR version bump.
Services (public callables) — dynamic_user.services
The only place a Profile/Setting update or an account-deletion state transition happens. Every
model reference is resolved through resolution.py/settings.AUTH_USER_MODEL at call time.
| Method | Signature | Notes |
|---|---|---|
ProfileService.update |
(user: AbstractBaseUser, validated_data: dict) -> AbstractProfile |
get_or_creates the row; sends profile_updated if anything changed |
SettingService.update |
(user: AbstractBaseUser, validated_data: dict) -> AbstractSetting |
Same shape; no signal |
DeletionService.current |
(user: AbstractBaseUser) -> AccountDeletionRequest | None |
The user's active (PENDING/APPROVED) request, or None |
DeletionService.request |
(user: AbstractBaseUser, *, reason: str = "") -> AccountDeletionRequest |
Raises DeletionRequestAlreadyExists if one is already active |
DeletionService.review |
(request_id: int, *, approved: bool, reviewed_by: AbstractBaseUser) -> AccountDeletionRequest |
Raises InvalidDeletionState unless currently PENDING. Rejecting is terminal |
DeletionService.finalize |
(request_id: int) -> None |
Raises InvalidDeletionState unless currently APPROVED. Implements DELETION_MODE; raises ImproperlyConfigured on a misconfigured "anonymize" mode rather than falling back |
DeletionService.cancel |
(user: AbstractBaseUser) -> None |
Raises InvalidDeletionState if no PENDING request exists. Deletes the row outright — no "cancelled" status |
DeletionService.cancel_by_id |
(request_id: int) -> None |
v1.1.0. Admin-side cancel — accepts PENDING or APPROVED (unlike .cancel()) |
UserService.create |
(*, password: str | None = None, **fields) -> AbstractBaseUser |
v1.1.0. Via UserManager.create_user — identity validation/username generation happen once, inside save() |
UserService.update |
(user: AbstractBaseUser, validated_data: dict, *, actor=None) -> AbstractBaseUser |
v1.1.0. Sends user_updated if anything changed |
UserService.set_password |
(user: AbstractBaseUser, raw_password: str, *, actor=None) -> None |
v1.1.0. Runs AUTH_PASSWORD_VALIDATORS; sends user_password_set |
UserService.delete |
(user: AbstractBaseUser, *, actor=None) -> None |
v1.1.0. Sends user_deleted, user_id captured before the delete |
Signature changes to any of the above are a MAJOR version bump.
Mixins — dynamic_user.mixins
One composable abstract model per mixin. Compose onto your own subclass of AbstractDynamicUser/
AbstractProfile/AbstractSetting as needed — none are applied by default.
| Mixin | Fields added | Composing-model requirement |
|---|---|---|
AvatarMixin |
avatar, avatar_updated_at |
Needs the [avatar] extra only if actually used — the import itself never requires Pillow |
TimestampMixin |
created_at, updated_at |
None |
HistoryMixin |
none (adds .log_change(field, old, new, *, actor=None)) |
None — writes to the always-migrated, non-swappable ChangeLogEntry |
SoftDeleteMixin |
is_deleted, deleted_at |
The composing model must define its own objects (filtered) and all_objects (unfiltered) managers — a mixin can't safely inject a manager onto User, which already needs UserManager |
VerificationMixin |
email_verified, email_verified_at, phone_verified, phone_verified_at |
None — flags and timestamps only, no delivery logic |
LastSeenMixin |
last_seen_at, last_seen_ip |
None — write-throttling per LAST_SEEN_UPDATE_SECONDS is a host-wired hook's job |
MetadataMixin |
metadata (JSONField) |
None — never read/written by this package's own views/serializers by default |
Test helpers
dynamic_user.factories exports factory_boy factories for User/Profile/Setting/
AccountDeletionRequest — this package's public test-only surface. Add factory-boy to your own
test dependency group to use them; this module is never imported by anything under this package's
own src/. v1.1.0: UserFactory carries phone_only/email_only traits
(UserFactory(phone_only=True)) matching the identity rule — a plain UserFactory() still sets
both email and phone.
Recommended periodic schedule
Behind the celery extra only — this app is fully functional with no worker running at all. A
host without Celery drives the exact same underlying logic via
python manage.py process_deletion_requests (finalize_due_deletions only) on plain cron
instead; there is no cron-only equivalent of purge_deletion_history shipped, add your own if you
want that one scheduled without Celery.
dynamic_user.tasks.finalize_due_deletions — daily at 03:00
dynamic_user.tasks.purge_deletion_history — weekly (day/hour genuinely host-specific — pick one)
This is a recommendation, not something that auto-registers — the host creates the actual
django_celery_beat schedule entry, preferably as a data migration in core/ (see
INTEGRATION-GUIDE.md §2 step 9) so it's reproducible and reviewable.
Suggested Jazzmin icons
Jazzmin is not a dependency of this package — it never writes to JAZZMIN_SETTINGS itself. If a
host has Jazzmin installed:
JAZZMIN_SETTINGS = {
...
"icons": {
"dynamic_user.user": "fas fa-user",
"dynamic_user.profile": "fas fa-id-card",
"dynamic_user.setting": "fas fa-sliders-h",
"dynamic_user.accountdeletionrequest": "fas fa-user-slash",
"dynamic_user.changelogentry": "fas fa-history",
"admin.logentry": "fas fa-clipboard-list", # v1.1.0 — LogEntryAdmin, registered by this app
},
}
Re-key these to your own app label if you subclassed the swappable models (e.g.
"core.user" instead of "dynamic_user.user") — accountdeletionrequest, changelogentry, and
admin.logentry stay as shown either way, since none of the three is swappable.
Installation — frontend
npm install @hjtdev/appkit # if not already installed
npm install @hjtdev/django-dynamic-user
Peer dependencies: react>=18, @tanstack/react-query>=5, @hjtdev/appkit>=2.0.0 <3.0.0.
Usage — two basePaths entries, then import hooks from the package root
This app registers two API surfaces, not one — dynamic_user (self-service) and
dynamic_user_admin (admin). Register both entries explicitly on @hjtdev/appkit's
ApiClientProvider, the one provider a host mounts for its whole app. In practice, omitting
dynamic_user_admin is often not immediately visible: useApiClient(key, defaultBasePath)
falls back to this app's own documented default (/api/v1/admin/users) when the key is missing,
which is exactly this app's own recommended mount path — so a host that mounted the backend URLs
as shown below sees the admin hooks keep working. The real risk is silent, not immediate: a host
that later remounts the backend at a different prefix, while still relying on the unregistered
default, gets a failure with no signal at the point the mistake was made. Register both keys
explicitly regardless — it's the only config the fallback can't paper over later.
// app/providers.tsx — one-time wiring per host
import { useState } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { ApiClientProvider, makeQueryClient } from "@hjtdev/appkit";
import { apiClient } from "@/lib/api-client";
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => makeQueryClient());
return (
<QueryClientProvider client={queryClient}>
<ApiClientProvider
client={apiClient}
basePaths={{
// ...entries for already-installed apps stay here
dynamic_user: "/api/v1/users",
dynamic_user_admin: "/api/v1/admin/users",
}}
>
{children}
</ApiClientProvider>
</QueryClientProvider>
);
}
Requires the host's @tanstack/react-query QueryClientProvider already mounted above these
hooks. No further frontend configuration needed.
Self-service hooks
import {
useMe, useUpdateMe, useMyProfile, useUpdateMyProfile, useMySetting, useUpdateMySetting,
usePublicProfiles, usePublicProfile,
useMyDeletionRequest, useRequestDeletion, useCancelDeletionRequest,
dynamicUserKeys,
} from "@hjtdev/django-dynamic-user";
useUpdateMe() (v1.1.0) wraps PATCH /me/ — USER_SELF_EDITABLE_FIELDS (default: just
name).
Admin hooks
import {
useAdminUsers, useCreateAdminUser, useAdminUser, useUpdateAdminUser, useDeleteAdminUser,
useSetAdminUserPassword,
useAdminUserProfile, useUpdateAdminUserProfile,
useAdminUserSetting, useUpdateAdminUserSetting,
useAdminProfiles, useCreateAdminProfile, useAdminProfile, useUpdateAdminProfileById,
useDeleteAdminProfile,
useAdminSettings, useCreateAdminSetting, useAdminSetting, useUpdateAdminSettingById,
useDeleteAdminSetting,
useAdminDeletionRequests, useAdminDeletionRequest, useCreateAdminDeletionRequest,
useCancelAdminDeletionRequest, useReviewDeletionRequest, useFinalizeDeletionRequest,
useAdminChangeLog, useAdminChangeLogEntry, useDeleteAdminChangeLogEntry,
useAdminLogEntries, useAdminLogEntry, useDeleteAdminLogEntry,
useAdminGroups, useAdminGroup, useAdminPermissions,
dynamicUserAdminKeys,
} from "@hjtdev/django-dynamic-user";
function AdminUserRow({ id }: { id: number }) {
const { data: user } = useAdminUser(id);
const { mutate: update } = useUpdateAdminUser(id);
// ...
}
v1.1.0 added 26 hooks, all above — user create/delete/set-password; profile and setting
collections (useAdminProfiles/useAdminProfile/... — keyed by the row's own pk, distinct
from the existing per-user useAdminUserProfile(id)); deletion-request retrieve/create/cancel;
read-only change-log, Django LogEntry, group, and permission hooks plus the two audit-delete
mutations.
All 46 hooks and both key factories (dynamicUserKeys, dynamicUserAdminKeys) are exported from
the package root — there is no other entrypoint, and no provider export (the host mounts appkit's
ApiClientProvider once, as shown above).
Where this README and docs/CONTRACT.md disagree
Code is the source of truth throughout this document. Phase 9 found four such spots; Phase 10
resolved three of them (USER_EDITABLE_FIELDS's false "admin baseline" claim was corrected in
docs/CONTRACT.md §6 and dynamic_user/conf.py's own comment, CI now exists, and the version
below is current). One remains, kept here as an accurate record rather than a discrepancy to fix:
ChangeLogEntryis defined inmodels.py, notmixins.py.CONTRACT.md§1 shows it inside the mixins code block. This is already recorded inCONTRACT.md§10 item 15 as a deliberate deviation (Django only auto-discovers models frommodels.py) — flagged here only to confirm the register entry is accurate, not to re-litigate it.
No other disagreements were found across the DYNAMIC_USER key table (all 24 keys, verified
against conf.py DEFAULTS), the eleven signal payloads, the twelve service signatures, every
self-service/admin endpoint and its permission classes, all 46 frontend hook names, or the two
task names/recommended schedule — verified again for v1.1.0.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_dynamic_user-1.1.0.tar.gz.
File metadata
- Download URL: django_dynamic_user-1.1.0.tar.gz
- Upload date:
- Size: 115.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
eb5e2a2cbdb4881385f24382e47536c6e62b5d225ae584043976a270dd6738d1
|
|
| MD5 |
e7d2759f4fdd5921cba72e064407143b
|
|
| BLAKE2b-256 |
6576d0cf1465181ff3bade0c0b0fec5b9da40fb77e3bccb9ab7f89187dd964ab
|
Provenance
The following attestation bundles were made for django_dynamic_user-1.1.0.tar.gz:
Publisher:
ci.yml on HjtDev/django-dynamic-user
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_dynamic_user-1.1.0.tar.gz -
Subject digest:
eb5e2a2cbdb4881385f24382e47536c6e62b5d225ae584043976a270dd6738d1 - Sigstore transparency entry: 2736067346
- Sigstore integration time:
-
Permalink:
HjtDev/django-dynamic-user@8d5c74d3202d4b25239202ec422789dc68b051f5 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/HjtDev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@8d5c74d3202d4b25239202ec422789dc68b051f5 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_dynamic_user-1.1.0-py3-none-any.whl.
File metadata
- Download URL: django_dynamic_user-1.1.0-py3-none-any.whl
- Upload date:
- Size: 102.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7ff2b2ae854ab988e7fb3001983744cb63e9fdc0eabcd7e0b048789574ddac58
|
|
| MD5 |
d7bddcda48728010887e50e2ee8f1f1d
|
|
| BLAKE2b-256 |
aa3615bf3a617b6be06aad794b60510912f858cbd20d6d58258aae5d584f0cd6
|
Provenance
The following attestation bundles were made for django_dynamic_user-1.1.0-py3-none-any.whl:
Publisher:
ci.yml on HjtDev/django-dynamic-user
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_dynamic_user-1.1.0-py3-none-any.whl -
Subject digest:
7ff2b2ae854ab988e7fb3001983744cb63e9fdc0eabcd7e0b048789574ddac58 - Sigstore transparency entry: 2736067434
- Sigstore integration time:
-
Permalink:
HjtDev/django-dynamic-user@8d5c74d3202d4b25239202ec422789dc68b051f5 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/HjtDev
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
ci.yml@8d5c74d3202d4b25239202ec422789dc68b051f5 -
Trigger Event:
push
-
Statement type: