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 Vue 3 SPA backed by a REST API.

Overview

The admin panel is built around four 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.

  4. AdminMiddleware - ASGI middleware that enforces authentication on /admin/api/ routes.

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] | None

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] | None

Fields shown in the sidebar filter panel.

list_editable: list[str] | None

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

search_fields: list[str] | None

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.

list_display_styles: dict[str, str] | None

Per-column CSS class overrides for list view cells.

show_full_result_count: bool

Whether to show the full count of matching rows (default: True).

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] | None

Fields displayed in the form but not editable.

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

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

form_fields: dict[str, dict] | None

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

sensitive_fields: list[str] | None

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

Actions:

actions: list[Callable] | None

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.

is_intrinsically_readonly(field) bool

Return True if a field is auto-managed (AutoField, auto_now, auto_now_add) and should not be edited on create.

action_delete_selected(request, queryset) Awaitable[int]

Built-in action: delete all objects in the queryset. Returns the count of deleted objects.

Dynamic getter methods:

Each list/form attribute has a corresponding get_* method that accepts an optional request (and sometimes obj) for request-aware overrides:

  • get_list_display(request=None)

  • get_list_display_links(request=None)

  • get_list_filter(request=None)

  • get_search_fields(request=None)

  • get_ordering(request=None)

  • get_list_select_related(request=None)

  • get_fields(request=None, obj=None)

  • get_exclude(request=None, obj=None)

  • get_sensitive_fields(request=None, obj=None)

  • get_readonly_fields(request=None, obj=None)

  • get_fieldsets(request=None, obj=None)

  • get_form_field_config(field_name)

  • get_actions(request=None)

  • get_model_info(request=None)

  • get_child_tables_info()

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.

extra_filters: dict | None

Additional filters applied to the inline queryset.

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).

Show a link to the full edit form for each inline row (default: False).

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_class, admin_class=None)

Register model_class with an optional admin_class. Uses the default ModelAdmin when admin_class is None. Raises AlreadyRegistered if the model is already registered.

unregister(model_class)

Remove a model from the registry. Raises NotRegistered if the model is not registered.

is_registered(model_class) bool

Return True if model_class is registered.

get_model_admin(model_class) ModelAdmin | None

Return the ModelAdmin instance for model_class, or None if not registered.

get_model_admin_by_name(model_name) ModelAdmin

Return the ModelAdmin instance by model class name (case-insensitive). Raises NotRegistered if not found.

get_model_admin_by_app_and_name(app_label, model_name) ModelAdmin

Return the ModelAdmin instance by app label and model name. Raises NotRegistered if not found.

get_model_by_name(model_name) type[Model]

Return the model class by name (case-insensitive). Raises NotRegistered if not found.

get_model_by_app_and_name(app_label, model_name) type[Model]

Return the model class by app label and model name. Raises NotRegistered if not found.

get_all_models() list[tuple[type[Model], ModelAdmin]]

Return all registered non-abstract models with their admin configurations.

get_models_grouped_by_app() dict[str, list[tuple[type[Model], ModelAdmin]]]

Return registered models grouped by their app name.

auto_discover_from_installed_apps() None

Import admin.py from each app in INSTALLED_APPS. Idempotent - only runs once.

discover_from_app(app_name) None

Import and register models from a single app’s admin.py module.

exception openviper.admin.registry.AlreadyRegistered(ValueError)

Raised when a model is registered more than once.

exception openviper.admin.registry.NotRegistered(ValueError)

Raised when accessing an unregistered model.

openviper.admin.site.get_admin_site() Router

Create and return the complete admin site router, including:

  • REST API routes at /api/

  • Extension manifest at /api/extensions/

  • Extension file serving at /extensions/{app_name}/{path} (DEBUG only)

  • Static asset serving at /assets/{path} (DEBUG only)

  • SPA fallback for all other routes

Calls autodiscover() before building the router.

Actions System

class openviper.admin.actions.AdminAction

Base class for admin batch actions. Subclass to create custom actions that can be performed on multiple selected objects.

name: str

Internal name for the action (defaults to lowercase class name).

description: str

Human-readable description shown in the UI.

confirm_message: str | None

Optional confirmation prompt displayed before execution.

permissions: list[str]

Required permissions to execute this action.

execute(queryset, request, model_admin=None) Awaitable[ActionResult]

Execute the action on the queryset. Must be overridden.

has_permission(request) bool

Check if the user has permission to run this action.

get_info() dict

Return action metadata for API responses.

class openviper.admin.actions.DeleteSelectedAction

Built-in action to delete selected objects. Registered as "delete_selected" in the global action registry.

openviper.admin.actions.action_registry

Global dict[str, type[AdminAction]] mapping action names to their classes.

openviper.admin.actions.register_action(action_class) type[AdminAction]

Register a custom action class with the global registry. Can be used as a decorator.

openviper.admin.actions.get_action(name) AdminAction | None

Return an action instance by name, or None if not found.

openviper.admin.actions.get_available_actions(request) list[AdminAction]

Return all actions the current user has permission to execute.

openviper.admin.actions.action(description=None, confirm_message=None, permissions=None) Callable

Decorator to create an AdminAction from a function. The decorated function may accept (queryset, request) or (model_admin, queryset, request) depending on the number of parameters. Async functions are awaited automatically.

class openviper.admin.actions.ActionResult

Dataclass returned by action execution.

success: bool

Whether the action completed successfully.

count: int

Number of objects affected.

message: str

Human-readable result message.

errors: list[str] | None

List of error messages, if any.

Change History

class openviper.admin.history.ChangeHistory

Model for tracking changes to admin-managed objects. Stores a record of every create, update, and delete operation performed through the admin interface.

model_name: CharField(max_length=100, db_index=True)
object_id: CharField(max_length=255, db_index=True)
object_repr: CharField(max_length=255)
action: CharField(max_length=10)

One of "add", "change", or "delete".

changed_fields: TextField(null=True)

JSON-encoded dict of field changes.

changed_by_id: CharField(max_length=255, null=True, db_index=True)
changed_by_username: CharField(max_length=150, null=True)
change_time: DateTimeField(auto_now_add=True)
change_message: TextField(null=True)
get_changed_fields_dict() dict

Parse changed_fields JSON to a dict.

classmethod get_for_object(model_name, object_id, limit=50) list[ChangeHistory]

Get change history for a specific object, ordered by most recent first.

class openviper.admin.history.ChangeAction(StrEnum)

Enum of change action types: ADD, CHANGE, DELETE.

openviper.admin.history.log_change(model_name, object_id, action, changes=None, user=None, object_repr=None, message=None) Awaitable[ChangeHistory]

Create a change history record.

openviper.admin.history.get_change_history(model_name, object_id, limit=50) Awaitable[list[ChangeHistory]]

Get change history for an object, most recent first.

openviper.admin.history.get_recent_activity(limit=20) Awaitable[list[ChangeHistory]]

Get recent change activity across all models.

openviper.admin.history.compute_changes(old_data, new_data) dict[str, dict]

Compute the differences between old and new field values. Returns a dict mapping field names to {"old": ..., "new": ...} dicts.

openviper.admin.history.normalize_for_compare(val) Any

Normalize a value for change comparison. Converts datetime/date/time objects to ISO strings so that comparisons between ORM-returned objects and coerced request values do not raise TypeError.

Field Mapping

openviper.admin.fields.FIELD_COMPONENT_MAP

dict[str, str] mapping OpenViper field class names to Vue component types (e.g. "CharField" -> "text", "ForeignKey" -> "foreignkey").

openviper.admin.fields.get_field_component_type(field) str

Return the Vue component type for a model field. Fields with choices always return "select".

openviper.admin.fields.get_filter_choices(field) list[dict[str, str]]

Return filter choices for a field, including lazy-loaded CountryField and CurrencyField data.

openviper.admin.fields.get_field_widget_config(field) dict

Return widget configuration for a field (required, readonly, choices, max_length, step, etc.).

openviper.admin.fields.get_field_schema(field) dict

Return the full schema dict for a field (type, component, config).

openviper.admin.fields.get_field_schema_cached(...) dict

LRU-cached version of field schema computation (up to 512 entries).

openviper.admin.fields.coerce_field_value(field, value) Any

Coerce a raw request value to the correct Python type for a given model field.

openviper.admin.fields.serialize_default(field) Any

Serialize a field’s default value for API responses.

Middleware

class openviper.admin.middleware.AdminMiddleware

ASGI middleware that enforces authentication on /admin/api/ routes. Non-HTTP requests and non-admin paths pass through unmodified.

ADMIN_PATH_PREFIX: str

Path prefix to protect (default: "/admin/api/").

EXEMPT_PATHS: list[str]

Paths that skip authentication:

  • /admin/api/auth/login/

  • /admin/api/auth/refresh/

  • /admin/api/auth/logout/

  • /admin/api/config/

The middleware normalizes request paths (decodes percent-encoding, collapses double slashes, rejects path-traversal segments) before prefix matching.

Permissions

openviper.admin.api.permissions.check_admin_access(request) bool

Return True if the user is authenticated and has staff or superuser status.

openviper.admin.api.permissions.check_model_permission(request, model_class, action) bool

Check if the user has permission for a model action ("view", "add", "change", "delete"). Superusers always pass. Staff users pass for basic CRUD. Falls back to user.has_perm() for granular checks.

openviper.admin.api.permissions.check_object_permission(request, obj, action) bool

Check if the user has permission for a specific object. Delegates to check_model_permission() for the object’s model class.

class openviper.admin.api.permissions.PermissionChecker(request)

Object-oriented interface for admin permission checks.

Serialization

openviper.admin.api.serializers.serialize_instance(instance, model_admin, include_fields=None) dict

Serialize a model instance to a dict. Sensitive fields are excluded.

openviper.admin.api.serializers.serialize_value(value) Any

Serialize a field value for JSON (handles datetime, UUID, Decimal, etc.).

openviper.admin.api.serializers.serialize_for_list(instance, model_admin) dict

Serialize a model instance for list view (only list_display fields).

openviper.admin.api.serializers.serialize_for_detail(instance, model_admin) dict

Serialize a model instance for detail view (all non-sensitive fields).

openviper.admin.api.views

The REST API module provides CRUD operations, search, filtering, batch actions, and CSV export for all admin-registered models. It is mounted automatically by get_admin_site() at /admin/api/.

Key functions include:

openviper.admin.api.views.sanitize_csv_cell(value) str

Sanitize a cell value for safe CSV export. Prevents CSV/formula injection by prefixing dangerous leading characters (=, +, -, @, tab, CR) with a single quote.

openviper.admin.types

Shared structural type aliases for the admin package.

JsonScalar

Type alias: str | int | float | bool | None.

JsonValue

Recursive type alias for valid JSON values.

JsonObject

Shorthand for dict[str, JsonValue].

Auto-Discovery

openviper.admin.discovery.autodiscover() None

Run admin auto-discovery. Imports admin.py from all installed apps and registers auth models. Called automatically by get_admin_site().

openviper.admin.discovery.discover_admin_modules() list[str]

Discover and import admin.py from all installed apps. Returns the list of app names where admin.py was found.

openviper.admin.discovery.import_admin_module(app_name) bool

Import the admin.py module from a single app. Returns True if the module was found and imported.

openviper.admin.discovery.discover_extensions() list[dict]

Discover admin_extensions/ directories from all installed apps and collect all .js files found there.

REST API Reference

All API endpoints are mounted under /admin/api/. Authentication endpoints are exempt from the admin middleware; all others require a staff or superuser JWT.

Auth endpoints:

Endpoint

Method

Description

/auth/login/

POST

Authenticate and receive JWT tokens. Rate-limited (5 req/min).

/auth/logout/

POST

Revoke access and refresh tokens.

/auth/refresh/

POST

Refresh an access token using a valid refresh token.

/auth/me/

GET

Get current authenticated user info.

/auth/change-password/

POST

Change the current user’s password. Rate-limited (5 req/min).

/auth/change-user-password/{user_id}/

POST

Change another user’s password (superuser only). Rate-limited (5 req/min).

Note

Both password-change endpoints rotate the session after a successful change. All other sessions for the affected user are invalidated, and a new session cookie is issued for the current request. This prevents session fixation and limits the window of opportunity for stolen session cookies.

Config and dashboard:

Endpoint

Method

Description

/config/

GET

UI configuration (title, user model, etc.).

/dashboard/

GET

Dashboard statistics and recent activity.

/extensions/

GET

JSON manifest of discovered admin extensions.

/plugins/

GET

List available admin plugins (placeholder).

Model CRUD (app-scoped routes):

Endpoint

Method

Description

/models/

GET

List all registered models with admin config.

/models/{app}/{model}/

GET

Get model configuration and metadata.

/models/{app}/{model}/list/

GET

List instances with pagination, search, filtering, sorting.

/models/{app}/{model}/

POST

Create a new instance (with inline children).

/models/{app}/{model}/filters/

GET

Get available filter options for a model.

/models/{app}/{model}/{id}/

GET

Get a single instance with model info and fieldsets.

/models/{app}/{model}/{id}/

PUT

Update an instance (with inline children and change logging).

/models/{app}/{model}/{id}/

DELETE

Delete an instance (with change logging).

/models/{app}/{model}/bulk-action/

POST

Execute a batch action on selected IDs. Rate-limited (10 req/min).

/models/{app}/{model}/export/

GET

Export instances to CSV (with injection sanitization).

/models/{app}/{model}/{id}/history/

GET

Get change history for an instance.

/models/{app}/{model}/fk-search/

GET

ForeignKey autocomplete search.

Model CRUD (legacy name-only routes):

These routes use {model_name} without {app_label} and resolve models by name alone. They support the same operations as the app-scoped routes above.

Endpoint

Method

Description

/models/{model}/

GET

List instances with pagination, search, filtering.

/models/{model}/

POST

Create a new instance.

/models/{model}/{id}/

GET

Get a single instance.

/models/{model}/{id}/

PATCH

Partially update an instance.

/models/{model}/{id}/

DELETE

Delete an instance.

/models/{model}/bulk-delete/

POST

Delete multiple instances by ID. Rate-limited (10 req/min).

/models/{model}/bulk-action/

POST

Execute a batch action. Rate-limited (10 req/min).

/models/{model}/search/

GET

Search instances (delegates to list).

/models/{model}/filters/

GET

Get filter options.

/models/{model}/export/

POST

Export instances to CSV.

/models/{model}/{id}/history/

GET

Get change history.

Global search:

Endpoint

Method

Description

/search/

GET

Search across all registered models. Rate-limited (30 req/min).

Query parameters for list endpoints:

Parameter

Description

page

Page number (default: 1).

per_page / page_size

Items per page (default: list_per_page, max: 1000).

q

Search query (searches search_fields).

sort

Sort field (prefix with - for descending).

filter_{field}

Filter by field value (one per field).

CSV export security:

CSV exports sanitize cell values to prevent formula injection in spreadsheet applications. Cells starting with =, +, -, @, tab, or carriage-return characters are prefixed with a single quote to force text-mode rendering.

Decorators

openviper.admin.decorators.register(*models) Callable

Decorator to register one or more models with the admin site. Each model is registered using the global admin registry singleton.

openviper.admin.unregister(model_class) None

Remove a model from the admin site registry.

Auth Model Auto-Registration

The openviper.admin.auth_admin module automatically registers the following models when autodiscover() runs:

  • User - with UserAdmin (excludes password, read-only dates)

  • Permission - with PermissionAdmin

  • Role - with RoleAdmin

  • UserRole - with UserRoleAdmin

  • RolePermission - with RolePermissionAdmin

  • ChangeHistory - with ChangeHistoryAdmin (read-only: no add/edit/delete)

All registrations use contextlib.suppress(AlreadyRegistered) so they do not raise errors if the models are already registered by user code.

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"]

@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"
    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):
        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):
        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:
            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"]
    sensitive_fields = [
        "password",
        "api_key",
        "refresh_token",
    ]
    readonly_fields = ["created_at", "last_login"]

Unregistering a Model

from openviper.admin import unregister
from myapp.models import LegacyModel

unregister(LegacyModel)

Admin Extensions

Apps can provide drop-in JavaScript extensions for the admin SPA by creating an admin_extensions/ directory inside the app package. Only .js and .vue files are served. Extension files are only available in DEBUG mode.

myapp/
    admin_extensions/
        dashboard_widget.js
        custom_chart.vue

The extension manifest is available at /admin/api/extensions/ and individual files at /admin/extensions/{app_name}/{path}.

Authentication Decorator

All admin API routes are protected by the @require_admin decorator, which verifies that the request user is an authenticated admin (staff or superuser) before the handler runs. Routes that do not require authentication (/auth/login, /auth/logout, /auth/refresh) intentionally omit the decorator.

openviper.admin.api.permissions.require_admin(func)

Decorator that enforces admin access on an async route handler.

Raises:

PermissionDenied: If the request user is not an authenticated admin.

from openviper.admin.api.permissions import require_admin

@router.get("/models/")
@require_admin
async def list_models(request: Request) -> JSONResponse:
    ...