Admin Panel

The openviper.admin package provides a fully-featured, auto-generated administration interface for OpenViper models. Mount it at /admin in routes.py and all registered models are immediately accessible through a React-based SPA backed by a REST API.

Overview

The admin panel is built around three components:

  1. ModelAdmin — per-model display and behavior configuration.

  2. AdminRegistry — central registry that maps model classes to their ModelAdmin instances.

  3. Admin Site — the router factory (get_admin_site()) that mounts the API and SPA at a given URL prefix.

Models are registered in admin.py inside each installed app and auto-discovered when the admin site is first accessed.

Key Classes & Functions

class openviper.admin.options.ModelAdmin(model_class)

Configuration class for admin model behavior. Subclass it to customize how a model appears in the admin panel.

List view attributes:

list_display: list[str]

Field names shown as columns in the list view. Defaults to id plus the first four fields.

Fields that are rendered as links to the detail view. Defaults to the first field in list_display.

list_filter: list[str]

Fields shown in the sidebar filter panel.

list_editable: list[str]

Fields that can be edited inline in the list view (without opening the detail form).

search_fields: list[str]

Fields searched when the admin search box is used.

ordering: str | list[str] | None

Default ordering for the list view. Prefix with - for descending. Defaults to ["-id"].

list_per_page: int

Rows per page in the list view (default: 25).

list_max_show_all: int

Maximum rows shown when “Show all” is clicked (default: 200).

date_hierarchy: str | None

A DateTimeField name used to drill down by year/month/day.

FK fields to eager-load in the list view via select_related. Set to True to auto-detect from FK fields in list_display.

Form view attributes:

fields: list[str] | None

Explicit list of fields shown in the create/edit form. When None all non-excluded, non-id fields are shown.

exclude: list[str] | None

Fields to hide from the form (alternative to fields).

readonly_fields: list[str]

Fields displayed in the form but not editable.

fieldsets: list[tuple[str | None, dict]]

Grouped form layout: list of (title, {"fields": [...], "classes": [...], "description": "..."}) tuples. Set title=None for an untitled group.

form_fields: dict[str, dict]

Per-field widget overrides, e.g. {"body": {"widget": "textarea", "rows": 10}}.

list_display_styles: dict[str, str]

Per-column CSS class overrides for list view cells.

sensitive_fields: list[str]

Fields never exposed in API responses (default: ["password"]). Extend to include tokens, secrets, API keys, etc.

Actions:

actions: list[Callable]

List of callables (or method name strings) available as batch actions in the list view. The built-in delete_selected action is always available.

actions_on_top: bool

Show the action bar above the list (default: True).

actions_on_bottom: bool

Show the action bar below the list (default: False).

Inlines:

inlines: list[type[InlineModelAdmin]]

Inline model admin classes for nested editing of related objects.

child_tables: list[type[ChildTable]]

Alias for inlines using the tabular layout.

UI options:

save_on_top: bool

Show save buttons at the top of the form (default: False).

preserve_filters: bool

Keep current list filters active after editing (default: True).

Permission methods:

has_view_permission(request, obj=None) bool
has_add_permission(request) bool
has_change_permission(request, obj=None) bool
has_delete_permission(request, obj=None) bool

All return True for staff or superuser by default. Override to add object-level permission logic.

CRUD methods:

save_model(request, obj, form_data, change=False) Awaitable[Model]

Apply form_data to obj and call obj.save(). Override to add pre/post-save side effects.

delete_model(request, obj) Awaitable[None]

Call obj.delete(). Override to add pre-delete logic.

class openviper.admin.options.InlineModelAdmin(parent_model)

Configuration for inline (nested) model editing.

model: type[Model]

The related model class. Required.

fk_name: str | None

Name of the FK field on the inline model pointing back to the parent. Auto-detected when there is exactly one FK.

fields: list[str] | None

Fields shown in the inline form. Defaults to all model fields.

exclude: list[str] | None

Fields to hide.

readonly_fields: list[str]

Read-only fields.

extra: int

Number of blank extra rows (default: 3).

max_num: int | None

Maximum number of inline objects.

min_num: int | None

Minimum number of inline objects.

can_delete: bool

Show delete checkbox on each inline row (default: True).

class openviper.admin.options.TabularInline(parent_model)

Subclass of InlineModelAdmin rendered as a horizontal table.

class openviper.admin.options.StackedInline(parent_model)

Subclass of InlineModelAdmin rendered as vertical cards.

class openviper.admin.options.ChildTable(parent_model)

Alias for TabularInline.

class openviper.admin.registry.AdminRegistry

Central registry for admin-managed models.

register(model, admin_class=None)

Register model with an optional admin_class. Uses the default ModelAdmin when admin_class is None.

unregister(model)

Remove a model from the registry.

is_registered(model) bool

Return True if model is registered.

get_admin(model) ModelAdmin

Return the ModelAdmin instance for model.

openviper.admin.site.get_admin_site() Router

Create and return the complete admin site router, including:

  • REST API routes at /api/

  • Static asset serving at /assets/ (DEBUG only)

  • SPA fallback for all other routes

Example Usage

See also

Working projects that use the admin panel:

Registering a Model

Create myapp/admin.py:

from openviper.admin import register, ModelAdmin
from myapp.models import Post, Comment

@register(Post)
class PostAdmin(ModelAdmin):
    list_display = ["title", "author", "is_published", "created_at"]
    list_display_links = ["title"]
    list_filter = ["is_published", "created_at"]
    list_editable = ["is_published"]
    search_fields = ["title", "body"]
    ordering = "-created_at"
    list_per_page = 50
    readonly_fields = ["created_at", "updated_at"]
    sensitive_fields = ["password", "internal_token"]
    fieldsets = [
        ("Content", {"fields": ["title", "body"]}),
        ("Publishing", {
            "fields": ["author", "is_published"],
            "description": "Controls when the post is visible",
        }),
        ("Timestamps", {
            "fields": ["created_at", "updated_at"],
            "classes": ["collapse"],
        }),
    ]
    date_hierarchy = "created_at"
    list_select_related = ["author"]   # or True for auto-detect

@register(Comment)
class CommentAdmin(ModelAdmin):
    pass

Mounting the Admin Site

# routes.py
from openviper.admin.site import get_admin_site

route_paths = [
    ("/", main_router),
    ("/admin", get_admin_site()),
]

Now visit http://localhost:8000/admin/ to access the admin panel.

Custom Actions

from openviper.admin import register, ModelAdmin, action, ActionResult
from myapp.models import Post

@register(Post)
class PostAdmin(ModelAdmin):
    actions = ["publish_posts", "archive_posts"]
    list_display = ["title", "is_published", "is_archived"]

    @action(description="Publish selected posts")
    async def publish_posts(self, queryset, request):
        count = await queryset.update(is_published=True)
        return ActionResult(success=True, count=count, message=f"Published {count} posts.")

    @action(description="Archive selected posts")
    async def archive_posts(self, queryset, request):
        count = await queryset.update(is_archived=True, is_published=False)
        return ActionResult(success=True, count=count, message=f"Archived {count} posts.")

Inline Editing

from openviper.admin import register, ModelAdmin, ChildTable
from openviper.admin.options import StackedInline
from myapp.models import Post, Comment, Tag

class CommentInline(ChildTable):
    model = Comment
    fk_name = "post"       # FK on Comment pointing to Post
    fields = ["author", "body", "created_at"]
    readonly_fields = ["created_at"]
    extra = 1
    max_num = 20
    can_delete = True

class TagInline(StackedInline):
    model = Tag
    fields = ["name", "slug"]

@register(Post)
class PostAdmin(ModelAdmin):
    inlines = [CommentInline, TagInline]

Overriding Permissions

@register(Post)
class PostAdmin(ModelAdmin):
    def has_delete_permission(self, request=None, obj=None):
        # Only superusers can delete posts
        if request is None:
            return True
        user = getattr(request, "user", None)
        return getattr(user, "is_superuser", False)

    def has_change_permission(self, request=None, obj=None):
        # Users can only edit their own posts
        if request is None or obj is None:
            return True
        user = getattr(request, "user", None)
        if getattr(user, "is_superuser", False):
            return True
        return obj.author_id == getattr(user, "pk", None)

Overriding save_model

@register(Post)
class PostAdmin(ModelAdmin):
    async def save_model(self, request, obj, form_data, change=False):
        if not change:
            # Set author automatically on create
            obj.author_id = request.user.pk
        return await super().save_model(request, obj, form_data, change)

Sensitive Fields

from openviper.admin import register, ModelAdmin
from openviper.auth import get_user_model

User = get_user_model()

@register(User)
class UserAdmin(ModelAdmin):
    list_display = ["id", "username", "email", "is_active"]
    # 'password' is hidden by default; extend for other secrets:
    sensitive_fields = [
        "password",
        "api_key",
        "refresh_token",
    ]
    readonly_fields = ["created_at", "last_login"]