BETA

Helpdesk · Django · Real-time Queue · State Machine

Customer Support

A real-time helpdesk where tickets flow through a server-owned state machine, agents see live queue updates the instant a ticket is created, and submission is rate-limited — entirely in Python.

SlotComponent WebSocket Queue OptimisticMixin RateLimitMixin State Machine Pure-Python Tests

The Scenario

A SaaS company's support team handles hundreds of tickets a day. They need a real-time ticket queue where new tickets appear the moment a customer submits them — no polling, no page refresh. Each ticket must flow through a well-defined state machine: openassignedpending_customerresolved, with invalid transitions rejected server-side. Ticket submission must be rate-limited (5 per hour per user) to prevent spam and accidental duplicate submissions. The agent layout uses a slot-based composition — a persistent sidebar showing agent availability alongside the main ticket panel.

The standard answer involves React, a socket.io server, a REST API for ticket CRUD, a separate state-machine library, middleware for rate limiting, a context provider for agent state, and Storybook for component testing. The team considered this path carefully.

They chose component-framework instead. The state machine lives in an 8-line Python method. Rate limiting is a one-line mixin. Real-time queue updates are a WebSocket broadcast from the submit handler. Tests are plain pytest — no browser, no mocking of React internals.

Architecture at a Glance

Browser (HTMX + component-client.js)POST /support/components/queue/ (event, state, payload)POST /support/components/ticket/<id>/POST /support/components/reply/<id>/WS /ws/support/queue/ (new_ticket push)Django Views (IsAuthenticated · RateLimitMixin) │ permission check → rate-limit check → dispatch ▼ TicketQueueComponent live queue, filter, SLA highlighting TicketDetailComponent state machine, OptimisticMixin AgentDashboardComponent CompositeComponent — sidebar + main QuickReplyComponent FormComponent + RateLimitMixin (5/hour) │ ▼ Django ORM / Redis Channels (WebSocket layer)

Key insight: ticket status, assignment, and SLA breach data are all stored in self.state on the server. The browser never owns a copy of the state machine — it simply sends event names and receives fresh HTML fragments. Invalid transitions are caught in the handler before any database write occurs.

TicketQueueComponent — Live Queue

Renders a paginated, filterable list of open tickets. New tickets are pushed via WebSocket and prepended to the list instantly. Tickets that have exceeded their SLA deadline are flagged with a computed sla_breached field in state — no client-side logic required.

The Component

support/components.py Python
from datetime import timedelta
from django.utils import timezone
from component_framework.core import Component, registry
from .models import Ticket

@registry.register("ticket_queue")
class TicketQueueComponent(Component):
    """
    Live ticket queue for support agents.

    State shape:
        tickets:       list[TicketSummary]   paginated, newest-first
        status_filter: str                   "open" | "assigned" | "all"
        page:          int
        has_more:      bool
    """

    template_name = "support/ticket_queue.html"
    PAGE_SIZE = 20
    SLA_HOURS = 8

    def mount(self):
        self.state.update({
            "tickets":       [],
            "status_filter": "open",
            "page":          1,
            "has_more":      False,
        })
        self._load_tickets()

    def _load_tickets(self):
        sla_deadline = timezone.now() - timedelta(hours=self.SLA_HOURS)
        qs = (
            Ticket.objects
            .select_related("assignee", "customer")
            .order_by("-created_at")
        )
        status = self.state["status_filter"]
        if status != "all":
            qs = qs.filter(status=status)

        page = self.state["page"]
        offset = (page - 1) * self.PAGE_SIZE
        batch = qs[offset : offset + self.PAGE_SIZE + 1]
        has_more = len(batch) > self.PAGE_SIZE

        self.state["tickets"] = [
            {
                "id":           t.pk,
                "subject":      t.subject,
                "customer":     t.customer.get_full_name(),
                "status":       t.status,
                "assignee":     t.assignee.get_full_name() if t.assignee else None,
                "created_at":   t.created_at.isoformat(),
                "sla_breached": t.created_at < sla_deadline and t.status != "resolved",
            }
            for t in batch[: self.PAGE_SIZE]
        ]
        self.state["has_more"] = has_more

    def on_filter_status(self, status: str):
        """Switch the status filter and reset to page 1."""
        allowed = {"open", "assigned", "pending_customer", "resolved", "all"}
        if status not in allowed:
            self.errors["filter"] = f"Unknown status: {status}"
            return
        self.state["status_filter"] = status
        self.state["page"] = 1
        self._load_tickets()

    def on_assign_to_me(self, ticket_id: int):
        """Claim an unassigned ticket directly from the queue."""
        agent = self.params["user"]
        ticket = Ticket.objects.get(pk=ticket_id, status="open")
        ticket.assignee = agent
        ticket.status = "assigned"
        ticket.save(update_fields=["assignee", "status"])
        # Reflect immediately in local state without re-querying
        for t in self.state["tickets"]:
            if t["id"] == ticket_id:
                t["status"]   = "assigned"
                t["assignee"] = agent.get_full_name()
                break

    def on_load_more(self):
        """Paginate — append next page of tickets to existing list."""
        if not self.state["has_more"]:
            return
        self.state["page"] += 1
        self._load_tickets()

WebSocket Push — New Ticket Arrives

When a customer submits a ticket, QuickReplyComponent (or the public submission form) broadcasts a new_ticket event to the agents' shared channel. The queue consumer receives it and prepends the ticket to state["tickets"] without touching the database again.

support/consumers.py (Django Channels) Python
from component_framework.core import ws_manager
from django.utils import timezone


class QueueConsumer(ws_manager.ComponentConsumer):
    """
    WebSocket consumer: subscribes each authenticated agent to the
    shared 'support-queue' group. Incoming 'new_ticket' messages
    prepend the ticket summary to local state and re-render.
    """
    component_name = "ticket_queue"

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

    async def handle_new_ticket(self, ticket_data: dict):
        # Prepend to state and push updated HTML to this agent's browser
        self.component.state["tickets"].insert(0, ticket_data)
        html = await self.render_component()
        await self.send_html(html)
SLA highlighting requires zero client-side code. The sla_breached boolean is computed in _load_tickets() each time the queue renders. The template simply adds a CSS class when the flag is true. The browser never does date arithmetic.

TicketDetailComponent — State Machine

Wraps a single Ticket model. Transitions between statuses are validated by a guard dict — invalid transitions raise ComponentError before any database write. OptimisticMixin means clicking "Resolve" immediately updates the badge in the browser while the server confirms.

The Component

support/components.py (continued) Python
from pydantic import BaseModel, field_validator
from component_framework.core import ComponentError
from component_framework.adapters.django_model import DjangoModelComponent
from component_framework.core.composition import OptimisticMixin


class TicketState(BaseModel):
    """Validated state shape for TicketDetailComponent."""
    ticket_id:   int
    subject:     str
    body:        str
    status:      str
    assignee_id: int | None
    customer:    str
    created_at:  str

    @field_validator("status")
    def status_must_be_valid(cls, v):
        allowed = {"open", "assigned", "pending_customer", "resolved"}
        if v not in allowed:
            raise ValueError(ff"Invalid status: {v}")
        return v


# State machine transition guard
ALLOWED_TRANSITIONS: dict[str, set[str]] = {
    "open":             {"assigned"},
    "assigned":        {"pending_customer", "resolved"},
    "pending_customer": {"assigned", "resolved"},
    "resolved":        set(),
}


@registry.register("ticket_detail")
class TicketDetailComponent(OptimisticMixin, DjangoModelComponent):
    """
    Single-ticket view with server-side state machine transitions.

    params:
        ticket_id (int): PK of the ticket to render.
        user      (User): Injected by view for permission checks.
    """

    model = Ticket
    state_fields = ["subject", "body", "status", "assignee_id"]
    template_name = "support/ticket_detail.html"

    def mount(self):
        ticket_id = self.params["ticket_id"]
        t = Ticket.objects.select_related("customer", "assignee").get(pk=ticket_id)
        self.state.update({
            "ticket_id":   t.pk,
            "subject":     t.subject,
            "body":        t.body,
            "status":      t.status,
            "assignee_id": t.assignee_id,
            "customer":    t.customer.get_full_name(),
            "created_at":  t.created_at.isoformat(),
        })

    def on_transition(self, to_status: str):
        """
        Advance the ticket through the state machine.
        Raises ComponentError for invalid transitions — no DB write occurs.
        """
        current = self.state["status"]
        if to_status not in ALLOWED_TRANSITIONS.get(current, set()):
            raise ComponentError(
                ff"Transition '{current}' → '{to_status}' is not permitted."
            )
        ticket = Ticket.objects.get(pk=self.state["ticket_id"])
        ticket.status = to_status
        if to_status == "assigned" and not ticket.assignee_id:
            ticket.assignee = self.params["user"]
            self.state["assignee_id"] = self.params["user"].pk
        ticket.save(update_fields=["status", "assignee"])
        self.state["status"] = to_status

    # ── Optimistic UI ────────────────────────────────────────────────────
    def get_optimistic_patch(self, event: str, payload: dict) -> dict | None:
        """
        Flash the new status badge immediately. The server validates the
        transition and rolls back if the guard rejects it.
        """
        if event == "transition":
            return {"status": payload.get("to_status")}
        return None
Why raise ComponentError instead of setting self.errors? A transition guard failure is a programming or security error — the UI should never offer a button for an illegal transition. ComponentError returns HTTP 400 with a JSON body so HTMX can show a toast, and no state is mutated.

The View

support/views.py Python
from component_framework.adapters.django_views import AuthenticatedComponentView
from component_framework.core.permissions import IsStaff


class TicketDetailView(AuthenticatedComponentView):
    """
    Only staff agents may manage tickets.
    Customers access a read-only, separately-permissioned view.
    """
    permission_classes = [IsStaff]

    def get_component_params(self, request, **kwargs):
        params = super().get_component_params(request, **kwargs)
        params["ticket_id"] = int(kwargs["ticket_id"])
        return params
support/urls.py Python
from django.urls import path
from .views import TicketDetailView, TicketQueueView, QuickReplyView

urlpatterns = [
    path("components/queue/",              TicketQueueView.as_view(),  name="support_queue"),
    path("components/ticket/<int:ticket_id>/", TicketDetailView.as_view(), name="support_ticket"),
    path("components/reply/<int:ticket_id>/",  QuickReplyView.as_view(),  name="support_reply"),
]

The Template

support/templates/support/ticket_detail.html Django Template
{# Outer element is the HTMX swap target #}
<div id="ticket-{{ component_id }}"
     hx-target="this"
     hx-swap="outerHTML">

  <header class="ticket-header">
    <h2>{{ state.subject }}</h2>
    <span class="status-badge status--{{ state.status }}">
      {{ state.status }}
    </span>
  </header>

  <p class="ticket-meta">
    From {{ state.customer }} · opened {{ state.created_at }}
  </p>

  <div class="ticket-body">{{ state.body }}</div>

  {# Transition buttons — only rendered for valid next states #}
  <div class="ticket-actions">
    {% if state.status == "open" %}
      <button
        hx-post="{% url 'support_ticket' state.ticket_id %}"
        hx-vals='{"event": "transition", "to_status": "assigned"}'>
        Assign to Me
      </button>
    {% endif %}
    {% if state.status == "assigned" %}
      <button
        hx-post="{% url 'support_ticket' state.ticket_id %}"
        hx-vals='{"event": "transition", "to_status": "pending_customer"}'>
        Awaiting Customer
      </button>
      <button
        hx-post="{% url 'support_ticket' state.ticket_id %}"
        hx-vals='{"event": "transition", "to_status": "resolved"}'
        class="btn-resolve">
        Resolve Ticket
      </button>
    {% endif %}
    {% if state.status == "pending_customer" %}
      <button
        hx-post="{% url 'support_ticket' state.ticket_id %}"
        hx-vals='{"event": "transition", "to_status": "resolved"}'
        class="btn-resolve">
        Resolve Ticket
      </button>
    {% endif %}
    {% if state.status == "resolved" %}
      <p class="resolved-notice">This ticket is resolved.</p>
    {% endif %}
  </div>

</div>

AgentDashboardComponent — Availability

The dashboard is a CompositeComponent that composes two sub-components into a two-panel layout: AgentStatusComponent fills the "sidebar" slot (showing each agent's current availability), and TicketQueueComponent fills the "main" slot. When an agent changes their status, a WebSocket broadcast updates the sidebar for every other agent simultaneously.

The Component

support/components.py (continued) Python
from component_framework.core.composition import CompositeComponent, SlotComponent


@registry.register("agent_status")
class AgentStatusComponent(SlotComponent):
    """
    Sidebar listing all online agents and their current availability.

    State shape:
        agents: list[{id, name, status, avatar_url}]
        my_status: str   "available" | "busy" | "away"
    """
    template_name = "support/agent_status.html"

    def mount(self):
        from django.contrib.auth import get_user_model
        User = get_user_model()
        agents = User.objects.filter(is_staff=True, is_active=True).select_related("profile")
        me = self.params["user"]
        self.state.update({
            "agents": [
                {
                    "id":         a.pk,
                    "name":       a.get_full_name(),
                    "status":     a.profile.support_status,
                    "avatar_url": a.profile.avatar_url,
                }
                for a in agents
            ],
            "my_status": me.profile.support_status,
        })

    def on_set_status(self, status: str):
        """Agent changes their own availability. Broadcast to all peers."""
        allowed = {"available", "busy", "away"}
        if status not in allowed:
            return
        me = self.params["user"]
        me.profile.support_status = status
        me.profile.save(update_fields=["support_status"])
        self.state["my_status"] = status

        import asyncio
        from component_framework.core import ws_manager
        asyncio.create_task(
            ws_manager.broadcast(
                group="support-queue",
                message={
                    "event":   "agent_status_changed",
                    "payload": {"agent_id": me.pk, "status": status},
                },
            )
        )


@registry.register("agent_dashboard")
class AgentDashboardComponent(CompositeComponent):
    """
    Two-panel layout composed from AgentStatusComponent (sidebar slot)
    and TicketQueueComponent (main slot).
    """
    template_name = "support/agent_dashboard.html"
    slots = ["sidebar", "main"]

    def mount(self):
        user = self.params["user"]
        self.state["title"] = "Support Dashboard"
        self.fill_slot(
            "sidebar",
            AgentStatusComponent(params={"user": user}),
        )
        self.fill_slot(
            "main",
            TicketQueueComponent(params={"user": user}),
        )

The Composition Template

support/templates/support/agent_dashboard.html Django Template
{% extends "support/base.html" %}
{% block content %}
<div class="dashboard-layout">

  {# Sidebar slot — AgentStatusComponent renders here #}
  <aside class="dashboard-sidebar">
    {% component_slot "sidebar" %}
  </aside>

  {# Main slot — TicketQueueComponent renders here #}
  <main class="dashboard-main">
    <h1>{{ state.title }}</h1>
    {% component_slot "main" %}
  </main>

</div>
{% endblock %}
Slot independence. Each slot component manages its own state and event handlers independently. HTMX targets only the slot's root element on re-render — the surrounding layout is never touched. Both slots share the same WebSocket connection managed by QueueConsumer.

QuickReplyComponent — Rate-Limited Submission

A FormComponent for submitting a reply to a ticket. RateLimitMixin enforces 5 submissions per hour per user. The Pydantic ReplySchema validates content length and strips HTML. On successful submission, a WebSocket broadcast delivers the reply to the customer's browser.

The Component

support/components.py (continued) Python
from pydantic import BaseModel, field_validator, constr
from component_framework.core.form import FormComponent
from .models import TicketReply


class ReplySchema(BaseModel):
    """Validated reply payload."""
    content: constr(min_length=10, max_length=4000, strip_whitespace=True)

    @field_validator("content")
    def strip_html_tags(cls, v: str) -> str:
        import html
        return html.escape(v)


@registry.register("quick_reply")
class QuickReplyComponent(FormComponent):
    """
    Rate-limited reply form for support agents.

    params:
        ticket_id (int): Ticket to reply to.
        user      (User): Sending agent.
    """
    schema        = ReplySchema
    template_name = "support/quick_reply.html"

    def mount(self):
        self.state.update({
            "ticket_id": self.params["ticket_id"],
            "submitted": False,
            "content":   "",
        })

    def on_submit(self):
        """
        Validate, persist the reply, then push it to the customer via WebSocket.
        Pydantic validation errors are collected in self.errors automatically.
        """
        if not self.validate():
            return

        agent = self.params["user"]
        ticket_id = self.state["ticket_id"]

        reply = TicketReply.objects.create(
            ticket_id=ticket_id,
            author=agent,
            content=self.validated_data.content,
        )

        # Push reply to the customer's browser
        import asyncio
        from component_framework.core import ws_manager
        asyncio.create_task(
            ws_manager.broadcast(
                group=f"ticket-customer-{ticket_id}",
                message={
                    "event":   "new_reply",
                    "payload": {
                        "reply_id": reply.pk,
                        "author":   agent.get_full_name(),
                        "content":  reply.content,
                    },
                },
            )
        )

        self.state["submitted"] = True
        self.state["content"]   = ""

Rate Limiting the Reply Endpoint

The view applies RateLimitMixin with a per-user throttle of 5 submissions per hour. The throttle key is scoped to reply:{user_id} so different agents have independent budgets. Excess requests receive HTTP 429 with a Retry-After header; optimistic state is rolled back automatically by the client library.

support/views.py (continued) Python
from component_framework.adapters.django_ratelimit import RateLimitMixin


class QuickReplyView(RateLimitMixin, AuthenticatedComponentView):
    """
    Rate-limited reply endpoint.

    - Staff only (permission_classes on component)
    - 5 replies per hour per agent (RateLimitMixin)
    - Excess → HTTP 429 + Retry-After header
    """
    permission_classes = [IsStaff]
    throttle_rate      = "5/hour"

    def get_throttle_key(self, request, **kwargs):
        return ff"reply:{request.user.pk}"

    def get_component_params(self, request, **kwargs):
        params = super().get_component_params(request, **kwargs)
        params["ticket_id"] = int(kwargs["ticket_id"])
        return params
Why rate-limit agent replies? SaaS helpdesks are high-value targets for automated abuse and credential-stuffing — a compromised agent account should not be able to spam thousands of customers in seconds. The 5/hour limit has no impact on normal agent workflows (agents rarely reply more than once per minute) but stops runaway scripts cold.

React vs Component Framework

Here is the state machine transition handler — the same business logic in React and in component-framework. The React version requires useReducer, action type constants, a dispatch function, and a context provider wired through the component tree. The component-framework version is 8 lines.

// REACT + CONTEXT
TicketDetail.tsx TypeScript
// 1. Action types
type Action =
  | { type: 'TRANSITION'; to: Status }
  | { type: 'ERROR'; msg: string };

// 2. Reducer with guard
function ticketReducer(state, action) {
  if (action.type === 'TRANSITION') {
    const allowed = TRANSITIONS[state.status];
    if (!allowed?.includes(action.to)) {
      return {...state,
        error: `Bad transition: ${action.to}`};
    }
    return {...state, status: action.to};
  }
  return state;
}

// 3. Component
function TicketDetail({ ticketId }) {
  const [state, dispatch] = useReducer(
    ticketReducer, initialState
  );
  const transition = async (to) => {
    dispatch({ type: 'TRANSITION', to });
    try {
      await apiTransition(ticketId, to);
    } catch {
      dispatch({ type: 'ERROR', msg: 'Failed' });
    }
  };
  /* + TicketContext.Provider, types, bundler */
}
// COMPONENT FRAMEWORK
support/components.py Python
# Transition guard — plain dict
ALLOWED_TRANSITIONS = {
    "open":             {"assigned"},
    "assigned":        {"pending_customer",
                         "resolved"},
    "pending_customer": {"assigned",
                         "resolved"},
    "resolved":        set(),
}

def on_transition(self, to_status: str):
    current = self.state["status"]
    if to_status not in \
            ALLOWED_TRANSITIONS.get(current, set()):
        raise ComponentError(
            ff"'{current}' → '{to_status}' forbidden"
        )
    ticket = Ticket.objects.get(
        pk=self.state["ticket_id"]
    )
    ticket.status = to_status
    ticket.save(update_fields=["status"])
    self.state["status"] = to_status

# That's it. pytest covers the rest.
Concern React + Context/Redux Component Framework
State machine useReducer + action types + guard logic on_transition() + ALLOWED_TRANSITIONS dict
Invalid transitions Client-side guard + server re-validation Server-onlyComponentError → HTTP 400
Real-time queue socket.io + Redux dispatch on message ws_manager.broadcast() → consumer prepend
Rate limiting API gateway / custom middleware RateLimitMixin(throttle_rate="5/hour")
Composition Context providers + prop drilling CompositeComponent + fill_slot()
Optimistic UI useOptimistic hook + rollback on error get_optimistic_patch() → auto rollback
Input validation Zod / Yup schema (client) + server re-check Pydantic — single schema, server only
Auth gating Route guards + API middleware permission_classes = [IsStaff]
Build step webpack / Vite required None
Test tooling Jest + RTL + mock socket server pytest — pure Python, no browser

Testing

All component behaviour — state machine guards, rate-limit rejection, and optimistic patches — is testable with plain Python. No browser, no running server, no mocking of React internals or socket connections required.

support/tests/test_components.py Python
from component_framework.testing import ComponentTestCase, MockRenderer
from component_framework.core import ComponentError
from support.components import TicketDetailComponent, QuickReplyComponent


class TestTicketStateMachine(ComponentTestCase):

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

    def _make_ticket(self, status="open"):
        c = self.make_component(TicketDetailComponent)
        c.state = {
            "ticket_id": 1,
            "subject":   "Login broken",
            "body":      "Cannot log in since update.",
            "status":    status,
            "assignee_id": None,
        }
        return c

    def test_valid_transition_open_to_assigned(self):
        c = self._make_ticket("open")
        c.on_transition("assigned")
        self.assertEqual(c.state["status"], "assigned")

    def test_invalid_transition_open_to_resolved_raises(self):
        c = self._make_ticket("open")
        with self.assertRaises(ComponentError):
            c.on_transition("resolved")

    def test_resolved_ticket_accepts_no_transitions(self):
        c = self._make_ticket("resolved")
        for target in ["open", "assigned", "pending_customer"]:
            with self.assertRaises(ComponentError):
                c.on_transition(target)

    def test_optimistic_patch_contains_status(self):
        c = self._make_ticket("assigned")
        patch = c.get_optimistic_patch(
            "transition", {"to_status": "resolved"}
        )
        self.assertEqual(patch["status"], "resolved")

    def test_optimistic_patch_is_none_for_unknown_event(self):
        c = self._make_ticket("open")
        patch = c.get_optimistic_patch("unknown_event", {})
        self.assertIsNone(patch)


class TestQuickReplyValidation(ComponentTestCase):

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

    def test_short_content_fails_validation(self):
        c = self.make_component(QuickReplyComponent, params={"ticket_id": 1})
        c.mount()
        c.state["content"] = "Too short"
        result = c.validate()
        self.assertFalse(result)
        self.assertIn("content", c.errors)

    def test_valid_content_passes(self):
        c = self.make_component(QuickReplyComponent, params={"ticket_id": 1})
        c.mount()
        c.state["content"] = "Thank you for reaching out. I have investigated the issue and applied a fix."
        result = c.validate()
        self.assertTrue(result)
        self.assertEqual(c.errors, {})
shell bash
pytest support/tests/ -v
Test the state machine without a database. The guard logic in on_transition() is pure Python before any ORM call. By pre-loading self.state directly, tests exercise the full guard and optimistic-patch path without migrations, fixtures, or mocking Django's ORM.

Running the Example

  1. Clone and install the Django extras
    shellbash
    git clone https://github.com/fsecada01/component-framework
    cd component-framework/examples/django_example
    uv sync --extra django
  2. Apply migrations and create a staff superuser
    shellbash
    python manage.py migrate
    python manage.py createsuperuser
  3. Configure Django Channels in ASGI with Redis as the channel layer
    settings.py (excerpt)Python
    CHANNEL_LAYERS = {
        "default": {
            "BACKEND": "channels_redis.core.RedisChannelLayer",
            "CONFIG": {"hosts": [("127.0.0.1", 6379)]},
        }
    }
  4. Start Redis (required for WebSocket channel layer)
    shellbash
    docker run -p 6379:6379 redis:7-alpine
  5. Run the ASGI dev server
    shellbash
    python manage.py runserver
    # → http://localhost:8000/support/dashboard/
Open http://localhost:8000/support/dashboard/ in two browser tabs logged in as a staff user. In a third tab (or via the API) create a new ticket — watch it appear in both agent queues simultaneously. Click "Assign to Me", then "Resolve" — see the status badge flash optimistically before the server responds. No page refresh. No spinner. Just Python.

Summary

Feature Component / Mechanism How
Real-time queue TicketQueueComponent WebSocket Push via ws_manager.broadcast() → consumer prepend
State machine on_transition guard ALLOWED_TRANSITIONS dict + ComponentError on violation
Rate limiting QuickReplyComponent RateLimitMixin(throttle_rate="5/hour")
Composition AgentDashboardComponent CompositeComponent + SlotComponent + fill_slot()
Optimistic feedback TicketDetailComponent OptimisticMixin + get_optimistic_patch() → auto rollback
Input validation ReplySchema Pydantic BaseModel — server-only, no client duplicate
Auth gating All agent views permission_classes = [IsStaff] → JSON 401/403
Testing All components Pure Python — pytest, MockRenderer, ComponentTestCase