Skip to main content

Django Anvil

Declarative Resource classes that generate a DRF serializer, viewset, router urls, admin registration, RBAC enforcement, tenant scoping, and tests from one model definition — for existing Django + DRF projects, not a replacement for either. Free, open-source, no hosted service.

Status

All four planned phases are built and demo-verified: Resource core, RBAC, shared-schema multi-tenancy, and an AI suggestion engine, plus doctor/ audit polish. See demo/ for a working example (four resources — Product, Coupon, Review, Tag — sharing one app).

Install into a project

pip install django-anvil

Installing from a local checkout instead (for contributing to Anvil itself):

pip install -e /path/to/django-anvil

One install, everything included — the AI providers (OpenAI, Anthropic, Gemini) and django-simple-history (for audited = True) are plain dependencies of django-anvil itself, not opt-in extras. Nothing extra to remember; ANVIL_AI_PROVIDER just picks which AI provider is actually used.

# settings.py
INSTALLED_APPS = [
    ...,
    "rest_framework",
    "django_filters",
    "django_anvil",
    "django_anvil.rbac",      # optional: only if you use permissions
    "django_anvil.tenancy",   # optional: only if you use tenant_scoped
    "simple_history",         # optional: only if you use audited=True
    "your_app",
]

MIDDLEWARE = [
    ...,
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_anvil.tenancy.middleware.CurrentOrganizationMiddleware",  # optional
    ...,
]

Usage

  1. Define a Resource next to your model, in your_app/resources.py:

    from django_anvil.core.resource import Resource
    from django_anvil.mixins import SoftDeleteViewSetMixin, TimestampedSerializerMixin
    from .models import Product
    
    class ProductResource(Resource):
        model = Product
        fields = ["id", "name", "price", "stock", "is_active", "created_at", "updated_at"]
        read_only_fields = ["id", "created_at", "updated_at"]
        searchable = ["name"]
        filters = ["is_active"]
        sortable = ["price", "created_at"]
        mixins = [SoftDeleteViewSetMixin, TimestampedSerializerMixin]
    
        # optional, see below:
        permissions = {}       # RBAC
        tenant_scoped = False  # multi-tenancy
        audited = False        # change history
    
  2. Generate everything:

    python manage.py anvil resource Product
    

    This writes/merges into your_app/api/serializers.py, your_app/api/views.py, your_app/api/urls.py, registers the model in your_app/admin.py, and writes your_app/tests/test_product_api.py (plus RBAC/tenancy test files if those are enabled). Multiple Resources in the same app share these files correctly — a second anvil resource Coupon adds its own classes alongside Product's, the way a person would by hand. Re-running is safe by default: it never overwrites a Resource's existing block unless you pass --force (which regenerates only that Resource's block, not anyone else's, and never touches admin.py's registration — see below).

  3. Wire the generated router into your project urls (only needed once per app, the first time you generate a resource in it):

    path("api/", include("your_app.api.urls")),
    
  4. See every registered Resource, or check the project for common mistakes:

    python manage.py anvil list
    python manage.py anvil doctor
    

RBAC

permissions maps an action (view, create, update, delete) to either "public", "authenticated", or a real Django permission codename ("<app_label>.<codename>" — the four Django auto-creates per model, add_x/change_x/delete_x/view_x, or one you declare yourself in the model's Meta.permissions):

permissions = {
    "view": "public",
    "create": "products.add_product",
    "update": "products.change_product",
    "delete": "products.delete_product",
}

Fails closed: an action with no rule listed is denied to everyone, not allowed. Roles are just Django's own Group/Permission system, exposed as django_anvil.rbac.models.Role (a friendlier-named proxy for Group) with a .grant("app_label.codename") helper — manage them from the normal Django admin "Roles" section, no new UI to learn. Generating a Resource with permissions set also writes an RBAC test file (test_<model>_permissions.py) proving each protected action really is denied/allowed as declared, using real HTTP requests.

Multi-tenancy

Shared-schema: every tenant's rows live in the same tables, tagged with an organization_id and auto-scoped. Opt in per model by inheriting TenantScopedModel (adds the organization FK) and setting tenant_scoped = True on the Resource:

from django_anvil.tenancy.models import TenantScopedModel

class Product(TenantScopedModel):
    ...

Fails closed: Model.objects (the default manager) returns nothing if there's no organization in context for the current request — never "every organization's rows." Model.all_objects is the same queryset unfiltered, for the deliberate cases (Django admin, scripts) that need everything; generated ModelAdmins already use it, since admin is a global surface. The current organization is resolved from an X-Org-Id header (only honored if the user is really a member) or automatically if the user belongs to exactly one organization — resolved inside the DRF ViewSet itself (TenantScopedViewSetMixin), not via Django middleware, because DRF's own authentication (tokens, force_authenticate in tests) runs later than Django's middleware chain. Generating a tenant_scoped Resource also writes a tenancy isolation test file proving one organization can't see or query another's rows through the API.

RBAC and tenancy compose: a role check happens first, then the tenant-scoped queryset filters what that role is allowed to see.

AI engine (analyze + suggest, apply only with your approval)

python manage.py anvil ai "add a coupon system with percent or fixed-amount \
discounts, an optional expiry date, and a usage limit" --app products --output coupon.patch

Reads your project's models/Resources for context, asks the configured LLM provider for complete models.py/resources.py file contents (not a diff — LLMs write whole files far more reliably than correct diff syntax), and computes the unified diff itself. By default, nothing is ever written — review the diff, apply it by hand (git apply coupon.patch or patch -p1 < coupon.patch), then run anvil resource <NewModel> yourself.

Add --apply to be asked, after seeing the diff, whether to write the files directly:

python manage.py anvil ai "add a coupon system..." --app products --apply
--- a/products/models.py
+++ b/products/models.py
...
Write 2 file(s) now (products/models.py, products/resources.py)? [y/N]:

--apply never writes without that typed confirmation — it isn't a "trust the AI" flag, it's "let me approve without leaving the terminal." It still only ever writes models.py/resources.py; it never runs makemigrations or anvil resource for you — those stay separate, deliberate steps you run yourself once you've looked at what changed. In a non-interactive session (no TTY — CI, a piped command), --apply always declines and writes nothing, rather than guessing.

Four providers ship, chosen via ANVIL_AI_PROVIDER (a dotted path — nothing in the AI engine itself is hard-locked to one vendor). All three real providers' SDKs come with a plain pip install django-anvil — no extras needed:

Provider API key
OpenAIProvider (default) OPENAI_API_KEY
AnthropicProvider ANTHROPIC_API_KEY
GeminiProvider GOOGLE_API_KEY
StaticProvider none — fixed response, for tests/CI

Switching providers is exactly this — no installing anything else, ever:

# settings.py
ANVIL_AI_PROVIDER = "django_anvil.ai.providers.AnthropicProvider"  # or OpenAIProvider / GeminiProvider
ANVIL_AI_MODEL = "claude-sonnet-5"  # optional override, per-provider default otherwise
ANVIL_AI_API_KEY = env("MY_KEY")    # optional; falls back to each SDK's own env var

GeminiProvider uses the current google-genai package, not the end-of-lifed google-generativeai. Writing your own provider (a self-hosted model, an internal proxy, a vendor not listed above) is one class with one method — see CustomProviderExample in django_anvil/ai/providers.py for a working template, and tests/test_ai_providers.py for how each shipped provider is verified against a mocked SDK (no real API key needed to run those tests).

Audit history

Set audited = True on a Resource and add history = HistoricalRecords() to the model yourself (Anvil wires up the admin integration — SimpleHistoryAdmin, giving a full change log in the admin UI — but never edits your model file for you, the same policy as TenantScopedModel). django-simple-history is already installed as part of django-anvil — just add "simple_history" to INSTALLED_APPS. anvil doctor flags a Resource that says audited = True but whose model doesn't have the field yet.

anvil doctor

Static checks, no database needed — safe to run in CI before migrate:

  • DEBUG=True, an unreplaced default SECRET_KEY, empty ALLOWED_HOSTS with DEBUG=False.
  • A tenant_scoped Resource whose model isn't actually a TenantScopedModel.
  • A mixin used without the field it needs (SoftDeleteViewSetMixin needs is_deleted, OwnerScopedViewSetMixin needs owner).
  • A permission codename that doesn't match any real permission on the model — RBAC fails closed, so a typo there silently locks everyone out of that action with no error to point at the cause.
  • A field that's filtered/sorted on a lot with no database index.

Mixins

django_anvil.mixins ships real, reusable behavior — not just codegen templates. A Resource opts in by listing them; the generator wires the matching one into the serializer or the viewset base classes based on its name (...SerializerMixin vs ...ViewSetMixin):

  • SoftDeleteViewSetMixin — requires an is_deleted field; excludes soft-deleted rows and turns DELETE into a soft delete.
  • OwnerScopedViewSetMixin — requires an owner FK; scopes every action (including list/retrieve) to request.user's own rows and auto-assigns it on create — "your own stuff only", like a private notes/wishlist feature. Fails closed (empty, not a crash) for anonymous requests. Don't pair it with permissions = {"view": "public"} expecting a public-reads/private-writes feed — that needs a different get_queryset than this mixin provides; see its docstring.
  • TenantScopedViewSetMixin — auto-injected when tenant_scoped = True; don't add it yourself.
  • TimestampedSerializerMixin — marks created_at/updated_at read-only if present.

Write your own the same way: a plain class following the same naming convention, mixed into the generated ModelViewSet/ModelSerializer.

Developing Anvil itself

Two separate test suites, for two separate things:

pip install -e ".[dev]"
python -m pytest -v          # this package's own code -- providers, indexer, diff/apply logic (17 tests)
cd demo && python -m pytest products/tests/ -v   # the generated code the package produces (49 tests)

They're kept apart on purpose: demo/ is its own Django project with its own settings, and Django only allows one process-global settings object — a bare pytest from the repo root only collects ./tests/ (see testpaths in pyproject.toml), so it never tries to load both.

Demo

demo/ is a throwaway Django project proving every feature end to end, via four Resources in one app:

Resource Demonstrates
Product RBAC (mixed public/codename rules), tenancy, SoftDeleteViewSetMixin, TimestampedSerializerMixin
Coupon RBAC (all codenames, no public), tenancy, audit history (SimpleHistoryAdmin)
Review OwnerScopedViewSetMixin, the "authenticated" permission rule, deliberately not tenant-scoped
Tag Added live via anvil ai --apply during development — proof the propose→approve→write loop produces a genuinely working Resource

To run it yourself:

python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
cd demo
python manage.py migrate
python -m pytest products/ -v      # 49 tests
python manage.py anvil doctor
python manage.py seed_demo          # orgs, roles, users, sample data -- safe to re-run
python manage.py runserver

Then, e.g.:

curl http://127.0.0.1:8000/api/products/                                    # [] -- anonymous, no org context
curl -u alice:demo-pass-1234 http://127.0.0.1:8000/api/products/            # Acme's products only
curl -u bob:demo-pass-1234 http://127.0.0.1:8000/api/products/              # Globex's only
curl -u carol:demo-pass-1234 -X POST http://127.0.0.1:8000/api/products/ \
  -d 'name=Test&price=1&stock=1&is_active=true'                             # 403 -- Staff, view-only

Or open http://127.0.0.1:8000/admin/ (admin / demo-pass-1234) to browse Roles, Organizations, Products, Coupons (with a "History" button from audit logging), Reviews, and Tags directly. seed_demo prints the full list of demo accounts and what each one can do.

Release files for django-anvil 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-anvil 0.1.0
File Size Uploaded
django_anvil-0.1.0.tar.gz 42.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-anvil 0.1.0
File Interpreter ABI Platform
django_anvil-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 85.8 kB

Release files / django_anvil-0.1.0.tar.gz

Download URL django_anvil-0.1.0.tar.gz
Size 42.5 kB
Tags Source
SHA-256 checksum
How to use checksums
7a3f15d400707ce01e37f1e60e1be1ccb42f90e496616de3aacefe5cb60d16f1
BLAKE2b-256 checksum
How to use checksums
2421872e7e29050f522f45cdbbd79b483c6d82589f5375b0b910bc8444b29c12
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release files / django_anvil-0.1.0-py3-none-any.whl

Download URL django_anvil-0.1.0-py3-none-any.whl
Size 43.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
37f946f6deb4fc8a6027e86584a77e44ffa0591d600bfed5eca04b47f460bd88
BLAKE2b-256 checksum
How to use checksums
5cbd73e48e7053a839bac8908ade8e54c4f13da5effdba9ea031faa8f7e01637
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.6

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release 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