Skip to main content

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-app package reaches through get_user_model(), the same indirection django.contrib.auth's own views use.
  • Requires another app package: No. hjtdev-appkit is a real, versioned dependency (cache, pagination, permissions, error envelope, HttpClient/provider) — install and wire it before this app; the settings block below assumes REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] already exists as a dict, and that appkit's standard_exception_handler is already the configured EXCEPTION_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 is ChangeLogEntry, the concrete model behind HistoryMixin.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/phone are both optional (at least one is required), username auto-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(), bypassing UserManager entirely — see "Migrations" and the USERNAME_*/USER_SELF_EDITABLE_FIELDS settings 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: intv1.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 | Nonev1.1.0. Sent by admin DELETE /{id}/, superuser-only
user_password_set resolved user model user_id: int, actor_id: int | Nonev1.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 onedynamic_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:

  1. ChangeLogEntry is defined in models.py, not mixins.py. CONTRACT.md §1 shows it inside the mixins code block. This is already recorded in CONTRACT.md §10 item 15 as a deliberate deviation (Django only auto-discovers models from models.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

django_dynamic_user-1.1.0.tar.gz (115.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_dynamic_user-1.1.0-py3-none-any.whl (102.1 kB view details)

Uploaded Python 3

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

Hashes for django_dynamic_user-1.1.0.tar.gz
Algorithm Hash digest
SHA256 eb5e2a2cbdb4881385f24382e47536c6e62b5d225ae584043976a270dd6738d1
MD5 e7d2759f4fdd5921cba72e064407143b
BLAKE2b-256 6576d0cf1465181ff3bade0c0b0fec5b9da40fb77e3bccb9ab7f89187dd964ab

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_dynamic_user-1.1.0.tar.gz:

Publisher: ci.yml on HjtDev/django-dynamic-user

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_dynamic_user-1.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_dynamic_user-1.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 7ff2b2ae854ab988e7fb3001983744cb63e9fdc0eabcd7e0b048789574ddac58
MD5 d7bddcda48728010887e50e2ee8f1f1d
BLAKE2b-256 aa3615bf3a617b6be06aad794b60510912f858cbd20d6d58258aae5d584f0cd6

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 files

1.0.1

2 files

1.0.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page