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:
ModelAdmin - per-model display and behavior configuration.
AdminRegistry - central registry that maps model classes to their
ModelAdmininstances.Admin Site - the router factory (
get_admin_site()) that mounts the API and SPA at a given URL prefix.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
idplus the first four fields.
- list_display_links: list[str] | None
Fields that are rendered as links to the detail view. Defaults to the first field in
list_display.
- list_editable: list[str] | None
Fields that can be edited inline in the list view (without opening the detail form).
- ordering: str | list[str] | None
Default ordering for the list view. Prefix with
-for descending. Defaults to["-id"].
FK fields to eager-load in the list view via
select_related. Set toTrueto auto-detect from FK fields inlist_display.
Form view attributes:
- fields: list[str] | None
Explicit list of fields shown in the create/edit form. When
Noneall non-excluded, non-id fields are shown.
- fieldsets: list[tuple[str | None, dict]] | None
Grouped form layout: list of
(title, {"fields": [...], "classes": [...], "description": "..."})tuples. Settitle=Nonefor 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_selectedaction is always available.
Inlines:
- inlines: list[type[InlineModelAdmin]]
Inline model admin classes for nested editing of related objects.
- child_tables: list[type[ChildTable]]
Alias for
inlinesusing the tabular layout.
UI options:
Permission methods:
- has_delete_permission(request, obj=None) bool
All return
Truefor 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.
- is_intrinsically_readonly(field) bool
Return
Trueif 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 optionalrequest(and sometimesobj) 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.
- class openviper.admin.options.TabularInline(parent_model)
Subclass of
InlineModelAdminrendered as a horizontal table.
- class openviper.admin.options.StackedInline(parent_model)
Subclass of
InlineModelAdminrendered 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
ModelAdminwhen admin_class isNone. RaisesAlreadyRegisteredif the model is already registered.
- unregister(model_class)
Remove a model from the registry. Raises
NotRegisteredif the model is not registered.
- get_model_admin(model_class) ModelAdmin | None
Return the
ModelAdmininstance for model_class, orNoneif not registered.
- get_model_admin_by_name(model_name) ModelAdmin
Return the
ModelAdmininstance by model class name (case-insensitive). RaisesNotRegisteredif not found.
- get_model_admin_by_app_and_name(app_label, model_name) ModelAdmin
Return the
ModelAdmininstance by app label and model name. RaisesNotRegisteredif not found.
- get_model_by_name(model_name) type[Model]
Return the model class by name (case-insensitive). Raises
NotRegisteredif 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
NotRegisteredif 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.
- 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.
- execute(queryset, request, model_admin=None) Awaitable[ActionResult]
Execute the action on the queryset. Must be overridden.
- 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
Noneif 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
AdminActionfrom 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.
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)
- 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
choicesalways return"select".
- openviper.admin.fields.get_filter_choices(field) list[dict[str, str]]
Return filter choices for a field, including lazy-loaded
CountryFieldandCurrencyFielddata.
- 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.- 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
Trueif 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 touser.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_displayfields).
- 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.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.pyfrom all installed apps and registers auth models. Called automatically byget_admin_site().
- openviper.admin.discovery.discover_admin_modules() list[str]
Discover and import
admin.pyfrom all installed apps. Returns the list of app names whereadmin.pywas found.
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 |
|---|---|---|
|
POST |
Authenticate and receive JWT tokens. Rate-limited (5 req/min). |
|
POST |
Revoke access and refresh tokens. |
|
POST |
Refresh an access token using a valid refresh token. |
|
GET |
Get current authenticated user info. |
|
POST |
Change the current user’s password. Rate-limited (5 req/min). |
|
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 |
|---|---|---|
|
GET |
UI configuration (title, user model, etc.). |
|
GET |
Dashboard statistics and recent activity. |
|
GET |
JSON manifest of discovered admin extensions. |
|
GET |
List available admin plugins (placeholder). |
Model CRUD (app-scoped routes):
Endpoint |
Method |
Description |
|---|---|---|
|
GET |
List all registered models with admin config. |
|
GET |
Get model configuration and metadata. |
|
GET |
List instances with pagination, search, filtering, sorting. |
|
POST |
Create a new instance (with inline children). |
|
GET |
Get available filter options for a model. |
|
GET |
Get a single instance with model info and fieldsets. |
|
PUT |
Update an instance (with inline children and change logging). |
|
DELETE |
Delete an instance (with change logging). |
|
POST |
Execute a batch action on selected IDs. Rate-limited (10 req/min). |
|
GET |
Export instances to CSV (with injection sanitization). |
|
GET |
Get change history for an instance. |
|
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 |
|---|---|---|
|
GET |
List instances with pagination, search, filtering. |
|
POST |
Create a new instance. |
|
GET |
Get a single instance. |
|
PATCH |
Partially update an instance. |
|
DELETE |
Delete an instance. |
|
POST |
Delete multiple instances by ID. Rate-limited (10 req/min). |
|
POST |
Execute a batch action. Rate-limited (10 req/min). |
|
GET |
Search instances (delegates to list). |
|
GET |
Get filter options. |
|
POST |
Export instances to CSV. |
|
GET |
Get change history. |
Global search:
Endpoint |
Method |
Description |
|---|---|---|
|
GET |
Search across all registered models. Rate-limited (30 req/min). |
Query parameters for list endpoints:
Parameter |
Description |
|---|---|
|
Page number (default: 1). |
|
Items per page (default: |
|
Search query (searches |
|
Sort field (prefix with |
|
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
adminregistry singleton.
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
PermissionAdminRole - with
RoleAdminUserRole - with
UserRoleAdminRolePermission - with
RolePermissionAdminChangeHistory - 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:
examples/todoapp/ - simple admin with
@register,list_display,search_fieldsexamples/ai_moderation_platform/ - custom actions,
ChildTableinlines, multi-app adminexamples/ecommerce_clone/ -
unregister/ re-register pattern,UserRoleInline
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)
Global Search
The /admin/api/search/ endpoint searches across all registered
models concurrently. It uses each model’s search_fields (or falls
back to common field names like name, title, username,
email) and returns up to 5 results per model, capped at 50 total.
# Frontend usage
GET /admin/api/search/?q=john
# Response
{
"results": [
{"id": 1, "display": "John Doe", "model_name": "user", "app_label": "auth"},
{"id": 42, "display": "John's Post", "model_name": "post", "app_label": "blog"}
]
}
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:
...