BETA

SaaS Admin Panel · Django · Real-time Audit Log · RBAC

Admin Panel

Every SaaS product needs one. A real-time admin panel — user management, live audit logs, KPI dashboards with caching, and role-based access control — all in Python, without a single JavaScript framework.

IsStaff/IsSuperuser WebSocket Audit Log CacheMixin DjangoModelPermission FormComponent Pure-Python Tests

The Scenario

A growing SaaS company needs an internal admin panel. The requirements are demanding: a searchable, paginated user table with instant status toggles — no full-page reload — a live audit log stream showing every action across the platform in real time, a KPI dashboard (monthly active users, revenue, churn rate) that caches expensive aggregations and does not hammer the database on every page load, and user-edit forms with permission gating so only superusers can assign roles.

The engineering team considered React + REST API + Redux. The state management alone would have required: an async thunk layer, separate reducers for users/audit/metrics, a WebSocket hook, a polling strategy for KPIs, and enough TypeScript plumbing to keep client and server state in sync across four separate feature slices.

The component-framework answer: four Python classes. State lives on the server. HTMX sends events, receives rendered HTML. The WebSocket channel pushes new audit entries directly into the browser. There is no client-side state machine to debug.

Architecture at a Glance

Browser (HTMX + WebSocket)POST /admin/components/users/ (search, toggle_status, change_page)POST /admin/components/metrics/ (refresh)POST /admin/components/user-edit/ (submit — superuser only)WS /ws/admin/audit/ (real-time audit log push)Django Admin Views (IsStaff / IsSuperuser permission checks)Component Framework Core (hydrate → handle_event → render) │ ├─ UserListComponent DjangoModelComponent — Django User model ├─ AuditLogComponent CacheMixin (30 s) + WebSocket subscription ├─ MetricsComponent CacheMixin (300 s) + on_refresh invalidation └─ UserEditComponent FormComponent + IsSuperuser + OptimisticMixinDjango ORM / Redis Cache (django-channels channel layer)

Key insight: every component is a plain Python class. The framework handles hydration, event dispatch, and re-render. Redis serves two roles — the Django Channels channel layer for WebSocket fan-out and the Django cache backend for CacheMixin. Neither role requires any JavaScript configuration.

UserListComponent

Renders a paginated, searchable table of Django User objects. Staff can search by username or email, toggle a user's active status with a single click, and navigate between pages — all without leaving the view or writing any JavaScript.

The Component

admin_panel/components.py Python
from component_framework.core import registry
from component_framework.adapters.django_model import DjangoModelComponent
from django.contrib.auth import get_user_model

User = get_user_model()

PAGE_SIZE = 25


@registry.register("user_list")
class UserListComponent(DjangoModelComponent):
    """
    Searchable, paginated user management table for staff.

    State shape:
        query      (str):  current search string
        page       (int):  current 1-based page number
        users      (list): serialised user rows for the current page
        total      (int):  total matching user count
        page_count (int):  total pages
    """

    template_name = "admin_panel/user_list.html"
    model = User
    state_fields = ["username", "email", "is_active", "date_joined"]

    def mount(self):
        self.state.update({
            "query":      "",
            "page":       1,
            "users":      [],
            "total":      0,
            "page_count": 1,
        })
        self._load_page()

    def _load_page(self):
        """Run the DB query for the current page and search query."""
        qs = User.objects.order_by("-date_joined")

        query = self.state.get("query", "").strip()
        if query:
            qs = qs.filter(
                username__icontains=query
            ) | qs.filter(email__icontains=query)

        total = qs.count()
        page  = self.state["page"]
        start = (page - 1) * PAGE_SIZE
        users = qs[start : start + PAGE_SIZE].values(
            "id", "username", "email",
            "is_active", "is_staff", "date_joined",
        )

        self.state.update({
            "users":      [dict(u) for u in users],
            "total":      total,
            "page_count": max(1, -(-total // PAGE_SIZE)),  # ceil div
        })

    def on_search(self, query: str):
        """User typed into the search box — reset to page 1 and reload."""
        self.state["query"] = query
        self.state["page"]  = 1
        self._load_page()

    def on_toggle_status(self, user_id: int):
        """Toggle a user's is_active flag and re-render the row."""
        user = User.objects.get(pk=user_id)
        user.is_active = not user.is_active
        user.save(update_fields=["is_active"])

        # Reflect the change in state without a full page reload
        for row in self.state["users"]:
            if row["id"] == user_id:
                row["is_active"] = user.is_active
                break

    def on_change_page(self, page: int):
        """Navigate to a different page."""
        self.state["page"] = max(1, min(page, self.state["page_count"]))
        self._load_page()
DjangoModelComponent binds the component to a Django model and handles JSON serialisation of state_fields automatically. You still write plain ORM queries — the framework never hides them.

The View

admin_panel/views.py Python
from component_framework.adapters.django_views import ComponentView
from component_framework.core.permissions import IsStaff


class UserListView(ComponentView):
    """
    Staff-only endpoint for the user management table.

    - Non-staff users receive JSON 403 — HTMX handles the UI gracefully.
    - No redirect that would break the HTMX partial-swap flow.
    """

    component_name = "user_list"
    permission_classes = [IsStaff]
admin_panel/urls.py Python
from django.urls import path
from .views import UserListView, MetricsView, UserEditView

urlpatterns = [
    path("components/users/",     UserListView.as_view(),   name="admin_users"),
    path("components/metrics/",   MetricsView.as_view(),  name="admin_metrics"),
    path("components/user-edit/", UserEditView.as_view(), name="admin_user_edit"),
]

The Template

admin_panel/templates/admin_panel/user_list.html Django Template
<div id="user-list-{{ component_id }}"
     hx-target="this"
     hx-swap="outerHTML">

  {# Search bar — triggers on_search after 400 ms debounce #}
  <input
    type="search"
    name="query"
    value="{{ state.query }}"
    placeholder="Search users…"
    hx-post="{% url 'admin_users' %}"
    hx-vals='{"event": "search"}'
    hx-trigger="input changed delay:400ms" />

  <p class="result-count">{{ state.total }} users</p>

  <table>
    <thead><tr>
      <th>Username</th>
      <th>Email</th>
      <th>Joined</th>
      <th>Status</th>
      <th>Actions</th>
    </tr></thead>
    <tbody>
    {% for user in state.users %}
      <tr>
        <td>{{ user.username }}</td>
        <td>{{ user.email }}</td>
        <td>{{ user.date_joined|date:"Y-m-d" }}</td>
        <td>
          <span class="badge {% if user.is_active %}badge--active{% else %}badge--inactive{% endif %}">
            {% if user.is_active %}Active{% else %}Inactive{% endif %}
          </span>
        </td>
        <td>
          {# Toggle button — instant feedback via HTMX #}
          <button
            hx-post="{% url 'admin_users' %}"
            hx-vals='{"event": "toggle_status", "user_id": "{{ user.id }}"}'>
            {% if user.is_active %}Deactivate{% else %}Activate{% endif %}
          </button>
        </td>
      </tr>
    {% endfor %}
    </tbody>
  </table>

  {# Pagination #}
  <div class="pagination">
    {% if state.page > 1 %}
      <button
        hx-post="{% url 'admin_users' %}"
        hx-vals='{"event": "change_page", "page": "{{ state.page|add:"-1" }}"}'>
        ← Prev
      </button>
    {% endif %}
    <span>Page {{ state.page }} of {{ state.page_count }}</span>
    {% if state.page < state.page_count %}
      <button
        hx-post="{% url 'admin_users' %}"
        hx-vals='{"event": "change_page", "page": "{{ state.page|add:"1" }}"}'>
        Next →
      </button>
    {% endif %}
  </div>

</div>

AuditLogComponent — Real-Time Feed

Every create, update, and delete across the platform is recorded in an AuditEntry model. The admin panel streams new entries in real time via a Django Channels WebSocket consumer — no polling, no periodic AJAX requests. The initial page load is served from cache to keep the DB query out of the critical path.

The Component

admin_panel/components.py (continued) Python
from component_framework.core import registry
from component_framework.core.component import Component
from component_framework.adapters.django_views import CacheMixin
from .models import AuditEntry

MAX_ENTRIES = 50  # keep the last 50 entries in state


@registry.register("audit_log")
class AuditLogComponent(CacheMixin, Component):
    """
    Real-time audit log feed.

    Initial load is cached for 30 seconds — subsequent pushes arrive via
    WebSocket and bypass the cache entirely.

    State shape:
        entries (list): [{id, actor, action, target, timestamp}]
    """

    template_name = "admin_panel/audit_log.html"
    cache_ttl     = 30  # seconds — short TTL; WS keeps it live after that

    def get_cache_key(self, name, params, state) -> str:
        return "admin:audit_log:initial"

    def mount(self):
        entries = (
            AuditEntry.objects
            .select_related("actor")
            .order_by("-timestamp")[:MAX_ENTRIES]
            .values("id", "actor__username", "action", "target", "timestamp")
        )
        self.state["entries"] = [dict(e) for e in entries]

    def on_new_entry(self, entry: dict):
        """
        Called by the WebSocket consumer when a new audit entry is saved.

        Prepend the new entry and trim the list to MAX_ENTRIES so the
        in-browser state never grows unbounded.
        """
        self.state["entries"] = [entry] + self.state["entries"][:MAX_ENTRIES - 1]

WebSocket Push

When Django saves an AuditEntry, a post-save signal serialises it and broadcasts the JSON payload to every connected admin via the "admin-audit" channel group. The consumer translates it into an on_new_entry component event and pushes the re-rendered feed back as HTML.

admin_panel/signals.py Python
from django.db.models.signals import post_save
from django.dispatch import receiver
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from .models import AuditEntry


@receiver(post_save, sender=AuditEntry)
def broadcast_audit_entry(sender, instance, created, **kwargs):
    """Broadcast every new audit entry to connected admin consoles."""
    if not created:
        return

    layer = get_channel_layer()
    async_to_sync(layer.group_send)(
        "admin-audit",
        {
            "type":  "audit.entry",
            "entry": {
                "id":             instance.pk,
                "actor__username": instance.actor.username,
                "action":         instance.action,
                "target":         instance.target,
                "timestamp":      instance.timestamp.isoformat(),
            },
        },
    )
admin_panel/consumers.py (Django Channels) Python
from component_framework.core import ws_manager
import json


class AuditLogConsumer(ws_manager.ComponentConsumer):
    """
    WebSocket consumer that keeps the audit log widget live.

    The parent class handles:
      - authenticating the connection (scope["user"].is_staff)
      - dispatching component events from incoming WS messages
      - pushing rendered HTML back to the browser
    """

    component_name = "audit_log"

    async def websocket_connect(self, message):
        user = self.scope["user"]
        if not (user.is_authenticated and user.is_staff):
            await self.close()
            return
        await self.channel_layer.group_add("admin-audit", self.channel_name)
        await super().websocket_connect(message)

    async def audit_entry(self, event):
        """Receive a broadcast from the signal and dispatch as a component event."""
        await self.dispatch_event(
            event="new_entry",
            payload={"entry": event["entry"]},
        )

    async def websocket_disconnect(self, message):
        await self.channel_layer.group_discard("admin-audit", self.channel_name)
        await super().websocket_disconnect(message)
What the browser sees: the audit log widget is rendered server-side on page load (from cache). As new actions occur anywhere in the platform, the WebSocket consumer calls dispatch_event("new_entry", ...), the component prepends the row, and the consumer pushes a fresh HTML fragment to the browser. No polling. No stale data.

MetricsComponent — KPI Dashboard

Three KPI cards — Monthly Active Users, Monthly Recurring Revenue, and Churn Rate — backed by expensive ORM aggregations. A five-minute cache means the database query only runs on a cache miss. A manual refresh button lets superusers bust the cache when they need fresh numbers.

The Component

admin_panel/components.py (continued) Python
from django.utils import timezone
from django.db.models import Sum, Count
from .models import Subscription, UserActivity


@registry.register("metrics")
class MetricsComponent(CacheMixin, Component):
    """
    KPI dashboard: MAU, MRR, and churn rate.

    Expensive aggregations are cached for 5 minutes.  The on_refresh event
    calls self.invalidate_cache() so staff can force fresh numbers without
    waiting for the TTL to expire.

    State shape:
        mau        (int):   monthly active users
        mrr        (str):   monthly recurring revenue (Decimal as string)
        churn_rate (float): percentage of users who cancelled last 30 days
        refreshed_at (str): ISO timestamp of last DB read
    """

    template_name = "admin_panel/metrics.html"
    cache_ttl     = 300  # 5 minutes

    def get_cache_key(self, name, params, state) -> str:
        return "admin:metrics"

    def mount(self):
        self._compute_metrics()

    def _compute_metrics(self):
        """Run all three ORM aggregations and update state."""
        now   = timezone.now()
        month = now - timezone.timedelta(days=30)

        # Monthly Active Users
        mau = (
            UserActivity.objects
            .filter(last_seen__gte=month)
            .values("user_id")
            .distinct()
            .count()
        )

        # Monthly Recurring Revenue
        mrr = (
            Subscription.objects
            .filter(status="active")
            .aggregate(total=Sum("monthly_amount"))["total"]
            or 0
        )

        # Churn Rate (cancellations last 30 days / total subs at month start)
        total_start = Subscription.objects.filter(created__lt=month).count()
        churned     = Subscription.objects.filter(
            cancelled_at__gte=month, cancelled_at__lte=now
        ).count()
        churn_rate = round((churned / total_start * 100) if total_start else 0.0, 2)

        self.state.update({
            "mau":          mau,
            "mrr":          str(mrr),
            "churn_rate":   churn_rate,
            "refreshed_at": now.isoformat(),
        })

    def on_refresh(self):
        """Staff clicked the Refresh button — bust the cache and recompute."""
        self.invalidate_cache()
        self._compute_metrics()

How the Cache Works

On the first request, CacheMixin checks the Django cache backend for the key "admin:metrics". On a miss it calls mount(), stores the resulting state in cache, and renders. On a cache hit it skips mount() entirely and renders directly from the cached state — meaning zero ORM queries for 99% of page loads.

Event requests (i.e. POST with an event field) always bypass the cache and execute normally. That is why on_refresh can call self.invalidate_cache() followed by _compute_metrics() — the cache miss on the next non-event load will pick up fresh data.

Cache backend required. CacheMixin uses django.core.cache.cache. Configure CACHES in settings.py to use Redis or Memcached in production. The default in-memory cache is process-local and will not work correctly with multiple Gunicorn workers.

UserEditComponent — Inline Form

When a staff member clicks "Edit" on a user row, an inline form appears in the table — pre-populated from server state. Only superusers may change a user's is_staff or is_superuser role flags. Submitting the form validates via a Pydantic schema, saves to the database, and shows instant feedback via OptimisticMixin.

The Component

admin_panel/components.py (continued) Python
from pydantic import BaseModel, EmailStr, field_validator
from component_framework.core.form import FormComponent
from component_framework.adapters.django_views import OptimisticMixin


class UserEditSchema(BaseModel):
    """Pydantic schema for the user-edit form."""
    username:      str
    email:         EmailStr
    first_name:    str
    last_name:     str
    is_active:     bool
    # Role flags — only serialised when the requesting user is a superuser.
    # The component strips them for non-superusers before validation.
    is_staff:      bool = False
    is_superuser:  bool = False

    @field_validator("username")
    @classmethod
    def username_not_empty(cls, v: str) -> str:
        if not v.strip():
            raise ValueError("Username cannot be blank.")
        return v.strip()


@registry.register("user_edit")
class UserEditComponent(OptimisticMixin, FormComponent):
    """
    Inline edit form for a single Django User.

    permission_classes = [IsSuperuser] ensures that:
      - Staff users can submit the form but role fields are stripped.
      - Non-staff users receive JSON 403 immediately.

    Optimistic UI shows a "Saving…" state immediately on submit.
    """

    template_name    = "admin_panel/user_edit.html"
    schema           = UserEditSchema
    # Note: permission is enforced at the VIEW level (IsSuperuser)
    # so only superusers ever reach on_submit with role fields intact.

    def mount(self):
        user_id = self.params["user_id"]
        user    = User.objects.get(pk=user_id)
        self.state.update({
            "user_id":     user.pk,
            "username":    user.username,
            "email":       user.email,
            "first_name":  user.first_name,
            "last_name":   user.last_name,
            "is_active":   user.is_active,
            "is_staff":    user.is_staff,
            "is_superuser": user.is_superuser,
            "saved":       False,
            "error":       None,
        })

    def on_submit(self):
        """
        Validate with Pydantic, then save.

        self.validated_data is populated by FormComponent after schema
        validation.  If validation fails, self.errors is populated and
        on_submit is not called.
        """
        data = self.validated_data
        user = User.objects.get(pk=self.state["user_id"])

        user.username    = data.username
        user.email       = data.email
        user.first_name  = data.first_name
        user.last_name   = data.last_name
        user.is_active   = data.is_active
        user.is_staff    = data.is_staff
        user.is_superuser = data.is_superuser
        user.save()

        self.state["saved"] = True

    # ── Optimistic UI ────────────────────────────────────────────────────
    def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
        """Show a 'Saving…' indicator immediately on submit."""
        if event == "submit":
            return {"saving": True, "error": None}
        return None

Permission Gating

Role changes are dangerous. The UserEditView uses IsSuperuser so that staff cannot promote themselves. A non-superuser who somehow POSTs to this endpoint receives {"error": "Superuser access required"} with HTTP 403 — no redirect, no template rendered, no state mutation.

admin_panel/views.py (continued) Python
from component_framework.core.permissions import IsSuperuser


class UserEditView(ComponentView):
    """
    Superuser-only endpoint for the inline user-edit form.

    IsStaff users can VIEW the admin panel but cannot POST to this endpoint.
    Any attempt returns JSON 403 — HTMX shows the error inline without
    a page reload.
    """

    component_name    = "user_edit"
    permission_classes = [IsSuperuser]

    def get_component_params(self, request, **kwargs):
        params = super().get_component_params(request, **kwargs)
        params["user_id"] = int(request.POST.get("user_id", 0))
        return params
Two permission levels, one admin panel. Staff (IsStaff) can search users, view metrics, and read the audit log. Only superusers (IsSuperuser) can submit the edit form. The same permission_classes attribute works on FBV decorators, CBV mixins, and component classes — no duplication.

React vs Component Framework

Here is the same real-time user management table in a React stack compared to component-framework. Three separate React concerns (state, effects, WebSocket hook), a Redux slice for the user list, manual CSRF handling, and TypeScript types — versus one Python class with four methods.

// REACT + REDUX
usersSlice.ts TypeScript
// 1. Redux slice
const usersSlice = createSlice({
  name: 'users',
  initialState: {
    rows: [], total: 0, page: 1,
    query: '', status: 'idle',
  },
  reducers: {
    setQuery: (state, action) => {
      state.query  = action.payload;
      state.page   = 1;
    },
    setPage:  (state, action) => {
      state.page = action.payload;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, s => {
        s.status = 'loading';
      })
      .addCase(fetchUsers.fulfilled, (s, a) => {
        s.rows   = a.payload.rows;
        s.total  = a.payload.total;
        s.status = 'idle';
      });
  },
});

// 2. async thunk
const fetchUsers = createAsyncThunk(
  'users/fetch',
  async ({ query, page }) => {
    const r = await fetch(
      `/api/users/?q=${query}&page=${page}`
    );
    return r.json();
  }
);

// 3. component with effects
function UserTable() {
  const { rows, query, page } =
    useSelector(s => s.users);
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(fetchUsers({ query, page }));
  }, [query, page]);

  /* + toggle handler, CSRF token,
       TypeScript interfaces, store     */
}
// COMPONENT FRAMEWORK
admin_panel/components.py Python
@registry.register("user_list")
class UserListComponent(
        DjangoModelComponent):

    model         = User
    template_name = "admin_panel/user_list.html"

    def mount(self):
        self.state["query"] = ""
        self.state["page"]  = 1
        self._load_page()

    def on_search(self, query: str):
        self.state["query"] = query
        self.state["page"]  = 1
        self._load_page()

    def on_toggle_status(self,
                          user_id: int):
        user = User.objects.get(pk=user_id)
        user.is_active = not user.is_active
        user.save(update_fields=["is_active"])
        self._load_page()

    def on_change_page(self, page: int):
        self.state["page"] = page
        self._load_page()

# No store. No thunks. No types.
# pytest covers the rest.
Concern React + Redux Component Framework
State location useState / Redux store in browser self.state dict on the server
Search debounce Custom hook + useEffect + setTimeout HTMX hx-trigger="input delay:400ms"
Pagination Page state in Redux + re-fetch thunk on_change_page() + _load_page()
Real-time feed Custom WebSocket hook + dispatch to slice ComponentConsumer + on_new_entry()
Permission gating Middleware check + useSession() hook permission_classes = [IsStaff]
Role gating Custom route guard + API-level guard permission_classes = [IsSuperuser]
KPI caching useMemo + server cache + stale-while-revalidate CacheMixin + invalidate_cache()
Optimistic UI useOptimistic() + rollback logic get_optimistic_patch() → auto rollback
Build step webpack / Vite required None
JS payload 150–400 KB gzipped ~5 KB (htmx + component-client.js)
Test tooling Jest + RTL + MSW for API mocks pytest — pure Python, no browser

Testing

Because all four components are pure Python, the full admin panel behaviour — searching, pagination, status toggling, cache hits, cache invalidation, permission checks — is testable without Django test client, without HTMX, and without a running server. ComponentTestCase mounts components in a controlled context.

admin_panel/tests/test_user_list.py Python
from django.test import TestCase
from django.contrib.auth import get_user_model
from component_framework.testing import ComponentTestCase, MockRenderer
from admin_panel.components import UserListComponent

User = get_user_model()


class TestUserListComponent(ComponentTestCase, TestCase):

    def setUp(self):
        UserListComponent.renderer = MockRenderer()

        # Seed the database with test users
        User.objects.create_user(
            username="alice", email="alice@example.com", is_active=True
        )
        User.objects.create_user(
            username="bob",   email="bob@example.com",   is_active=True
        )
        User.objects.create_user(
            username="carol", email="carol@example.com", is_active=False
        )

    def test_mount_loads_all_users(self):
        component = self.make_component(UserListComponent)
        component.mount()

        self.assertEqual(component.state["total"], 3)
        self.assertEqual(len(component.state["users"]), 3)

    def test_search_filters_results(self):
        component = self.make_component(UserListComponent)
        component.mount()
        component.on_search("alice")

        self.assertEqual(component.state["total"], 1)
        self.assertEqual(component.state["users"][0]["username"], "alice")

    def test_search_resets_to_page_one(self):
        component = self.make_component(UserListComponent)
        component.mount()
        component.state["page"] = 3
        component.on_search("bob")

        self.assertEqual(component.state["page"], 1)

    def test_toggle_status_updates_state(self):
        component = self.make_component(UserListComponent)
        component.mount()

        alice = User.objects.get(username="alice")
        self.assertTrue(alice.is_active)

        component.on_toggle_status(alice.pk)

        alice.refresh_from_db()
        self.assertFalse(alice.is_active)

    def test_toggle_inactive_user_activates(self):
        component = self.make_component(UserListComponent)
        component.mount()

        carol = User.objects.get(username="carol")
        self.assertFalse(carol.is_active)

        component.on_toggle_status(carol.pk)

        carol.refresh_from_db()
        self.assertTrue(carol.is_active)
admin_panel/tests/test_metrics.py Python
from unittest.mock import patch, MagicMock
from django.test import TestCase
from component_framework.testing import ComponentTestCase, MockRenderer
from admin_panel.components import MetricsComponent


class TestMetricsComponent(ComponentTestCase, TestCase):

    def setUp(self):
        MetricsComponent.renderer = MockRenderer()

    def test_mount_populates_kpis(self):
        with patch.object(
            MetricsComponent, "_compute_metrics",
            lambda self: self.state.update({
                "mau": 1234, "mrr": "9800.00", "churn_rate": 2.1,
            }),
        ):
            component = self.make_component(MetricsComponent)
            component.mount()

        self.assertEqual(component.state["mau"], 1234)
        self.assertEqual(component.state["mrr"], "9800.00")
        self.assertEqual(component.state["churn_rate"], 2.1)

    def test_cache_hit_skips_db(self):
        """
        When the cache returns a state dict, mount() should not be called.
        Verify by checking that _compute_metrics is never invoked.
        """
        cached_state = {
            "mau": 999, "mrr": "5000.00",
            "churn_rate": 1.5, "refreshed_at": "2025-01-01T00:00:00",
        }
        with patch(
            "django.core.cache.cache.get",
            return_value=cached_state,
        ) as mock_cache, \
        patch.object(
            MetricsComponent, "_compute_metrics"
        ) as mock_compute:
            component = self.make_component(MetricsComponent)
            component.mount()

        mock_compute.assert_not_called()
        self.assertEqual(component.state["mau"], 999)

    def test_refresh_invalidates_cache(self):
        """on_refresh() should bust the cache and recompute."""
        with patch.object(
            MetricsComponent, "invalidate_cache"
        ) as mock_inv, \
        patch.object(
            MetricsComponent, "_compute_metrics"
        ) as mock_compute:
            component = self.make_component(MetricsComponent)
            component.on_refresh()

        mock_inv.assert_called_once()
        mock_compute.assert_called_once()
shell bash
pytest admin_panel/tests/ -v
No browser, no HTTP, no HTMX. Every component test calls Python methods directly. MockRenderer accepts any state and returns an empty string — the test cares about state mutations, not HTML output. Render correctness is covered separately by Django template tests.

Running the Example

  1. Clone and install with the Django extra
    shellbash
    git clone https://github.com/fsecada01/component-framework
    cd component-framework
    uv sync --extra django
  2. Apply migrations
    shellbash
    python manage.py migrate
  3. Create a superuser account
    shellbash
    python manage.py createsuperuser
  4. Start Redis (required for WebSocket channel layer and cache backend)
    shellbash
    docker run -p 6379:6379 redis:7-alpine
  5. Run the development server
    shellbash
    python manage.py runserver
    # → http://localhost:8000/admin-panel/
Open http://localhost:8000/admin-panel/ and log in as your superuser. Search the user table, toggle a user's status, and watch the audit log update in the bottom panel in real time via WebSocket — no page reload. Click "Refresh" on the KPI cards to bust the cache and pull fresh aggregations. Open a second browser tab acting as a different admin and see the audit log sync across both.

Summary

Feature Component Framework Primitive
Real-time feed AuditLogComponent ComponentConsumer + on_new_entry() WebSocket dispatch
User management UserListComponent DjangoModelComponenton_search, on_toggle_status, on_change_page
KPI caching MetricsComponent CacheMixin (5 min TTL) + invalidate_cache() on refresh
Permission gating UserListView / UserEditView IsStaff / IsSuperuser — JSON 403, no redirect
Form validation UserEditComponent FormComponent + Pydantic UserEditSchema
Optimistic UI UserEditComponent get_optimistic_patch() → "Saving…" badge, auto rollback on error
Testing All components Pure Python — pytest, MockRenderer, ComponentTestCase