Skip to main content

Theia NG

A dynamic Angular admin for Django, generated from your models — no build step.

Theia NG brings a django.contrib.admin-style experience to a modern Angular SPA. You register your models the way you already know, and Theia NG serves a full CRUD admin introspected from your ORM at runtime. No code generation on your side, no Node toolchain to install — the compiled Angular app ships inside the wheel.

ℹ️ Named after Theia, the Titaness of sight. Referred to as Theia NG to avoid confusion with the Eclipse Theia IDE.


Why

  • ORM is the source of truth. Theia NG mirrors your Django models, not your API. Like the built-in admin, it ships its own auto-CRUD layer, so it works with any Django project — Django REST Framework is not required.
  • No dependency on django.contrib.admin. It builds on django.contrib.auth for permissions and uses the admin only as a design reference. Its ModelAdmin-style registry is its own.
  • Dynamic, not generated. Models are introspected at runtime and served as a small intermediate representation the SPA consumes. Model changes show up immediately; there is no client-side build.
  • One prefix, runtime-configured bundle. The SPA and its API mount under a single configurable prefix; the prebuilt bundle is prefix-independent.

Features

  • Auto-CRUD: list with search, filtering, sorting, pagination; create / update / delete; custom server-side actions
  • ModelAdmin-style config: list_display (incl. computed columns and relation lookups like house__company__name), list_filter (field filters + custom filters + date presets), search_fields, ordering, readonly_fields, exclude, raw_id_fields, actions, relation_filters, fieldsets, list_editable, inlines
  • Inlines — edit related child rows on the parent's form (theia_ng.Inline, tabular or stacked); created / updated / deleted in one transaction
  • Fieldsets — group form fields into sections (optional heading, description, collapsible) — and list_editable — edit cells inline in the list
  • Relation-spanning lookups (a__b__c) in list_display / list_filter: labelled, sortable, and filterable with no extra code
  • Date filters with relative presets (today, last 2 / 7 / 30 days, last year) or a specific day (time optional); locale- and timezone-aware date rendering in the user's chosen language and timezone
  • Parameterized actions — actions that pop a form (text, choices, relation pickers, …) and run server-side; selection="none" for global actions like a broadcast
  • Audit log — every write (create / update / delete / action) is recorded with a field-level diff; users see their own trail, superusers see everyone's
  • Searchable, paginated relation pickers (FK single, M2M multi) that load options on demand — fine for tables with thousands of rows. Related rows carry View / Edit / Delete actions (permission-aware) and navigate to the record
  • M2M selections shown as a table above the picker; breadcrumbs + Back so you always know where you are
  • raw_id fields get a modal table picker (searchable, paginated, loads only on open) that pre-selects current assignments and works purely by pk; a raw FK with a value also offers View / Edit buttons to jump to the related record
  • Toast notifications for every operation (green success / red error, top-right, auto-dismiss); Save and continue editing
  • Skeleton loaders on the list and home cards while data loads (no empty flash) — the list shows full-row skeleton bars on every page load (no spinner), so stale rows never linger during pagination
  • get_queryset, list_select_related / list_prefetch_related, and object-level permissions hooks for row scoping and N+1 avoidance
  • Per-model display control: display_field / display() for relation labels
  • Hierarchy tree — render a record inside a parent→children tree (tree_parent / tree_children), always from the topmost ancestor. Children load lazily as searchable, paginated mini-tables (scales to thousands of relations), with per-row permission-aware View / Edit / Delete
  • Menu views — admin-defined, named subsets of the sidebar (which models, and which of their fields), switchable from the top bar; always narrowed by perms
  • Favorites — each user stars their own home-page shortcuts (server-side, per user; intersected with what they may see)
  • Per-user personalization (server-side, follows the user across devices): UI language (9 built-in languages, runtime-translated), dark / light / auto theme, timezone, and drag-to-reorder the sidebar (app groups + models) and home favorites — with a one-click "Reset order"
  • Settings page — per-user preferences plus, for superusers, admin overrides of the deploy config (site title, logo, schema-cache TTL, cache version) with a reset-to-settings.py and a manual schema-cache flush
  • Configurable brand logo before the title (a static path or any URL)
  • Session login built into the SPA, gated by the theia_ng.access permission
  • Responsive throughout: collapsible sidebar (full → compact initials rail → off-canvas drawer on mobile), scrollable tables; a per-user greeting in the bar
  • Sidebar grouped by Django app; app names link to a per-app landing page of their model cards; sticky top bar with a settings gear and sign-out
  • Optional admin.py discovery — reuse existing django.contrib.admin registrations (incl. fieldsets, list_editable, inlines) via DISCOVER_ADMIN_FILES
  • Optional natural-language assistant — describe what you want and it builds the filter; deletes and creates are proposed, shown against the real queryset, and confirmed by you. Swappable provider (Ollama / OpenAI / Anthropic / your own) and no new dependency
  • Optional DRF delegation (use your serializers) and OpenAPI enrichment — both lazy, so the core never imports DRF

Requirements

  • Python 3.11+
  • Django 4.2 LTS or 5.2 LTS
  • Django REST Framework 3.14+ (optional, only for DRF delegation)

Installation

pip install theia_ng

Quickstart

1. Add the app to INSTALLED_APPS:

INSTALLED_APPS = [
    # …
    "django.contrib.auth",
    "theia_ng",
]

2. Mount it under any prefix in your root urls.py:

from django.urls import include, path

urlpatterns = [
    path("theia/", include("theia_ng.urls")),
]

3. Register models in an app's theia.py (autodiscovered, like admin.py):

import theia_ng
from myapp.models import Article, Category


@theia_ng.register(Article)
class ArticleAdmin(theia_ng.ModelAdmin):
    list_display = ["title", "category", "published", "byline"]
    list_filter = ["published", "category"]
    search_fields = ["title"]
    ordering = ["-created"]
    readonly_fields = ["created", "modified"]   # shown, but not editable

    @theia_ng.display(description="Byline")      # computed list_display column
    def byline(self, obj):
        return f"{obj.author} · {obj.created:%Y-%m-%d}"


@theia_ng.register(Category)
class CategoryAdmin(theia_ng.ModelAdmin):
    list_display = ["name"]
    search_fields = ["name"]

4. Migrate (creates the theia_ng.access permission and Theia's own tables — MenuView for sidebar views, UserSettings for per-user preferences, SiteConfig for admin config overrides — run this after upgrading too):

python manage.py migrate

5. Grant access — superusers always pass; otherwise give a user the theia_ng.access permission (plus the usual per-model view/add/change/delete permissions). Open …/theia/ and sign in.

Configuration

All optional, via a THEIA_NG dict in settings:

THEIA_NG = {
    "SITE_TITLE": "My Admin",   # shown in the top bar
    "LOGO_URL": "img/logo.png", # logo before the title; a static path (resolved
                                # via static()) or any absolute URL / data: URI
    "MOUNT_PREFIX": "/theia/",  # usually auto-detected from the request
    "SCHEMA_TTL": 300,          # IR cache TTL in seconds (0 disables caching)
    "CACHE_VERSION": "1",       # bump to invalidate the IR cache on deploy
    # List rows auto-select_related the forward relations their labels traverse
    # (kills N+1); on by default, set False to disable.
    "AUTO_SELECT_RELATED": True,
    # Optional fast list path — a swappable provider that serializes list pages
    # in bulk instead of per row. Unset = generic per-instance path (default).
    # "LIST_PROVIDER": "fastberry.list_provider.ListProvider",
    # Optional natural-language assistant. Absent => the feature does not exist:
    # no endpoint, no button. See "Natural-language assistant" below.
    # "LLM": {"PROVIDER": "openai_compatible",
    #         "BASE_URL": "http://localhost:11434/v1", "MODEL": "qwen2.5:7b"},
}

SITE_TITLE, LOGO_URL, SCHEMA_TTL and CACHE_VERSION are overridable at runtime from the Settings page (superusers): the override is stored in the SiteConfig row and layered over settings.py, with a one-click reset back to these values and a button to flush the schema cache. LIST_PROVIDER and MOUNT_PREFIX are structural and stay deploy-only. See Personalization & settings.

By default the list endpoint inspects each model's relation labels — the row's __str__, computed @display columns, and every FK's target __str__ (or display_field) — and select_relateds the forward-relation paths they reach, so a list never does a per-row N+1 for labels. It only follows forward FK/O2O hops (never reverse/M2M) and the output is unchanged. You can still add explicit list_select_related on a ModelAdmin; set THEIA_NG['AUTO_SELECT_RELATED'] = False to turn the automatic behaviour off.

The introspected schema is cached (structure only; per-user permissions are always computed fresh). Configure a shared cache backend (e.g. Redis) in production so all workers agree.

Permissions & auth

  • The SPA uses Django session auth (same origin). It sends the CSRF token as X-CSRFToken, so keep the default csrftoken cookie readable (CSRF_COOKIE_HTTPONLY = False, the Django default).

  • Entry is gated by the theia_ng.access permission — not is_staff, so it never collides with the Django admin.

  • Per-model access uses the standard view/add/change/delete permissions. Override has_*_permission(self, request, obj=None) on your ModelAdmin to plug in a custom scheme. On detail endpoints obj is the target instance, so you can enforce object-level rules:

    def has_change_permission(self, request, obj=None):
        if obj is not None:
            return obj.owner_id == request.user.id
        return super().has_change_permission(request)
    

Customizing the form & columns

@theia_ng.register(Article)
class ArticleAdmin(theia_ng.ModelAdmin):
    readonly_fields = ["created_by"]   # shown in the form, but disabled
    exclude = ["internal_notes"]       # dropped from the form entirely
    raw_id_fields = ["author"]         # plain id input instead of a picker
  • readonly_fields — rendered in the form, disabled (good for audit fields).
  • exclude — removed from the form (still usable in list_display).
  • raw_id_fields — render an FK/M2M as a plain id input rather than the searchable picker. Relations whose target model isn't registered fall back to this automatically.

Descriptions & hints

Set description for a short blurb shown under the title on a model's list and detail pages; field-level help_text from the model renders as a small hint under each field:

@theia_ng.register(Article)
class ArticleAdmin(theia_ng.ModelAdmin):
    description = "Public articles, drafted then published by editors."

Fieldsets

Group form fields into sections (django-admin's fieldsets). A "collapse" class makes the section collapsible (collapsed by default); fields you don't list still render (so a required-on-create field is never hidden):

@theia_ng.register(Article)
class ArticleAdmin(theia_ng.ModelAdmin):
    fieldsets = [
        (None, {"fields": ["title", "category", "body"]}),
        ("Advanced", {
            "fields": ["slug", "internal_notes"],
            "classes": ["collapse"],
            "description": "Rarely changed.",
        }),
    ]

Inline editing in the list (list_editable)

Make non-relation columns editable in place on the list. They must also be in list_display; a save bar commits all edited rows at once, and a row click still opens the record:

@theia_ng.register(Stock)
class StockAdmin(theia_ng.ModelAdmin):
    list_display = ["title", "quantity", "is_active"]
    list_editable = ["quantity", "is_active"]

Inlines

Edit a parent's related child rows right on its form (django-admin's inlines). Subclass theia_ng.Inline, point it at the child model, and list it in the parent's inlines. The child's foreign key back to the parent is auto-detected (set fk_name if ambiguous) and filled in on save; rows are created / updated / deleted in the same transaction as the parent (an invalid child rolls the whole save back):

class ItemInline(theia_ng.Inline):
    model = OrderItem
    fields = ["product", "qty", "price"]   # the parent FK is excluded
    extra = 1                              # blank rows offered for adding
    style = "tabular"                      # "tabular" (grid) | "stacked"
    # can_delete, readonly_fields, exclude, raw_id_fields, fk_name also supported

@theia_ng.register(Order)
class OrderAdmin(theia_ng.ModelAdmin):
    inlines = [ItemInline]

Each inline cell reuses the normal field widgets, so child FK / choice / boolean fields edit exactly like the main form. Tabular inlines lay rows out as a grid (labels become column headers); stacked inlines render each row as a labelled block.

Personalization & settings

A Settings page (gear icon in the top bar) holds:

  • Per-user preferences — UI language (English, Hungarian, German, French, Chinese, Korean, Russian, Spanish, Turkish), theme (dark / light / auto, following the OS), and timezone. Stored server-side per user (they follow the user across devices); the defaults come from Django (get_language(), TIME_ZONE). Dates and numbers render in the chosen locale and timezone.
  • Site settings (superusers) — override THEIA_NG['SITE_TITLE'], LOGO_URL, SCHEMA_TTL and CACHE_VERSION from the UI; each shows its settings.py default as a hint, with a Reset to defaults that drops the override.
  • Maintenance (superusers) — Clear schema cache, flushing the cached IR immediately (without bumping CACHE_VERSION).

The sidebar (app groups and the models within them) and the home favorites are drag-to-reorder (a handle on each item; the rest of the row still navigates), saved per user, with a Reset order control to restore the default ordering.

Scoping rows: get_queryset

Override the base queryset for both the list and detail (e.g. multi-tenant scoping, annotations, or select_related). Rows it excludes are hidden from detail/update/delete too, not just the list:

def get_queryset(self, request):
    return super().get_queryset(request).filter(company=request.user.company)

Custom list filters

Field names in list_filter build the obvious filters. For anything else, add a ListFilter subclass (the equivalent of django.contrib.admin.SimpleListFilter) — it contributes a labelled dropdown and applies arbitrary queryset logic:

class HasOrdersFilter(theia_ng.ListFilter):
    title = "Has orders"
    parameter_name = "has_orders"

    def lookups(self, request):
        return [("yes", "Has orders"), ("no", "No orders")]

    def queryset(self, request, queryset, value):
        if value == "yes":
            return queryset.filter(orders__isnull=False).distinct()
        if value == "no":
            return queryset.filter(orders__isnull=True)
        return queryset


@theia_ng.register(Customer)
class CustomerAdmin(theia_ng.ModelAdmin):
    list_filter = ["active", HasOrdersFilter]   # mix field + custom filters

Choices come from lookups() (keep them static — they're cached with the schema). The filter runs after get_queryset, so any annotations are available.

Menu views

Admins can define named views that narrow the left sidebar: which models appear, and optionally which of each model's fields are shown (in the list and the form). A view only ever narrows within what the user may already see — permissions are checked first, then the view intersects. Staff switch the active view from a button in the top bar that opens a picker dialog (an icon-only button on mobile); "Full" (everything permitted) is always available.

Views are stored in the built-in MenuView model and managed through the admin itself (no code) — so they're maintainable at runtime. Nothing to configure; just migrate and a "Menu views" entry appears in the sidebar.

Dependent relation options

Narrow a relation field's options by the current values of sibling fields on the record being edited — e.g. only show Spaces that belong to the Stock's selected house:

@theia_ng.register(Stock)
class StockAdmin(theia_ng.ModelAdmin):
    relation_filters = {
        "spaces": {"house": "house"},   # {target_lookup: source_field}
    }

This loads Space.objects.filter(house=<the form's current house value>). The picker re-fetches when house changes, and shows nothing until it is set. The filter lookups are server-defined (the client only sends the sibling values), so it can't be used to query arbitrary fields.

Hierarchy tree

Show a record inside a parent→children tree — e.g. a multi-tenant Registration → Company → House → Space hierarchy. Each model declares the forward FK up to its parent (tree_parent) and the reverse accessors down to its children (tree_children):

@theia_ng.register(Registration)
class RegistrationAdmin(theia_ng.ModelAdmin):
    tree_children = ["companies"]          # reverse accessor (related_name)

@theia_ng.register(Company)
class CompanyAdmin(theia_ng.ModelAdmin):
    tree_parent = "registration"
    tree_children = ["houses"]

@theia_ng.register(House)
class HouseAdmin(theia_ng.ModelAdmin):
    tree_parent = "company"
    tree_children = ["spaces"]

@theia_ng.register(Space)
class SpaceAdmin(theia_ng.ModelAdmin):
    tree_parent = "house"                   # leaf

Any model with tree_parent and/or tree_children gets a Hierarchy button on its detail page. The tree always renders from the topmost ancestor, regardless of which level you opened it from (open it on a Space and you still see the whole Registration tree, with the lineage auto-expanded down to that Space and highlighted).

Children load lazily: each node shows its child relations as collapsed groups with a count; expanding one fetches a searchable, paginated mini-table (server-side search over the child's search_fields, page size = its list_per_page). Nothing is loaded until you open it, so a node with thousands of children stays fast. A child group is hidden entirely if the user can't view that model. tree_children uses Django's reverse accessor names (the related_name, or the default <model>_set).

Compact hierarchy

The detail page also offers a collapsible compact hierarchy: instead of lazy mini-tables it builds the whole tree (from the topmost ancestor, every descendant) in a single eager request and renders it as a simple indented table, with a single permission-based action per row (Edit if you may change it, else View) and no delete. The opened record is marked (this record).

@compact_tree field

To embed a compact tree among the form fields (independent of the page section above), decorate a method that returns the object to root it at, and drop its name into fields / fieldsets like any field. It is read-only, roots at the returned object's own descendants, and is hidden when the method returns None:

@theia_ng.register(House)
class HouseAdmin(theia_ng.ModelAdmin):
    fieldsets = [(None, {"fields": ["name", "company", "structure"]})]

    @theia_ng.compact_tree(description="Structure")
    def structure(self, obj):
        return obj            # this House's spaces/stocks
        # return obj.company  # ...or root higher up

Parameterized actions

A plain action runs over a selection: method(request, queryset). A parameterized action also pops a small form — declare it with @theia_ng.action and it receives the collected values as a third params dict. selection controls whether it needs selected rows: "required" (default), "optional", or "none" (a global action, e.g. a broadcast):

@theia_ng.register(Message)
class MessageAdmin(theia_ng.ModelAdmin):
    actions = ["broadcast"]

    @theia_ng.action(
        label="Broadcast message",
        selection="none",                       # ignores row selection
        fields=[
            theia_ng.ActionField("body", "text", label="Message", required=True),
            theia_ng.ActionField("to_all", "boolean", label="All active users"),
            theia_ng.ActionField("recipients", "m2m", relation="users.customuser"),
            theia_ng.ActionField("send_push", "boolean", label="Send push"),
        ],
    )
    def broadcast(self, request, queryset, params):
        recipients = (
            User.objects.filter(is_active=True) if params["to_all"]
            else User.objects.filter(pk__in=params["recipients"], is_active=True)
        )
        Message.objects.bulk_create([...])
        return {"sent": recipients.count()}

ActionField types reuse the IR field types (string, text, integer, decimal, boolean, choice, plus fk / m2m with relation="app.model" for a searchable picker). The SPA renders the form with the same widgets as the record form and validates required fields server-side. Selection-less (none) actions are toolbar buttons; selection-driven ones appear in the bulk bar below.

Object (detail) actions

detail=True makes an action run on a single record and show up as a button on that record's detail page (not in the list's bulk bar) — the equivalent of a custom button on django-admin's change page. The queryset it receives holds just that one record; its fields are the inputs beyond the record itself (e.g. a copy target). dangerous=True adds a confirm step.

@theia_ng.register(House)
class HouseAdmin(theia_ng.ModelAdmin):
    actions = ["copy_stocks", "clear_modules"]

    @theia_ng.action(
        label="Copy stock(s) to house",
        detail=True,                              # button on the House page
        fields=[theia_ng.ActionField("target", "fk", relation="structure.house",
                                     required=True)],
    )
    def copy_stocks(self, request, queryset, params):
        source = queryset.first()                 # the open record
        ...

    @theia_ng.action(label="Clear modules", detail=True, dangerous=True)
    def clear_modules(self, request, queryset, params):
        queryset.first().modules.clear()

The detail buttons sit below the record title and wrap onto new rows when they don't fit.

Bulk actions & row selection

The list has row checkboxes and a bulk action bar (django-admin style), on by default. Toggle per model with list_selectable:

@theia_ng.register(Stock)
class StockAdmin(theia_ng.ModelAdmin):
    list_selectable = True          # default; set False to hide checkboxes
    actions = ["deactivate"]        # selection-driven actions show in the bulk bar

    def deactivate(self, request, queryset):
        return {"updated": queryset.update(is_active=False)}
  • A built-in "Delete selected" action is always available (gated on delete permission); your actions with selection "required"/"optional" join it in the bar.
  • The header checkbox selects every row on the page. When a page is fully selected, a banner offers "Select all N" — running the action then operates on every record matching the current filters/search (the server rebuilds that queryset from the same filters; it isn't sent a giant id list).
  • Dangerous actions (like delete) confirm first. Actions are gated on the permission they need (change for custom actions, delete for the built-in).

Audit log

Every write through Theia NG (create / update / delete / action) is recorded as a LogEntry: who, when, which model + object, and — for create/update — a field-level diff ({field: [old, new]}, audit-noise fields excluded). It is best-effort, so logging never breaks the operation it records.

The Activity page (linked from the sidebar and the home "Theia NG Admin" section) lists the entries, filterable by action and model. Regular users see only their own trail; superusers see everyone's and can filter by user. No configuration needed — it follows from migrate.

Discovering existing admin.py

If your project (or a third-party package) already configures models with django.contrib.admin, Theia NG can reuse that — set:

THEIA_NG = {"DISCOVER_ADMIN_FILES": True}

On startup, after your theia.py registrations, Theia imports every app/package admin.py and, for each model not already registered with Theia, builds a ModelAdmin from the compatible subset of the Django admin's options.

Copied (same meaning in both): list_display (real fields + a__b lookups only), list_filter (plain field-name filters only), search_fields, ordering, list_per_page, readonly_fields, exclude, raw_id_fields, fields (flat lists), list_select_related (when an explicit list), fieldsets, list_editable, and inlines (TabularInline / StackedInline, mapped to theia_ng.Inline).

Dropped (Django-specific / not portable): callable or admin-method list_display columns, SimpleListFilter classes, actions, custom widgets, date_hierarchy, autocomplete_fields, etc. A discovered model renders with safe defaults rather than broken columns. Explicit theia.py registrations always win, and one broken admin.py never breaks the rest.

Natural-language assistant (optional)

Lets someone describe what they want instead of navigating to it — "the cancelled sheets from last week" — and turns it into a filter on the list. It can also propose a delete or a create, which the user confirms against the real queryset before anything happens.

Off unless configured, and it adds no Python dependency: the built-in providers speak HTTP through the standard library, so pointing Theia at Ollama, vLLM, OpenAI or Anthropic is a settings change.

A ready-to-run local model stack is in examples/llm/ — docker compose up -d and you have a free, self-hosted model.

Settings

THEIA_NG = {
    # ... the rest of your config ...
    "LLM": {
        # Which provider implementation to use:
        #   "openai_compatible" — anything exposing POST /v1/chat/completions:
        #                         Ollama, llama.cpp server, vLLM, LM Studio,
        #                         OpenRouter, Groq, Together, OpenAI itself.
        #   "anthropic"         — the Anthropic Messages API.
        #   "myapp.llm.MyProvider" — a dotted path to your own subclass of
        #                         theia_ng.llm.LLMProvider.
        "PROVIDER": "openai_compatible",

        # Base URL up to and including /v1. Required for openai_compatible;
        # optional for anthropic (defaults to https://api.anthropic.com/v1).
        "BASE_URL": "http://localhost:11434/v1",

        # Model name as the endpoint knows it. Required.
        "MODEL": "qwen2.5:7b",

        # Sent as a Bearer token (or x-api-key for anthropic). Leave empty for a
        # local model. Read it from the environment — never commit a key.
        "API_KEY": os.getenv("THEIA_LLM_API_KEY", ""),

        # Seconds to wait for the model. Default 20. A request holds a worker for
        # this long, so keep it tight; the UI degrades to "unavailable" on timeout.
        "TIMEOUT": 20,

        # Retries after a reply fails validation. Default 1 (so 2 attempts total).
        "MAX_RETRIES": 1,

        # Rollout gate. Empty/absent = every registered model. List model keys to
        # enable it for those only. This is deploy-level and cannot be overridden
        # by host code; to opt ONE model out, prefer `assist = False` on its
        # ModelAdmin, which lives next to that model's other configuration.
        "ALLOW_MODELS": [],

        # anthropic only:
        # "MAX_TOKENS": 512,
        # "ANTHROPIC_VERSION": "2023-06-01",
    },
}

Per-model opt-out, in code:

@theia_ng.register(Payroll)
class PayrollAdmin(theia_ng.ModelAdmin):
    assist = False   # never offer the assistant here

Provider examples

# Local Ollama (see examples/llm/). From another container use
# http://host.docker.internal:11434/v1 on Docker Desktop.
"LLM": {"PROVIDER": "openai_compatible",
        "BASE_URL": "http://localhost:11434/v1", "MODEL": "qwen2.5:7b"}

# OpenAI
"LLM": {"PROVIDER": "openai_compatible", "BASE_URL": "https://api.openai.com/v1",
        "MODEL": "gpt-4o-mini", "API_KEY": os.getenv("OPENAI_API_KEY")}

# Anthropic. The key comes from the Anthropic Console (console.anthropic.com)
# and is billed per token — a Claude.ai chat subscription is a separate product
# and does not provide one.
"LLM": {"PROVIDER": "anthropic", "MODEL": "claude-opus-5",
        "API_KEY": os.getenv("ANTHROPIC_API_KEY")}

What it is allowed to do

The model proposes; it never executes. It returns a filter state; if it proposes a delete or a create, the user is shown the real queryset (exact count plus sample rows) and must confirm. The confirmed action then runs through the existing endpoints — delete_selected and the ordinary create view — so permission checks, full_clean() and the audit entry apply exactly as they would without the assistant.

  • Permissions first. Theia access and the model's view permission; a delete proposal additionally needs delete permission or it is downgraded to a filter.
  • Only schema metadata leaves your server — the filterable fields and their allowed values, with labels. Never row data. Relations and traversals are not described at all, since setting one would mean guessing a primary key.
  • Everything the model returns is re-validated against that same slice. Anything not provably allowed is dropped and reported, never trusted.
  • An unqualified delete is refused, not proposed: with no filter it would match the whole table, and that has to be deliberate.
  • Every prompt is audited (LogEntry action assist) with the user, the sentence, the interpreted intent and the match count — including prompts that were refused. The write that follows a confirmation is audited separately, so the instruction and the effect stay distinct events.

Privacy: field names, choice labels and the user's typed sentence go to whatever endpoint you configure, and the sentence may contain customer names. That is an argument for a local model in production, and the reason the provider is swappable.

Optional: hints, dictionary and examples

Two admin-editable tables (Assistant hints, Assistant examples) can add material to the prompt: a model description, per-field explanations, a dictionary of terms your users say, and worked examples.

They ship empty on purpose. On a local 7B model, added prompt prose made the assistant measurably worse on unseen sentences — 82% → 73% for plain field descriptions, and 59% when a hint listed trigger words. Small models drift toward answering instead of refusing as the prompt grows. See docs/llm-eval/RESULTS.md for the full measurement.

If you add hints, treat it as an experiment: write short descriptions of what a field means, never lists of trigger words, and use the examples table as a regression set to check that an edit actually helped. A per-deployment character budget caps how much can reach the prompt.

Optional: DRF delegation

If a model already has a DRF serializer, let Theia NG defer to it for (de)serialization and validation:

@theia_ng.register(Article)
class ArticleAdmin(theia_ng.ModelAdmin):
    serializer_class = ArticleSerializer

The serializer also enriches the schema (required / read-only / help text). DRF is imported lazily — if you don't use this, you don't need DRF installed.

Optional: fast list provider

The list endpoint serializes one model instance per row by default. For large or hot lists you can plug in a list provider that builds the whole page from column-projected queries instead — no per-row instances.

A ready-made one is fastberry: its ListProvider compiles each list into a column-projected schema (FK labels via a join, M2M set-based over the through table) and encodes the page in one go — typically several times faster on large/relational lists. To use it:

pip install fastberry
THEIA_NG = {"LIST_PROVIDER": "fastberry.list_provider.ListProvider"}

Theia core has no dependency on any provider — the dotted path is the only coupling, and the provider is fully swappable (fastberry is just the reference implementation). A model is only accelerated when its labels are DB-expressible (its admin, and every relation target's admin, set display_field); otherwise that model transparently uses the generic path, so output is identical either way. When LIST_PROVIDER is unset there is no fast path at all.

Want to back it with something else (raw .values(), a SQL view, a non-Django store)? Writing a provider is one method. See docs/list_provider.md.

Changelog

See CHANGELOG.md for the per-release history.

License

MIT — see LICENSE.

Metadata

Release files for theia-ng 0.28.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 theia-ng 0.28.0
File Size Uploaded Provenance Actions
theia_ng-0.28.0.tar.gz 366.6 kB With provenance Details for theia_ng-0.28.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for theia-ng 0.28.0
File Interpreter ABI Platform Actions
theia_ng-0.28.0-py3-none-any.whl Python 3 none any Details for theia_ng-0.28.0-py3-none-any.whl

Total release size: 646.6 kB

Release files / theia_ng-0.28.0.tar.gz

Download URL theia_ng-0.28.0.tar.gz
Size 366.6 kB
Tags Source
SHA-256 checksum
How to use checksums
6310f81cb221d684365189409af346502ccbce202c1952e0beea774956e4fc8a
BLAKE2b-256 checksum
How to use checksums
ba4780660370a581771af664ba74210ad1623081a623e3f0f29ea2e503943592
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.

Transparency log

Release files / theia_ng-0.28.0-py3-none-any.whl

Download URL theia_ng-0.28.0-py3-none-any.whl
Size 280.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d1e9e929963f5f6540f5d1fd3a69a273b5d10cb7e446ac414e2358ff33962802
BLAKE2b-256 checksum
How to use checksums
552cc7879df5232ffcd65a5077dbc28d23652f4d88e8827f511533f06adc47f5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.

Transparency log

Security for theia-ng 0.28.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for theia_ng-0.28.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 8, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.28.0 This release

2 release files Full provenance

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