Skip to main content

A JSON REST API for the Django admin — same permissions, same ModelAdmin, no new features. Powers django-admin-react and django-admin-mcp.

Project description

django-admin-rest-api

A JSON REST API for the Django admin — same permissions, same ModelAdmin, no new features.

PyPI version Python versions Django versions License: MIT Wire contract: stable Latest on Django Packages

django-admin-rest-api exposes every ModelAdmin you've already registered on django.contrib.admin.site (or your own AdminSite) through a JSON REST API — without introducing a parallel permission system, a parallel form layer, or any features the Django admin itself doesn't have.

It is the wire surface that lets these projects drive your admin:

Project Role PyPI
🟦 django-admin-react React single-page admin frontend django-admin-react
🟩 django-admin-rest-api (this repo) JSON REST API over ModelAdmin django-admin-rest-api
🟪 django-admin-mcp MCP server exposing the same API to LLMs (coming soon)

✨ The one design principle

This package adds no new behavior. It is a JSON wrapper.

That means every one of these is owned by your existing Django setup — not by this library:

  • 🔐 Authentication — Django's session + login. The API enforces the same is_active + is_staff + AdminSite.has_permission gate the HTML admin uses. No tokens, no custom auth backends, no JWTs.
  • 🛡️ Authorization / permissions — every endpoint calls the matching ModelAdmin.has_view_permission / has_add_permission / has_change_permission / has_delete_permission. If your admin says no, the API says 403.
  • 📋 Field validationPOST / PATCH route the payload through the same ModelForm Django would render in the HTML admin (ModelAdmin.get_form(request, obj)), so every clean method, every unique_together constraint, every custom widget validator runs exactly once and exactly the same way.
  • ⚙️ Actions — the action registry comes from ModelAdmin.get_actions(request). Your custom action functions run unmodified. One declaration, two surfaces: the signature of each action's third parameter chooses where it shows up in the SPA — a queryset (or QuerySet-annotated) param surfaces it on the changelist; an obj_id / pk / id param (or a str/int/Model annotation) surfaces it on the single-object detail page. No third-party dependency, no separate declaration list. See ⚙️ Configuration below.
  • 🔎 Search & filters — search uses ModelAdmin.get_search_results(request, queryset, term); filters use ModelAdmin.list_filter. No parallel implementation.
  • 📜 Audit log — writes go through Django's LogEntry so your history page (and every other consumer of LogEntry) keeps working.
  • 🌐 CSRF & sessions — Django's middleware. Nothing is @csrf_exempt.

If a behavior isn't in the HTML admin, it isn't here. If it is in the HTML admin, this library exposes it over JSON.


📓 Example consumer project

A copy-pasteable Django project that consumes the package lives at examples/minimal_project/. Use it to verify the install before adding django-admin-rest-api to a real project, or as a reference for the two-line wiring + a custom ModelAdmin with both batch and detail actions.

🚀 Plug-and-play install

pip install django-admin-rest-api

Two changes to your project:

# settings.py
INSTALLED_APPS = [
    # ... your existing apps ...
    "django.contrib.admin",
    "django_admin_rest_api",          # ← add
]
# urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("admin-api/", include("django_admin_rest_api.urls")),  # ← add
]

That's it. Your admin is now also a JSON API at /admin-api/api/v1/....


📡 The endpoints

Method Path What it returns
GET /api/v1/registry/ The same app/model tree Django renders in the admin index
GET /api/v1/schema/ OpenAPI 3.1 schema of every endpoint below
GET /api/v1/<app>/<model>/ List + pagination + filters + search
POST /api/v1/<app>/<model>/ Create (runs the same ModelForm)
GET /api/v1/<app>/<model>/<pk>/ Detail (read view as the HTML admin renders it)
PATCH /api/v1/<app>/<model>/<pk>/ Update
DELETE /api/v1/<app>/<model>/<pk>/ Destroy (with LogEntry)
PATCH /api/v1/<app>/<model>/bulk/ Bulk patch
GET /api/v1/<app>/<model>/<pk>/delete-preview/ Cascade preview (like the HTML admin's confirm page)
GET /api/v1/<app>/<model>/autocomplete/?q=… ModelAdmin.autocomplete_fields source
POST /api/v1/<app>/<model>/actions/<name>/ Run a ModelAdmin action; one endpoint serves both shapes (batch / detail) — the runner inspects the callable's signature and either passes the user-narrowed QuerySet or str(pk) for the single selected row
GET /api/v1/<app>/<model>/<pk>/history/ The LogEntry history for one object
GET /api/v1/recent-actions/ The dashboard's "Recent Actions" feed
POST /api/v1/login/ Same authenticate + login as the HTML admin
POST /api/v1/logout/ Same logout
POST /api/v1/<app>/<model>/<pk>/password/ JSON mirror of UserAdmin's password-change page (AdminPasswordChangeForm + AUTH_PASSWORD_VALIDATORS + set_password); 404 unless the model's admin declares change_password_form; gated by has_change_permission

Every endpoint enforces the same permission gates as the HTML admin.


🧩 ModelAdmin carry-through status

The package duck-types ModelAdmin and surfaces a wide slice of its configuration on the wire. This table is the honest at-a-glance answer to "will my gnarly admin just work?" — Honored (carried through and tested), Partial (surfaced with a documented caveat), Not yet (no signal emitted today).

ModelAdmin hook Status Notes
list_display (+ @admin.display callables) Honored Methods resolve via lookup_field.
list_display_links Honored Top-level list_display_links array on the changelist; None[].
list_filter (Simple / boolean / choice / FK / date / related-path) Honored FK filters carry autocomplete:true.
search_fields / search_help_text / get_search_results Honored
get_ordering / get_sortable_by / ordering Honored
date_hierarchy Honored
list_editable (bulk save) Honored Via PATCH .../bulk/.
list_select_related / get_queryset Honored N+1 guard on list and inlines.
actions (batch + detail) Honored One runner serves both shapes.
fieldsets / get_fieldsets (+ classes / description) Honored
get_readonly_fields Honored
inlines (Stacked / Tabular) — read Honored FK/M2M columns select_related / prefetch_related.
inlines (Stacked / Tabular) — write Honored Create / edit / delete child rows in the same POST/PATCH as the parent, via an "inlines" block routed through Django's real BaseModelFormSet + save_formset (atomic with the parent; per-inline add/change/delete permission gates). See ⚡ Editable inlines. Not yet: nested inlines, generic inlines, M2M-through with extra fields.
raw_id_fields / radio_fields / filter_horizontal / filter_vertical Honored Emitted as widget hints.
formfield_overrides Honored Reconciled into widget / type.
save_as / save_on_top / save-flow buttons Honored
empty_value_display / message_user / view_on_site Honored
show_full_result_count / list_max_show_all Honored
custom AdminSite / get_app_list Honored Via DJANGO_ADMIN_REST_API["ADMIN_SITE"].
change_password_form (UserAdmin) Honored …/<pk>/password/.
prepopulated_fields Honored {target:[sources]} on the add form-spec / /add/ schema.
autocomplete_fields Partial Endpoint exists; a widget:"autocomplete" hint is emitted only when the target admin declares search_fields. Authorization is target-has_view_permission based (slightly broader than Django's source-relation check).
change_form_template / add_form_template, overridden change_view / add_view Honored Rendered server-side and returned as an html-fragment (admin chrome stripped; inline <script>/<style> preserved) for the SPA to inject in place — no iframe. POST round-trips through …/<pk>/change/ (redirect / re-render + captured messages). Overrides must stay GET-idempotent (see SECURITY.md).
date list_filter range UX Partial Surfaced as {type:"date"} with exact-match; range UI deferred.
get_urls custom views Honored Surfaced as custom_views link descriptors ({name, label, url, level}) — object-level (route captures object_id) on the detail response, model-wide on the registry entry. The SPA can't render the consumer's template, so it links out (new tab). Stock CRUD routes are excluded; short_description on the view drives the label. See 🔗 Custom views.
Generic inlines (GenericTabularInline / GenericStackedInline) Not yet Not specifically handled.

📸 Screenshots

The JSON registry endpoint — the source-of-truth for any consumer frontend:

Registry endpoint JSON response

And here is the same admin rendered by django-admin-react on top of this API, to give you an idea of what a consumer can build:

SPA login SPA registry
SPA list SPA detail

⚙️ Configuration

All settings live under a single optional dict — defaults are sane, so most projects need no entry at all.

# settings.py (all keys optional)
DJANGO_ADMIN_REST_API = {
    # Dotted path to the AdminSite whose ModelAdmin registry the API
    # mirrors. Default exposes django.contrib.admin.site.
    "ADMIN_SITE": "django.contrib.admin.site",

    # Pagination. List endpoints use ModelAdmin.list_per_page as the
    # source of truth; DEFAULT_PAGE_SIZE is the fallback. MAX_PAGE_SIZE
    # caps ?page_size from the client (DoS guard).
    "DEFAULT_PAGE_SIZE": 25,
    "MAX_PAGE_SIZE": 200,

    # Cap on the number of pks per `actions/<name>/` POST. Mirrors
    # MAX_PAGE_SIZE's DoS-guard posture for the changelist. Set to 0
    # (or any non-positive value) to disable the cap entirely.
    "MAX_ACTION_PKS": 5000,

    # When True, list responses include per-query timing in a debug
    # block. Off by default — only enable in development.
    "ENABLE_PROFILING": False,
}

Startup-time validation

The AppConfig registers three Django system checks that surface common install mistakes at manage.py check / manage.py runserver time rather than as a 500 on the first request:

ID Severity Catches
django_admin_rest_api.W001 warning DJANGO_ADMIN_REST_API_* attribute typos (the canonical dict has exactly that name; any other prefix is silently ignored otherwise).
django_admin_rest_api.E001 error ADMIN_SITE doesn't resolve to an AdminSite instance.
django_admin_rest_api.W002 warning CsrfViewMiddleware / SessionMiddleware / AuthenticationMiddleware missing from settings.MIDDLEWARE.

You don't have to enable them — they fire automatically on the next manage.py invocation after install.


⚡ Actions: one declaration, two surfaces

Declare your actions exactly the way Django docs tell you to — @admin.action(description="…") plus actions = [...] on your ModelAdmin. The API surfaces each one in the registry, list, and detail responses with a target field the SPA reads to decide which surface to render it on:

from django.contrib import admin
from django.db.models import QuerySet


@admin.register(MyModel)
class MyAdmin(admin.ModelAdmin):
    actions = ["reprocess_batch", "reprocess_one"]

    @admin.action(description="Reprocess selected")
    def reprocess_batch(self, request, queryset: QuerySet):
        # Shows up on the CHANGELIST (multi-select).  target=batch
        # The runner passes the user-narrowed queryset.
        ...

    @admin.action(description="Reprocess this one")
    def reprocess_one(self, request, obj_id: str):
        # Shows up on the DETAIL page only.            target=detail
        # The runner passes str(pk) for the row in view.
        ...

Both actions reach the same endpoint (POST /api/v1/<app>/<model>/actions/<name>/). The runner inspects the callable's third parameter — its name (queryset / obj_id / pk / id / …) and its type annotation (QuerySet / str / int / Model subclass) — and dispatches to the right shape.

Permissions stay the same (has_change_permission per object). No django-object-actions, no parallel declaration list, no new configuration.


⚡ Editable inlines (write)

inlines are not just read. A create (POST) or update (PATCH) may carry an "inlines" block that creates, edits, and deletes child rows in the same request as the parent:

// PATCH /api/v1/<app>/<model>/<pk>/
{
  "fields": { "title": "Q3 invoice" },
  "inlines": {
    "lineitem_set": {                 // the inline's read-half name
      "items": [
        { "pk": 11, "fields": { "qty": 3 } },   // edit existing
        { "pk": 12, "DELETE": true },            // delete existing
        { "pk": null, "fields": { "sku": "A-9", "qty": 1 } }  // add new
      ]
    }
  }
}

Each block is routed through the inline's real Django BaseModelFormSet (inline.get_formset(request, obj=parent)), validated as a unit, and saved via ModelAdmin.save_formset(...) — never a per-row .save() loop. The whole thing runs inside the same transaction.atomic() as the parent, so a child permission denial (403) or formset validation error (400 validation_failed with an {"inlines": …} body) rolls the parent back too. Per-inline add / change / delete are gated by the inline's own has_*_permission.

Unknown inline keys are rejected (400). Not yet supported: nested inlines, GenericInlineModelAdmin, and M2M-through inlines with extra fields.


🔗 Custom views (get_urls)

A ModelAdmin that registers bespoke pages via get_urls() — a report, an importer, a "download schema.sql" link — has them surfaced as link descriptors so a consumer frontend can open them:

{
  "name":  "billing_invoice_download_report",
  "label": "Download report (CSV)",   // the view's short_description, else humanized
  "url":   "/admin/billing/invoice/42/download-report/",
  "level": "object"                    // "object" | "changelist"
}
  • Object-level views — the route captures object_id (Django's per-object convention) — appear on the detail response (custom_views), reversed with that object's pk.
  • Changelist-level views — no object capture — appear on the registry model entry (custom_views; omitted when empty).

The five stock CRUD routes (change / add / changelist / delete / history) are always excluded. The package never renders the consumer's Django template — it only surfaces the URL; the SPA links out to the legacy-admin-rendered page. A view that can't be reversed (extra unfillable captures) is silently skipped rather than 500ing the endpoint.


🔒 Security

  • The API is not a parallel auth surface. It refuses any caller the HTML admin would refuse, with the same gate (AdminSite.has_permission, plus the per-model ModelAdmin.has_*_permission).
  • Anonymous → 403 for every data endpoint.
  • Authenticated but non-staff → 403. Cookie present but resolved user is anonymous → 403 not_authenticated.
  • Writes always go through ModelForm.is_valid()unique_together, clean(), field validators all run.
  • Per-object guards run before the form does anything. The delete-preview and delete endpoints both check has_delete_permission(obj).
  • CSRF is enforced everywhere. No view in this package is @csrf_exempt. The login endpoint requires the CSRF cookie set by the consumer's shell.
  • DoS guard on the actions runner. MAX_ACTION_PKS (default 5000) caps the selection size of one action POST. Crafted large-selection requests return 400 instead of pinning a worker on an expensive action.
  • Audit-log field-name redaction. The history endpoint's change_message_structured strips field names matching the sensitive-name denylist (password, token, secret, api_key, …) so the audit log can't be used as an oracle for which sensitive fields were touched.

See SECURITY.md for the full threat model and the upstream django-admin-react SECURITY.md for the React-side surface — the API surface is identical and the guarantees transfer 1:1.

Recommended: rate-limit the auth + password endpoints

The login and password endpoints are deliberately not rate-limited by this package — the HTML admin isn't either, and we don't want to duplicate behavior. But you still need rate limiting in production. A typical Django shop already has django-axes or django-ratelimit deployed against /admin/login/; the parallel JSON endpoint needs the same protection.

Option A: django-axes (account-lockout-on-failed-attempts):

# settings.py
INSTALLED_APPS += ["axes"]
MIDDLEWARE += [
    # Must come AFTER AuthenticationMiddleware:
    "axes.middleware.AxesMiddleware",
]
AUTHENTICATION_BACKENDS = [
    "axes.backends.AxesStandaloneBackend",
    "django.contrib.auth.backends.ModelBackend",
]
AXES_FAILURE_LIMIT = 5
AXES_COOLOFF_TIME = 1  # hours

axes works without any package-specific config — it gates Django's authenticate() call, which is exactly the path /api/v1/login/ runs through.

Option B: django-ratelimit (request-per-window):

Wrap the package's URL include with a ratelimited dispatcher in your project's urls.py:

# your_project/urls.py
from django.urls import include, path
from django_ratelimit.decorators import ratelimit
from django.views.decorators.csrf import csrf_protect
from django.views.generic import View

# 5 login attempts per minute per IP
class RateLimitedAuthView(View):
    @ratelimit(key="ip", rate="5/m", block=True)
    def dispatch(self, request, *args, **kwargs):
        from django_admin_rest_api.api.views.auth import LoginView
        return LoginView.as_view()(request, *args, **kwargs)

urlpatterns = [
    path("admin/", admin.site.urls),
    path("admin-api/api/v1/login/", RateLimitedAuthView.as_view()),
    path("admin-api/", include("django_admin_rest_api.urls")),
]

(The literal login/ path must come BEFORE the package include so Django's URL resolver hits the ratelimited dispatcher first.)

Whichever you pick, deploy it on day one — there is no reason to wait for the first brute-force attempt.

Internationalization (i18n)

The package emits its UI / error-envelope strings ("Not found.", "You do not have permission.", "Invalid credentials…", etc.) through gettext_lazy, and model / field / choice labels are already str()-coerced lazy proxies. They resolve to the request-active locale — so to get localized envelopes and verbose_names, enable Django's LocaleMiddleware exactly as the HTML admin requires:

# settings.py
USE_I18N = True
MIDDLEWARE = [
    # ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.locale.LocaleMiddleware",   # ← activates request locale
    "django.middleware.common.CommonMiddleware",
    # ...
]

LocaleMiddleware must sit after SessionMiddleware and before CommonMiddleware (Django's documented ordering). Without it, requests fall back to LANGUAGE_CODE and you get English envelopes / labels — the same behavior the HTML admin exhibits without the middleware. The package ships no .po catalogs of its own; the lazy wrapping means a project that adds translations for these source strings (or relies on Django's own translated label strings) gets them for free.


🧪 Local development

git clone https://github.com/MartinCastroAlvarez/django-admin-api
cd django-admin-api
poetry install
poetry run pytest
poetry run ruff check .
poetry run black --check .
poetry run mypy django_admin_rest_api
poetry run bandit -c pyproject.toml -r django_admin_rest_api

The test suite uses pytest-django + an in-memory SQLite database, so no setup beyond poetry install.

Smoke-test the install on a real project

After dropping the package into your own Django project, run:

python manage.py admin_rest_api_check

It validates the configured ADMIN_SITE, the required middleware, and lists every registered ModelAdmin with its action count (batch / detail breakdown). Exits non-zero on any problem — also useful as a CI / deploy preflight.

Wire-contract reference

The JSON shape of every endpoint is documented in docs/api-contract.md. It is stable under semver: any rename, removal, or type change of a documented field requires a major version bump.


🤝 Contributing

Issues, PRs, and Discussions are welcome on GitHub: https://github.com/MartinCastroAlvarez/django-admin-api.

The lint + security gate is the same set the upstream django-admin-react repo uses: ruff, black, isort, flake8, pylint, mypy, bandit, pip-audit, gitleaks. Every change must pass all of them before merge.


📜 License

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_admin_rest_api-1.8.1.tar.gz (130.0 kB view details)

Uploaded Source

Built Distribution

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

django_admin_rest_api-1.8.1-py3-none-any.whl (155.5 kB view details)

Uploaded Python 3

File details

Details for the file django_admin_rest_api-1.8.1.tar.gz.

File metadata

  • Download URL: django_admin_rest_api-1.8.1.tar.gz
  • Upload date:
  • Size: 130.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for django_admin_rest_api-1.8.1.tar.gz
Algorithm Hash digest
SHA256 4923d513fc730147d07b6d060467b63016f892b3fec9681172494be35493f5c3
MD5 f567eb96adee911f852f9fe468c472a3
BLAKE2b-256 7e2a7f638ac8249ad25d69c9b0b696b0eddffa47b5274d980678a8600aae12b8

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_admin_rest_api-1.8.1.tar.gz:

Publisher: publish.yml on MartinCastroAlvarez/django-admin-rest-api

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_admin_rest_api-1.8.1-py3-none-any.whl.

File metadata

File hashes

Hashes for django_admin_rest_api-1.8.1-py3-none-any.whl
Algorithm Hash digest
SHA256 5bfeb845208bea640ab3331ea6dedc349dcec47b065f728e8dd56268ca0a0ba7
MD5 847863b3653b18120c8c5362ff475630
BLAKE2b-256 e9798c9f7f676b3f6d437ff5e8ee117795bf033d13990077d5da2209275c70e8

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_admin_rest_api-1.8.1-py3-none-any.whl:

Publisher: publish.yml on MartinCastroAlvarez/django-admin-rest-api

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

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page