Admin Widgets — Per-Page LiveView Slots & Bulk-Action Progress (v0.7.0)
djust.admin_ext ships a drop-in reactive replacement for Django's
stock admin (DjustAdminSite, DjustModelAdmin, plugin system,
dashboard widgets). v0.7.0 adds two building blocks that close the gap
with real-world admin requirements:
- Per-page widget slots on
DjustModelAdmin— embed any LiveView into a model's change-list or change-form page viachange_list_widgets/change_form_widgetsclass attributes. - Bulk-action progress — turn any ModelAdmin action into a
background job with a live progress page using
@admin_action_with_progress.
Why this design (and why we didn't ship DjustAdminMixin)
A lot of demos reach for "one mixin you sprinkle on admin.ModelAdmin
and your admin goes live." We prototyped that. In practice it
duplicates ~60% of DjustAdminSite, fights Django's change_view /
changelist_view MRO (20+ override points, complex
template_name resolution, ChangeList introspection), and diverges
from the recommended adoption path (ADR-007 Phase 4: djust[admin] =
DjustAdminSite).
Instead, v0.7.0:
- Extends the existing
DjustModelAdminwith thin per-page widget slots that reuse the already-shipped{% live_render %}machinery. - Adds
BulkActionProgressWidgetas a first-class LiveView that any ModelAdmin action can redirect to via a one-line decorator.
Result: an admin page that wants a live revenue chart is
class OrderAdmin(DjustModelAdmin):
change_list_widgets = [RevenueChartView]
...and the chart is a regular LiveView — same class you'd use on any page. No new framework surface, nothing novel to learn.
Per-page widget slots
Quick start — change_form_widgets
# myapp/djust_admin.py
from djust import LiveView
from djust.admin_ext import DjustModelAdmin, site
from djust.decorators import event_handler, state
from .models import Order
class OrderActivityWidget(LiveView):
"""Shows recent activity on the order currently being edited."""
template_name = "myapp/admin/order_activity.html"
label = "Recent activity"
size = "lg" # "sm" | "md" | "lg" — controls grid column span
events = state(default=[])
def mount(self, request, object_id=None, **kwargs):
self.request = request
self.object_id = object_id
self.events = list(
Order.objects.get(pk=object_id).activity.order_by("-ts")[:10]
)
def get_context_data(self, **kwargs):
return {"events": self.events}
@site.register(Order)
class OrderAdmin(DjustModelAdmin):
change_form_widgets = [OrderActivityWidget]
That's it. Open /admin/myapp/order/42/change/ and the widget renders
above the form, receives object_id=42, and behaves exactly like any
other LiveView.
change_list_widgets
Same deal, but on the list page. Widgets registered here receive no
object_id (since the page isn't scoped to a single model instance).
Typical uses:
- Live KPI tiles (total orders today, open support tickets)
- Filter-aware summaries (sum of currently-shown rows)
- Pinned announcements / banners
@site.register(Order)
class OrderAdmin(DjustModelAdmin):
change_list_widgets = [TodayRevenueWidget, OpenTicketsWidget]
Permissions
Any widget class can declare permission_required. Users without the
permission simply don't see the widget — no fallback placeholder is
rendered.
class InventoryAlertWidget(LiveView):
template_name = "myapp/admin/inventory_alert.html"
permission_required = "inventory.view_low_stock"
label = "Low stock alerts"
Bulk-action progress
Quick start — @admin_action_with_progress
# myapp/djust_admin.py
from djust.admin_ext import DjustModelAdmin, site
from djust.admin_ext.progress import admin_action_with_progress
from .models import Order
@site.register(Order)
class OrderAdmin(DjustModelAdmin):
actions = ["refund_selected"]
@admin_action_with_progress(description="Refund selected orders")
def refund_selected(self, request, queryset, progress):
total = queryset.count()
progress.update(current=0, total=total, message="Starting refunds…")
for i, order in enumerate(queryset.iterator(), start=1):
order.refund()
progress.update(
current=i,
total=total,
message=f"Refunded order #{order.pk}",
)
progress.update(message="All done.")
When a user runs this action:
- The decorator pins the selected rows to a list of primary keys (so lazy-queryset re-evaluation against the session can't affect the thread).
- Spawns a daemon thread that runs the function body.
- Returns an
HttpResponseRedirectto/admin/djust-progress/<job_id>/, which is served byBulkActionProgressWidget. - The progress page polls
progress.current / total / message / logevery 500 ms and re-renders the progress bar, status, and log. - If the user clicks Cancel, both
done=Trueandcancelled=Trueflip on the job, and the polling loop exits on the next tick.
Cancellation is cooperative
Important. Clicking Cancel on the progress page only flips
progress.cancelled = True. Python cannot safely interrupt a running thread mid-statement, so the action body must periodically checkprogress.cancelledand return early to actually stop. If your loop body never checks, the action runs to completion even though the user cancelled — and any destructive side-effects (row updates, API calls) still happen.The pattern:
@admin_action_with_progress(description="Sync with vendor") def sync_vendor(self, request, queryset, progress): total = queryset.count() for i, obj in enumerate(queryset): if progress.cancelled: progress.update(message="Cancelled by user.") return obj.sync_with_vendor() progress.update(current=i + 1, total=total, message=f"Synced {obj.pk}")For actions that can't be safely interrupted, skip the check and make it clear in the
description:"Finalize orders (cannot be cancelled)".
Permissions (permissions=[...])
@admin_action_with_progress(permissions=[...]) stamps an
allowed_permissions attribute on the wrapped action function.
DjustModelAdmin.run_action enforces this server-side — before
dispatching the action it calls request.user.has_perms(allowed) and
raises PermissionDenied if the user lacks any declared perm. This
closes the gap where Django's default has_*_permission methods
return True for any authenticated staff user, which would otherwise
let a view-only staff user fire a destructive action just because the
action dropdown rendered for them.
@admin_action_with_progress(
description="Refund selected orders",
permissions=["orders.refund_order", "orders.view_order"],
)
def refund_selected(self, request, queryset, progress):
...
Users without all of the listed perms see a 403 when they try to
run the action; the progress page is never created.
Known limitations
Single-worker only (v0.7.0). The process-local
_JOBSdict that backsBulkActionProgressWidgetis not shared across workers. If your deployment hasgunicorn --workers 4(or uvicorn with--workers > 1), the progress-page redirect may land on a different worker than the one running the background thread — producing a "Job not found or expired." error.Workarounds for v0.7.0:
- Run a single ASGI worker (
--workers 1) on the service handling admin traffic.- Enable sticky sessions (cookie-affinity) on your load balancer so the admin user stays on one worker.
- Don't use
@admin_action_with_progressfor workflows that need to survive worker crashes.v0.7.1 plans to back
_JOBSwith the project's channel layer (same broker asNotificationMixin.listen()), making multi-worker deploys work out of the box without changes to your action code. Adjust.A073system check fires at startup any time an admin site has a@admin_action_with_progress-decorated action, so this limitation is impossible to miss duringmanage.py check.
Other edge cases worth knowing:
- Single-worker
_JOBS+ LRU cap._JOBSis a process-local dict capped at_MAX_JOBS = 500entries (oldest entries evicted on insert once the cap is reached). Combined with the single-worker limitation above, this means you're always looking at the last 500 jobs on one worker — fine for typical admin bulk-action workloads, but don't treat_JOBSas a durable job store. - Per-message truncation. Both
Job.messageandJob.errorare individually truncated to_MAX_MESSAGE_CHARS = 4096characters on eachprogress.update(...)call — longer strings get"..."appended. Keeps the WebSocket payload and in-memory job size bounded no matter how noisy the action is. - Long-running actions keep running across user sessions — closing
the browser tab doesn't cancel the job, only
cancel()does. Jobs live 30 seconds after completion so late-arriving progress pages can still see the final state. - Log is capped at 50 lines. Call
progress.update(message=...)sparingly; the oldest entries are trimmed. - Exceptions are captured with full tracebacks logged at ERROR
level via
logger.exception(logger name:djust.admin_ext.progress);done=Truestill flips in thefinallyclause. The user-facingjob.erroris a generic message —"Action failed — see server logs for details"— and the raw exception text lives only on the server-side_error_rawattribute (server-only, never sent to the client). Don't leak exception messages to admin users. - Queryset is pinned to PKs before the thread starts. If you need the freshest data, re-fetch inside the thread: we already pin to the default manager's queryset but you can chain further filters.
Troubleshooting
djust.A072 — non-LiveView in widget slot
Admin djust_admin — change_form_widgets on myapp.order contains
non-LiveView class 'NotALiveView'. Widget slots can only embed djust
LiveView subclasses.
You registered a plain class (or a stock admin.ModelAdmin-style
widget) where a LiveView subclass was expected. The fix is to make
your widget inherit from djust.LiveView:
from djust import LiveView
class MyWidget(LiveView):
template_name = "..."
Widget slots expect LiveViews because they're rendered via
{% live_render %} — the same tag used for nested LiveViews
elsewhere in your app. If you want a static server-rendered card, use
the dashboard AdminWidget (from djust.admin_ext.plugins) instead —
those are plain Django template renders.
djust.A073 — multi-worker progress limitation
Admin djust_admin — uses @admin_action_with_progress with
DJUST_ASGI_WORKERS=4. The v0.7.0 BulkActionProgressWidget keeps job
state in a process-local dict (_JOBS); multi-worker deploys must pin
the progress URL to the worker that started the job (sticky sessions)
or run a single ASGI worker. v0.7.1 will back this with a channel
layer.
An informational notice — NOT an error. A073 is gated on the
DJUST_ASGI_WORKERS setting: it only fires when you set
DJUST_ASGI_WORKERS > 1 in your Django settings (so single-worker
development stays silent). Unset it or leave it at 1 to indicate
you're running a single worker.
# settings.py
DJUST_ASGI_WORKERS = 4 # This tells djust.A073 you're multi-worker.
See the "Known limitations" section above for the options. You can
also silence this check with DJUST_CONFIG = {"suppress_checks": ["A073"]} once you've picked a mitigation.
Defense-in-depth — DJUST_LIVE_RENDER_ALLOWED_MODULES
Because widget slots resolve dotted paths via {% live_render %}, you
can opt into an allowlist of acceptable module prefixes. Set
DJUST_LIVE_RENDER_ALLOWED_MODULES = ["myapp.widgets", "myorg.admin"]
in Django settings and any widget path that doesn't start with one of
those prefixes will raise TemplateSyntaxError at render time. When
unset (the default) all resolvable paths are permitted. This is not a
bug fix — just an extra layer for deployments that want to constrain
which modules the admin can reach through the live-render machinery.
Comparison with stock Django admin
| Capability | Stock admin.ModelAdmin | DjustModelAdmin (v0.7.0) |
|---|---|---|
| CRUD templates | full-page reload | real-time LiveView |
| Search / filter / sort | full-page reload | WebSocket round-trip |
| Add widgets to a change page | custom change_form_template + JS | change_form_widgets = [LiveView, …] |
| Add widgets to a list page | custom change_list_template + JS | change_list_widgets = [LiveView, …] |
| Bulk action with progress | none built-in | @admin_action_with_progress + live page |
| Permission-filter widgets | manual template conditional | permission_required on widget class |
| Multi-worker job routing | not applicable (no background jobs) | v0.7.0: single-worker; v0.7.1: channel layer |
v0.7.0 is intentionally a small addition to an already-shipped
admin. If DjustAdminSite doesn't fit your project, you can still
reach for a custom admin app — BulkActionProgressWidget is a plain
LiveView and admin_action_with_progress is a plain decorator; both
work outside DjustAdminSite if you route their URLs manually.