@hjtdev/django-dynamic-user
v1.1.0
Published
Swappable User/Profile/Setting data layer SDK for django-dynamic-user — typed hooks over both the self-service (/api/v1/users) and admin (/api/v1/admin/users) surfaces, generated from the backend's own OpenAPI schema.
Readme
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/[email protected]#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 AvatarMixinNeither 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_userIf 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_relationsIdempotent — 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-userPeer 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.
