component_framework.adapters.django_views

Django view adapters for component endpoints.

  1"""Django view adapters for component endpoints."""
  2
  3import json
  4import logging
  5
  6try:
  7    from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
  8    from django.http import HttpRequest, JsonResponse
  9    from django.utils.decorators import method_decorator
 10    from django.views import View
 11    from django.views.decorators.csrf import csrf_exempt
 12    from django.views.decorators.http import require_POST
 13    from django.views.generic import TemplateView
 14except ImportError as e:
 15    from . import _require_extra
 16
 17    raise _require_extra("django", "django") from e
 18
 19from ..core import Component, StateSerializer, registry
 20from ..core.permissions import AllowAny
 21
 22logger = logging.getLogger(__name__)
 23
 24
 25@require_POST
 26def component_view(request: HttpRequest, name: str) -> JsonResponse:
 27    """
 28    Django view for component endpoints.
 29
 30    POST /components/{name}/
 31    Body (JSON or form-data):
 32        - event: Event name
 33        - payload: Event data (JSON string)
 34        - state: Serialized state (JSON string)
 35        - params: Component parameters (JSON string)
 36
 37    Returns:
 38        JSON response with html, state, and component_id
 39    """
 40    try:
 41        # Get component class
 42        component_cls = registry.get(name)
 43        if not component_cls:
 44            return JsonResponse({"error": f"Component '{name}' not found"}, status=404)
 45
 46        # Check component-level permissions
 47        for perm_class in getattr(component_cls, "permission_classes", []) or [AllowAny]:
 48            if not perm_class().has_permission(request, component_cls):
 49                return JsonResponse({"error": "Permission denied"}, status=403)
 50
 51        # Parse request data (supports both JSON and form-encoded)
 52        if request.content_type == "application/json":
 53            try:
 54                data = json.loads(request.body)
 55            except json.JSONDecodeError as e:
 56                return JsonResponse({"error": f"Invalid JSON: {e}"}, status=400)
 57        else:
 58            data = {
 59                "event": request.POST.get("event"),
 60                "payload": request.POST.get("payload", "{}"),
 61                "state": request.POST.get("state"),
 62                "params": request.POST.get("params", "{}"),
 63            }
 64
 65        # Extract parameters
 66        event = data.get("event")
 67        payload_str = data.get("payload", "{}")
 68        state_str = data.get("state")
 69        params_str = data.get("params", "{}")
 70
 71        # Parse JSON strings
 72        try:
 73            payload = json.loads(payload_str) if isinstance(payload_str, str) else payload_str
 74            params = json.loads(params_str) if isinstance(params_str, str) else params_str
 75        except json.JSONDecodeError as e:
 76            return JsonResponse({"error": f"Invalid JSON in payload/params: {e}"}, status=400)
 77
 78        # Deserialize state
 79        state = None
 80        if state_str:
 81            try:
 82                state = StateSerializer.deserialize(state_str)
 83            except Exception as e:
 84                return JsonResponse({"error": f"Invalid state: {e}"}, status=400)
 85
 86        # Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
 87        # Hydrate state so get_optimistic_patch can access current state values.
 88        optimistic_patch: dict | None = None
 89        if event:
 90            probe = component_cls(**params)
 91            if state:
 92                probe.hydrate(state)
 93            else:
 94                probe.mount()
 95            optimistic_patch = probe.get_optimistic_patch(event, payload or {})
 96
 97        # Create and dispatch component
 98        component = component_cls(**params)
 99        result = component.dispatch(event=event, payload=payload, state=state)
100
101        # Serialize state for response
102        result["state"] = StateSerializer.serialize(result["state"])
103
104        # Include optimistic patch if the component provided one
105        if optimistic_patch is not None:
106            result["optimistic"] = optimistic_patch
107
108        return JsonResponse(result)
109
110    except Exception:
111        logger.exception(f"Error processing component '{name}'")
112        return JsonResponse({"error": "Internal server error"}, status=500)
113
114
115# CSRF-exempt version for HTMX requests (use with caution)
116component_view_no_csrf = csrf_exempt(component_view)
117
118
119def create_component_url_pattern():
120    """
121    Create URL pattern for component endpoint.
122
123    Usage in urls.py:
124        from component_framework.adapters.django_views import create_component_url_pattern
125
126        urlpatterns = [
127            create_component_url_pattern(),
128        ]
129    """
130    from django.urls import path
131
132    return path("components/<str:name>/", component_view, name="component_endpoint")
133
134
135# ==================== Class-Based Views ====================
136
137
138class ComponentView(View):
139    """
140    Class-based view for component endpoints.
141
142    Usage:
143        # urls.py
144        from component_framework.adapters.django_views import ComponentView
145
146        urlpatterns = [
147            path("components/<str:name>/", ComponentView.as_view()),
148        ]
149
150    Or with custom configuration:
151        class MyComponentView(ComponentView):
152            http_method_names = ['post', 'get']  # Allow GET too
153
154            def get_component_params(self, request, **kwargs):
155                # Custom param extraction
156                params = super().get_component_params(request, **kwargs)
157                params['user_id'] = request.user.id
158                return params
159    """
160
161    http_method_names = ["post"]
162
163    def check_component_permissions(
164        self, request: HttpRequest, component_cls: type[Component]
165    ) -> JsonResponse | None:
166        """
167        Check component-level permission_classes.
168
169        Returns a JsonResponse (403) if any permission class denies access,
170        or None if all permissions are granted.
171        """
172        for perm_class in getattr(component_cls, "permission_classes", []) or [AllowAny]:
173            if not perm_class().has_permission(request, component_cls):
174                return JsonResponse({"error": "Permission denied"}, status=403)
175        return None
176
177    def post(self, request: HttpRequest, name: str, **kwargs) -> JsonResponse:
178        """Handle POST request for component."""
179        try:
180            # Get component class
181            component_cls = self.get_component_class(name)
182            if not component_cls:
183                return self.component_not_found(name)
184
185            # Check component-level permissions
186            perm_response = self.check_component_permissions(request, component_cls)
187            if perm_response is not None:
188                return perm_response
189
190            # Parse request data
191            data = self.parse_request_data(request)
192
193            # Extract component parameters
194            event = data.get("event")
195            payload = self.parse_payload(data.get("payload", "{}"))
196            state = self.parse_state(data.get("state"))
197            params = self.parse_params(data.get("params", "{}"))
198
199            # Add custom params
200            params.update(self.get_component_params(request, **kwargs))
201
202            # Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
203            # A separate probe instance is used so the real dispatch starts clean.
204            optimistic_patch: dict | None = None
205            if event:
206                probe = self.create_component(component_cls, params)
207                if state:
208                    probe.hydrate(state)
209                else:
210                    probe.mount()
211                optimistic_patch = probe.get_optimistic_patch(event, payload)
212
213            # Create and dispatch component
214            component = self.create_component(component_cls, params)
215            result = self.dispatch_component(component, event, payload, state)
216
217            # Post-process result
218            result = self.post_process_result(result, component)
219
220            # Attach optimistic patch if the component provided one
221            if optimistic_patch is not None:
222                result["optimistic"] = optimistic_patch
223
224            return self.render_response(result)
225
226        except Exception as e:
227            return self.handle_error(e, name)
228
229    def get_component_class(self, name: str) -> type[Component] | None:
230        """Get component class from registry."""
231        return registry.get(name)
232
233    def parse_request_data(self, request: HttpRequest) -> dict:
234        """Parse request data (JSON or form-encoded)."""
235        if request.content_type == "application/json":
236            try:
237                return json.loads(request.body)
238            except json.JSONDecodeError as e:
239                raise ValueError(f"Invalid JSON: {e}")
240        else:
241            return {
242                "event": request.POST.get("event"),
243                "payload": request.POST.get("payload", "{}"),
244                "state": request.POST.get("state"),
245                "params": request.POST.get("params", "{}"),
246            }
247
248    def parse_payload(self, payload_str: str | dict) -> dict:
249        """Parse payload JSON string."""
250        if isinstance(payload_str, dict):
251            return payload_str
252        try:
253            return json.loads(payload_str)
254        except json.JSONDecodeError as e:
255            raise ValueError(f"Invalid payload JSON: {e}")
256
257    def parse_state(self, state_str: str | None) -> dict | None:
258        """Parse state JSON string."""
259        if not state_str:
260            return None
261        try:
262            return StateSerializer.deserialize(state_str)
263        except Exception as e:
264            raise ValueError(f"Invalid state: {e}")
265
266    def parse_params(self, params_str: str | dict) -> dict:
267        """Parse params JSON string."""
268        if isinstance(params_str, dict):
269            return params_str
270        try:
271            return json.loads(params_str)
272        except json.JSONDecodeError as e:
273            raise ValueError(f"Invalid params JSON: {e}")
274
275    def get_component_params(self, request: HttpRequest, **kwargs) -> dict:
276        """
277        Get additional component parameters.
278
279        Override to inject custom parameters (user, session data, etc.)
280        """
281        return {}
282
283    def create_component(self, component_cls: type[Component], params: dict) -> Component:
284        """Create component instance."""
285        return component_cls(**params)
286
287    def dispatch_component(
288        self, component: Component, event: str | None, payload: dict, state: dict | None
289    ) -> dict:
290        """Dispatch component with event."""
291        return component.dispatch(event=event, payload=payload, state=state)
292
293    def post_process_result(self, result: dict, component: Component) -> dict:
294        """Post-process component result. Override to add custom data."""
295        result["state"] = StateSerializer.serialize(result["state"])
296        return result
297
298    def render_response(self, result: dict) -> JsonResponse:
299        """Render JSON response."""
300        return JsonResponse(result)
301
302    def component_not_found(self, name: str) -> JsonResponse:
303        """Handle component not found."""
304        return JsonResponse({"error": f"Component '{name}' not found"}, status=404)
305
306    def handle_error(self, error: Exception, name: str) -> JsonResponse:
307        """Handle errors."""
308        logger.exception(f"Error processing component '{name}'")
309        return JsonResponse({"error": "Internal server error"}, status=500)
310
311
312class AuthenticatedComponentView(LoginRequiredMixin, ComponentView):
313    """
314    Component view that requires authentication.
315
316    Returns a JSON 401 response (not a redirect) for unauthenticated requests,
317    making it safe for HTMX/fetch consumers.
318
319    Usage:
320        urlpatterns = [
321            path("components/<str:name>/", AuthenticatedComponentView.as_view()),
322        ]
323    """
324
325    def handle_no_permission(self) -> JsonResponse:
326        """Return JSON 401 instead of redirecting to login."""
327        return JsonResponse({"error": "Authentication required"}, status=401)
328
329    def get_component_params(self, request: HttpRequest, **kwargs) -> dict:
330        """Add user to component params."""
331        params = super().get_component_params(request, **kwargs)
332        params["user"] = request.user  # type: ignore[unresolved-attribute]  # added by AuthenticationMiddleware
333        params["user_id"] = request.user.id  # type: ignore[unresolved-attribute]  # added by AuthenticationMiddleware
334        return params
335
336
337class PermissionComponentView(PermissionRequiredMixin, ComponentView):
338    """
339    Component view with permission checking.
340
341    Returns a JSON 403 response (not a redirect) when permission is denied,
342    making it safe for HTMX/fetch consumers.
343
344    Usage:
345        class MyComponentView(PermissionComponentView):
346            permission_required = 'app.change_model'
347
348        # Or dynamic permissions:
349        class MyComponentView(PermissionComponentView):
350            def get_permission_required(self):
351                # Check component-specific permissions
352                component_name = self.kwargs.get('name')
353                return f'app.use_{component_name}'
354    """
355
356    def handle_no_permission(self) -> JsonResponse:
357        """Return JSON 403 instead of redirecting."""
358        return JsonResponse({"error": "Permission denied"}, status=403)
359
360
361@method_decorator(csrf_exempt, name="dispatch")
362class CSRFExemptComponentView(ComponentView):
363    """
364    Component view with CSRF exemption.
365
366    Use with caution! Only for HTMX/AJAX requests with alternative CSRF protection.
367
368    Usage:
369        urlpatterns = [
370            path("api/components/<str:name>/", CSRFExemptComponentView.as_view()),
371        ]
372    """
373
374    pass
375
376
377class SingleComponentView(ComponentView):
378    """
379    View for a single, specific component.
380
381    Usage:
382        class CounterView(SingleComponentView):
383            component_name = "counter"
384
385        urlpatterns = [
386            path("counter/", CounterView.as_view()),
387        ]
388    """
389
390    component_name: str | None = None
391
392    def post(self, request: HttpRequest, **kwargs) -> JsonResponse:  # type: ignore[override]
393        """Override to use fixed component name."""
394        if not self.component_name:
395            raise ValueError("component_name must be set")
396        return super().post(request, self.component_name, **kwargs)
397
398
399class ComponentPageView(TemplateView):
400    """
401    Template view that renders a page with components.
402
403    Usage:
404        class MyPageView(ComponentPageView):
405            template_name = "my_page.html"
406            components = {
407                "counter": {"initial": 5},
408                "form": {"user_id": 123},
409            }
410
411            def get_component_params(self, component_name):
412                # Override to add dynamic params
413                params = super().get_component_params(component_name)
414                if component_name == "form":
415                    params["user_id"] = self.request.user.id
416                return params
417    """
418
419    components: dict[str, dict] = {}
420
421    def get_context_data(self, **kwargs):
422        """Add component rendering to context."""
423        context = super().get_context_data(**kwargs)
424        context["components"] = {}
425
426        for component_name, params in self.get_components().items():
427            component_cls = registry.get(component_name)
428            if component_cls:
429                # Add custom params
430                params = self.get_component_params(component_name, params)
431
432                # Render component
433                component = component_cls(**params)
434                result = component.dispatch()
435                context["components"][component_name] = result
436
437        return context
438
439    def get_components(self) -> dict[str, dict]:
440        """Get components to render. Override for dynamic components."""
441        return self.components
442
443    def get_component_params(self, component_name: str, params: dict) -> dict:
444        """Get parameters for a specific component. Override to add custom params."""
445        return params.copy()
446
447
448# ==================== Mixins ====================
449
450
451class CacheMixin:
452    """
453    Mixin to add caching to component responses via Django's cache framework.
454
455    Caches the JSON result of component dispatches (mount/hydrate only). On
456    subsequent requests with the same component name, params, and state, the
457    cached result is returned directly without re-dispatching the component.
458
459    Cache bypass rules:
460    - **Event requests** (requests that carry an ``event`` field) are never
461      served from or written to the cache. Events mutate component state, so
462      caching them would cause stale state to be returned on the next request.
463    - **Non-200 responses** are not stored in the cache. Errors, 404s, and
464      permission denials must always go through the full request cycle.
465
466    Attributes:
467        cache_timeout: Cache TTL in seconds. Defaults to 60.
468
469    Customising the cache key:
470        Override ``get_cache_key`` to produce application-specific keys::
471
472            class MyComponentView(CacheMixin, ComponentView):
473                cache_timeout = 300  # 5 minutes
474
475                def get_cache_key(self, name, params, state):
476                    return f"component:{name}:{params.get('id')}"
477
478    MRO note:
479        Always place ``CacheMixin`` *before* ``ComponentView`` in the class
480        bases so that this ``post`` override runs first::
481
482            class CachedView(CacheMixin, ComponentView):
483                pass
484    """
485
486    cache_timeout: int = 60
487
488    def get_cache_key(self, name: str, params: dict, state: dict | None) -> str:
489        """Generate cache key. Override for custom keys."""
490        import hashlib
491
492        params_json = json.dumps(params, sort_keys=True)
493        state_json = json.dumps(state or {}, sort_keys=True)
494        key_data = f"{name}:{params_json}:{state_json}"
495        return f"component:{hashlib.md5(key_data.encode()).hexdigest()}"
496
497    def post(self, request: HttpRequest, name: str, **kwargs) -> JsonResponse:
498        """Check cache before processing. Event requests bypass the cache entirely."""
499        from django.core.cache import cache
500
501        # Parse request data — methods provided by ComponentView via MRO
502        data = self.parse_request_data(request)  # type: ignore[unresolved-attribute]  # cooperative mixin
503
504        # Event requests mutate state and must never be served from or stored
505        # in the cache. Skip all cache logic and go straight to the parent view.
506        if data.get("event"):
507            return super().post(request, name, **kwargs)  # type: ignore[unresolved-attribute]  # cooperative mixin
508
509        params = self.parse_params(data.get("params", "{}"))  # type: ignore[unresolved-attribute]  # cooperative mixin
510        state = self.parse_state(data.get("state"))  # type: ignore[unresolved-attribute]  # cooperative mixin
511
512        cache_key = self.get_cache_key(name, params, state)
513        cached_result = cache.get(cache_key)
514
515        if cached_result is not None:
516            return JsonResponse(cached_result)
517
518        # Process normally
519        response = super().post(request, name, **kwargs)  # type: ignore[unresolved-attribute]  # cooperative mixin
520
521        # Only cache successful responses
522        if response.status_code == 200:
523            cache.set(cache_key, json.loads(response.content), self.cache_timeout)
524
525        return response
logger = <Logger component_framework.adapters.django_views (WARNING)>
@require_POST
def component_view( request: django.http.request.HttpRequest, name: str) -> django.http.response.JsonResponse:
 26@require_POST
 27def component_view(request: HttpRequest, name: str) -> JsonResponse:
 28    """
 29    Django view for component endpoints.
 30
 31    POST /components/{name}/
 32    Body (JSON or form-data):
 33        - event: Event name
 34        - payload: Event data (JSON string)
 35        - state: Serialized state (JSON string)
 36        - params: Component parameters (JSON string)
 37
 38    Returns:
 39        JSON response with html, state, and component_id
 40    """
 41    try:
 42        # Get component class
 43        component_cls = registry.get(name)
 44        if not component_cls:
 45            return JsonResponse({"error": f"Component '{name}' not found"}, status=404)
 46
 47        # Check component-level permissions
 48        for perm_class in getattr(component_cls, "permission_classes", []) or [AllowAny]:
 49            if not perm_class().has_permission(request, component_cls):
 50                return JsonResponse({"error": "Permission denied"}, status=403)
 51
 52        # Parse request data (supports both JSON and form-encoded)
 53        if request.content_type == "application/json":
 54            try:
 55                data = json.loads(request.body)
 56            except json.JSONDecodeError as e:
 57                return JsonResponse({"error": f"Invalid JSON: {e}"}, status=400)
 58        else:
 59            data = {
 60                "event": request.POST.get("event"),
 61                "payload": request.POST.get("payload", "{}"),
 62                "state": request.POST.get("state"),
 63                "params": request.POST.get("params", "{}"),
 64            }
 65
 66        # Extract parameters
 67        event = data.get("event")
 68        payload_str = data.get("payload", "{}")
 69        state_str = data.get("state")
 70        params_str = data.get("params", "{}")
 71
 72        # Parse JSON strings
 73        try:
 74            payload = json.loads(payload_str) if isinstance(payload_str, str) else payload_str
 75            params = json.loads(params_str) if isinstance(params_str, str) else params_str
 76        except json.JSONDecodeError as e:
 77            return JsonResponse({"error": f"Invalid JSON in payload/params: {e}"}, status=400)
 78
 79        # Deserialize state
 80        state = None
 81        if state_str:
 82            try:
 83                state = StateSerializer.deserialize(state_str)
 84            except Exception as e:
 85                return JsonResponse({"error": f"Invalid state: {e}"}, status=400)
 86
 87        # Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
 88        # Hydrate state so get_optimistic_patch can access current state values.
 89        optimistic_patch: dict | None = None
 90        if event:
 91            probe = component_cls(**params)
 92            if state:
 93                probe.hydrate(state)
 94            else:
 95                probe.mount()
 96            optimistic_patch = probe.get_optimistic_patch(event, payload or {})
 97
 98        # Create and dispatch component
 99        component = component_cls(**params)
100        result = component.dispatch(event=event, payload=payload, state=state)
101
102        # Serialize state for response
103        result["state"] = StateSerializer.serialize(result["state"])
104
105        # Include optimistic patch if the component provided one
106        if optimistic_patch is not None:
107            result["optimistic"] = optimistic_patch
108
109        return JsonResponse(result)
110
111    except Exception:
112        logger.exception(f"Error processing component '{name}'")
113        return JsonResponse({"error": "Internal server error"}, status=500)

Django view for component endpoints.

POST /components/{name}/ Body (JSON or form-data): - event: Event name - payload: Event data (JSON string) - state: Serialized state (JSON string) - params: Component parameters (JSON string)

Returns:

JSON response with html, state, and component_id

@require_POST
def component_view_no_csrf( request: django.http.request.HttpRequest, name: str) -> django.http.response.JsonResponse:
 26@require_POST
 27def component_view(request: HttpRequest, name: str) -> JsonResponse:
 28    """
 29    Django view for component endpoints.
 30
 31    POST /components/{name}/
 32    Body (JSON or form-data):
 33        - event: Event name
 34        - payload: Event data (JSON string)
 35        - state: Serialized state (JSON string)
 36        - params: Component parameters (JSON string)
 37
 38    Returns:
 39        JSON response with html, state, and component_id
 40    """
 41    try:
 42        # Get component class
 43        component_cls = registry.get(name)
 44        if not component_cls:
 45            return JsonResponse({"error": f"Component '{name}' not found"}, status=404)
 46
 47        # Check component-level permissions
 48        for perm_class in getattr(component_cls, "permission_classes", []) or [AllowAny]:
 49            if not perm_class().has_permission(request, component_cls):
 50                return JsonResponse({"error": "Permission denied"}, status=403)
 51
 52        # Parse request data (supports both JSON and form-encoded)
 53        if request.content_type == "application/json":
 54            try:
 55                data = json.loads(request.body)
 56            except json.JSONDecodeError as e:
 57                return JsonResponse({"error": f"Invalid JSON: {e}"}, status=400)
 58        else:
 59            data = {
 60                "event": request.POST.get("event"),
 61                "payload": request.POST.get("payload", "{}"),
 62                "state": request.POST.get("state"),
 63                "params": request.POST.get("params", "{}"),
 64            }
 65
 66        # Extract parameters
 67        event = data.get("event")
 68        payload_str = data.get("payload", "{}")
 69        state_str = data.get("state")
 70        params_str = data.get("params", "{}")
 71
 72        # Parse JSON strings
 73        try:
 74            payload = json.loads(payload_str) if isinstance(payload_str, str) else payload_str
 75            params = json.loads(params_str) if isinstance(params_str, str) else params_str
 76        except json.JSONDecodeError as e:
 77            return JsonResponse({"error": f"Invalid JSON in payload/params: {e}"}, status=400)
 78
 79        # Deserialize state
 80        state = None
 81        if state_str:
 82            try:
 83                state = StateSerializer.deserialize(state_str)
 84            except Exception as e:
 85                return JsonResponse({"error": f"Invalid state: {e}"}, status=400)
 86
 87        # Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
 88        # Hydrate state so get_optimistic_patch can access current state values.
 89        optimistic_patch: dict | None = None
 90        if event:
 91            probe = component_cls(**params)
 92            if state:
 93                probe.hydrate(state)
 94            else:
 95                probe.mount()
 96            optimistic_patch = probe.get_optimistic_patch(event, payload or {})
 97
 98        # Create and dispatch component
 99        component = component_cls(**params)
100        result = component.dispatch(event=event, payload=payload, state=state)
101
102        # Serialize state for response
103        result["state"] = StateSerializer.serialize(result["state"])
104
105        # Include optimistic patch if the component provided one
106        if optimistic_patch is not None:
107            result["optimistic"] = optimistic_patch
108
109        return JsonResponse(result)
110
111    except Exception:
112        logger.exception(f"Error processing component '{name}'")
113        return JsonResponse({"error": "Internal server error"}, status=500)

Django view for component endpoints.

POST /components/{name}/ Body (JSON or form-data): - event: Event name - payload: Event data (JSON string) - state: Serialized state (JSON string) - params: Component parameters (JSON string)

Returns:

JSON response with html, state, and component_id

def create_component_url_pattern():
120def create_component_url_pattern():
121    """
122    Create URL pattern for component endpoint.
123
124    Usage in urls.py:
125        from component_framework.adapters.django_views import create_component_url_pattern
126
127        urlpatterns = [
128            create_component_url_pattern(),
129        ]
130    """
131    from django.urls import path
132
133    return path("components/<str:name>/", component_view, name="component_endpoint")

Create URL pattern for component endpoint.

Usage in urls.py: from component_framework.adapters.django_views import create_component_url_pattern

urlpatterns = [
    create_component_url_pattern(),
]
class ComponentView(django.views.generic.base.View):
139class ComponentView(View):
140    """
141    Class-based view for component endpoints.
142
143    Usage:
144        # urls.py
145        from component_framework.adapters.django_views import ComponentView
146
147        urlpatterns = [
148            path("components/<str:name>/", ComponentView.as_view()),
149        ]
150
151    Or with custom configuration:
152        class MyComponentView(ComponentView):
153            http_method_names = ['post', 'get']  # Allow GET too
154
155            def get_component_params(self, request, **kwargs):
156                # Custom param extraction
157                params = super().get_component_params(request, **kwargs)
158                params['user_id'] = request.user.id
159                return params
160    """
161
162    http_method_names = ["post"]
163
164    def check_component_permissions(
165        self, request: HttpRequest, component_cls: type[Component]
166    ) -> JsonResponse | None:
167        """
168        Check component-level permission_classes.
169
170        Returns a JsonResponse (403) if any permission class denies access,
171        or None if all permissions are granted.
172        """
173        for perm_class in getattr(component_cls, "permission_classes", []) or [AllowAny]:
174            if not perm_class().has_permission(request, component_cls):
175                return JsonResponse({"error": "Permission denied"}, status=403)
176        return None
177
178    def post(self, request: HttpRequest, name: str, **kwargs) -> JsonResponse:
179        """Handle POST request for component."""
180        try:
181            # Get component class
182            component_cls = self.get_component_class(name)
183            if not component_cls:
184                return self.component_not_found(name)
185
186            # Check component-level permissions
187            perm_response = self.check_component_permissions(request, component_cls)
188            if perm_response is not None:
189                return perm_response
190
191            # Parse request data
192            data = self.parse_request_data(request)
193
194            # Extract component parameters
195            event = data.get("event")
196            payload = self.parse_payload(data.get("payload", "{}"))
197            state = self.parse_state(data.get("state"))
198            params = self.parse_params(data.get("params", "{}"))
199
200            # Add custom params
201            params.update(self.get_component_params(request, **kwargs))
202
203            # Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
204            # A separate probe instance is used so the real dispatch starts clean.
205            optimistic_patch: dict | None = None
206            if event:
207                probe = self.create_component(component_cls, params)
208                if state:
209                    probe.hydrate(state)
210                else:
211                    probe.mount()
212                optimistic_patch = probe.get_optimistic_patch(event, payload)
213
214            # Create and dispatch component
215            component = self.create_component(component_cls, params)
216            result = self.dispatch_component(component, event, payload, state)
217
218            # Post-process result
219            result = self.post_process_result(result, component)
220
221            # Attach optimistic patch if the component provided one
222            if optimistic_patch is not None:
223                result["optimistic"] = optimistic_patch
224
225            return self.render_response(result)
226
227        except Exception as e:
228            return self.handle_error(e, name)
229
230    def get_component_class(self, name: str) -> type[Component] | None:
231        """Get component class from registry."""
232        return registry.get(name)
233
234    def parse_request_data(self, request: HttpRequest) -> dict:
235        """Parse request data (JSON or form-encoded)."""
236        if request.content_type == "application/json":
237            try:
238                return json.loads(request.body)
239            except json.JSONDecodeError as e:
240                raise ValueError(f"Invalid JSON: {e}")
241        else:
242            return {
243                "event": request.POST.get("event"),
244                "payload": request.POST.get("payload", "{}"),
245                "state": request.POST.get("state"),
246                "params": request.POST.get("params", "{}"),
247            }
248
249    def parse_payload(self, payload_str: str | dict) -> dict:
250        """Parse payload JSON string."""
251        if isinstance(payload_str, dict):
252            return payload_str
253        try:
254            return json.loads(payload_str)
255        except json.JSONDecodeError as e:
256            raise ValueError(f"Invalid payload JSON: {e}")
257
258    def parse_state(self, state_str: str | None) -> dict | None:
259        """Parse state JSON string."""
260        if not state_str:
261            return None
262        try:
263            return StateSerializer.deserialize(state_str)
264        except Exception as e:
265            raise ValueError(f"Invalid state: {e}")
266
267    def parse_params(self, params_str: str | dict) -> dict:
268        """Parse params JSON string."""
269        if isinstance(params_str, dict):
270            return params_str
271        try:
272            return json.loads(params_str)
273        except json.JSONDecodeError as e:
274            raise ValueError(f"Invalid params JSON: {e}")
275
276    def get_component_params(self, request: HttpRequest, **kwargs) -> dict:
277        """
278        Get additional component parameters.
279
280        Override to inject custom parameters (user, session data, etc.)
281        """
282        return {}
283
284    def create_component(self, component_cls: type[Component], params: dict) -> Component:
285        """Create component instance."""
286        return component_cls(**params)
287
288    def dispatch_component(
289        self, component: Component, event: str | None, payload: dict, state: dict | None
290    ) -> dict:
291        """Dispatch component with event."""
292        return component.dispatch(event=event, payload=payload, state=state)
293
294    def post_process_result(self, result: dict, component: Component) -> dict:
295        """Post-process component result. Override to add custom data."""
296        result["state"] = StateSerializer.serialize(result["state"])
297        return result
298
299    def render_response(self, result: dict) -> JsonResponse:
300        """Render JSON response."""
301        return JsonResponse(result)
302
303    def component_not_found(self, name: str) -> JsonResponse:
304        """Handle component not found."""
305        return JsonResponse({"error": f"Component '{name}' not found"}, status=404)
306
307    def handle_error(self, error: Exception, name: str) -> JsonResponse:
308        """Handle errors."""
309        logger.exception(f"Error processing component '{name}'")
310        return JsonResponse({"error": "Internal server error"}, status=500)

Class-based view for component endpoints.

Usage:

urls.py

from component_framework.adapters.django_views import ComponentView

urlpatterns = [ path("components//", ComponentView.as_view()), ]

Or with custom configuration:

class MyComponentView(ComponentView): http_method_names = ['post', 'get'] # Allow GET too

def get_component_params(self, request, **kwargs):
    # Custom param extraction
    params = super().get_component_params(request, **kwargs)
    params['user_id'] = request.user.id
    return params
http_method_names = ['post']
def check_component_permissions( self, request: django.http.request.HttpRequest, component_cls: type[component_framework.core.Component]) -> django.http.response.JsonResponse | None:
164    def check_component_permissions(
165        self, request: HttpRequest, component_cls: type[Component]
166    ) -> JsonResponse | None:
167        """
168        Check component-level permission_classes.
169
170        Returns a JsonResponse (403) if any permission class denies access,
171        or None if all permissions are granted.
172        """
173        for perm_class in getattr(component_cls, "permission_classes", []) or [AllowAny]:
174            if not perm_class().has_permission(request, component_cls):
175                return JsonResponse({"error": "Permission denied"}, status=403)
176        return None

Check component-level permission_classes.

Returns a JsonResponse (403) if any permission class denies access, or None if all permissions are granted.

def post( self, request: django.http.request.HttpRequest, name: str, **kwargs) -> django.http.response.JsonResponse:
178    def post(self, request: HttpRequest, name: str, **kwargs) -> JsonResponse:
179        """Handle POST request for component."""
180        try:
181            # Get component class
182            component_cls = self.get_component_class(name)
183            if not component_cls:
184                return self.component_not_found(name)
185
186            # Check component-level permissions
187            perm_response = self.check_component_permissions(request, component_cls)
188            if perm_response is not None:
189                return perm_response
190
191            # Parse request data
192            data = self.parse_request_data(request)
193
194            # Extract component parameters
195            event = data.get("event")
196            payload = self.parse_payload(data.get("payload", "{}"))
197            state = self.parse_state(data.get("state"))
198            params = self.parse_params(data.get("params", "{}"))
199
200            # Add custom params
201            params.update(self.get_component_params(request, **kwargs))
202
203            # Compute optimistic patch BEFORE dispatch (uses pre-dispatch state).
204            # A separate probe instance is used so the real dispatch starts clean.
205            optimistic_patch: dict | None = None
206            if event:
207                probe = self.create_component(component_cls, params)
208                if state:
209                    probe.hydrate(state)
210                else:
211                    probe.mount()
212                optimistic_patch = probe.get_optimistic_patch(event, payload)
213
214            # Create and dispatch component
215            component = self.create_component(component_cls, params)
216            result = self.dispatch_component(component, event, payload, state)
217
218            # Post-process result
219            result = self.post_process_result(result, component)
220
221            # Attach optimistic patch if the component provided one
222            if optimistic_patch is not None:
223                result["optimistic"] = optimistic_patch
224
225            return self.render_response(result)
226
227        except Exception as e:
228            return self.handle_error(e, name)

Handle POST request for component.

def get_component_class( self, name: str) -> type[component_framework.core.Component] | None:
230    def get_component_class(self, name: str) -> type[Component] | None:
231        """Get component class from registry."""
232        return registry.get(name)

Get component class from registry.

def parse_request_data(self, request: django.http.request.HttpRequest) -> dict:
234    def parse_request_data(self, request: HttpRequest) -> dict:
235        """Parse request data (JSON or form-encoded)."""
236        if request.content_type == "application/json":
237            try:
238                return json.loads(request.body)
239            except json.JSONDecodeError as e:
240                raise ValueError(f"Invalid JSON: {e}")
241        else:
242            return {
243                "event": request.POST.get("event"),
244                "payload": request.POST.get("payload", "{}"),
245                "state": request.POST.get("state"),
246                "params": request.POST.get("params", "{}"),
247            }

Parse request data (JSON or form-encoded).

def parse_payload(self, payload_str: str | dict) -> dict:
249    def parse_payload(self, payload_str: str | dict) -> dict:
250        """Parse payload JSON string."""
251        if isinstance(payload_str, dict):
252            return payload_str
253        try:
254            return json.loads(payload_str)
255        except json.JSONDecodeError as e:
256            raise ValueError(f"Invalid payload JSON: {e}")

Parse payload JSON string.

def parse_state(self, state_str: str | None) -> dict | None:
258    def parse_state(self, state_str: str | None) -> dict | None:
259        """Parse state JSON string."""
260        if not state_str:
261            return None
262        try:
263            return StateSerializer.deserialize(state_str)
264        except Exception as e:
265            raise ValueError(f"Invalid state: {e}")

Parse state JSON string.

def parse_params(self, params_str: str | dict) -> dict:
267    def parse_params(self, params_str: str | dict) -> dict:
268        """Parse params JSON string."""
269        if isinstance(params_str, dict):
270            return params_str
271        try:
272            return json.loads(params_str)
273        except json.JSONDecodeError as e:
274            raise ValueError(f"Invalid params JSON: {e}")

Parse params JSON string.

def get_component_params(self, request: django.http.request.HttpRequest, **kwargs) -> dict:
276    def get_component_params(self, request: HttpRequest, **kwargs) -> dict:
277        """
278        Get additional component parameters.
279
280        Override to inject custom parameters (user, session data, etc.)
281        """
282        return {}

Get additional component parameters.

Override to inject custom parameters (user, session data, etc.)

def create_component( self, component_cls: type[component_framework.core.Component], params: dict) -> component_framework.core.Component:
284    def create_component(self, component_cls: type[Component], params: dict) -> Component:
285        """Create component instance."""
286        return component_cls(**params)

Create component instance.

def dispatch_component( self, component: component_framework.core.Component, event: str | None, payload: dict, state: dict | None) -> dict:
288    def dispatch_component(
289        self, component: Component, event: str | None, payload: dict, state: dict | None
290    ) -> dict:
291        """Dispatch component with event."""
292        return component.dispatch(event=event, payload=payload, state=state)

Dispatch component with event.

def post_process_result( self, result: dict, component: component_framework.core.Component) -> dict:
294    def post_process_result(self, result: dict, component: Component) -> dict:
295        """Post-process component result. Override to add custom data."""
296        result["state"] = StateSerializer.serialize(result["state"])
297        return result

Post-process component result. Override to add custom data.

def render_response(self, result: dict) -> django.http.response.JsonResponse:
299    def render_response(self, result: dict) -> JsonResponse:
300        """Render JSON response."""
301        return JsonResponse(result)

Render JSON response.

def component_not_found(self, name: str) -> django.http.response.JsonResponse:
303    def component_not_found(self, name: str) -> JsonResponse:
304        """Handle component not found."""
305        return JsonResponse({"error": f"Component '{name}' not found"}, status=404)

Handle component not found.

def handle_error(self, error: Exception, name: str) -> django.http.response.JsonResponse:
307    def handle_error(self, error: Exception, name: str) -> JsonResponse:
308        """Handle errors."""
309        logger.exception(f"Error processing component '{name}'")
310        return JsonResponse({"error": "Internal server error"}, status=500)

Handle errors.

class AuthenticatedComponentView(django.contrib.auth.mixins.LoginRequiredMixin, ComponentView):
313class AuthenticatedComponentView(LoginRequiredMixin, ComponentView):
314    """
315    Component view that requires authentication.
316
317    Returns a JSON 401 response (not a redirect) for unauthenticated requests,
318    making it safe for HTMX/fetch consumers.
319
320    Usage:
321        urlpatterns = [
322            path("components/<str:name>/", AuthenticatedComponentView.as_view()),
323        ]
324    """
325
326    def handle_no_permission(self) -> JsonResponse:
327        """Return JSON 401 instead of redirecting to login."""
328        return JsonResponse({"error": "Authentication required"}, status=401)
329
330    def get_component_params(self, request: HttpRequest, **kwargs) -> dict:
331        """Add user to component params."""
332        params = super().get_component_params(request, **kwargs)
333        params["user"] = request.user  # type: ignore[unresolved-attribute]  # added by AuthenticationMiddleware
334        params["user_id"] = request.user.id  # type: ignore[unresolved-attribute]  # added by AuthenticationMiddleware
335        return params

Component view that requires authentication.

Returns a JSON 401 response (not a redirect) for unauthenticated requests, making it safe for HTMX/fetch consumers.

Usage:

urlpatterns = [ path("components//", AuthenticatedComponentView.as_view()), ]

def handle_no_permission(self) -> django.http.response.JsonResponse:
326    def handle_no_permission(self) -> JsonResponse:
327        """Return JSON 401 instead of redirecting to login."""
328        return JsonResponse({"error": "Authentication required"}, status=401)

Return JSON 401 instead of redirecting to login.

def get_component_params(self, request: django.http.request.HttpRequest, **kwargs) -> dict:
330    def get_component_params(self, request: HttpRequest, **kwargs) -> dict:
331        """Add user to component params."""
332        params = super().get_component_params(request, **kwargs)
333        params["user"] = request.user  # type: ignore[unresolved-attribute]  # added by AuthenticationMiddleware
334        params["user_id"] = request.user.id  # type: ignore[unresolved-attribute]  # added by AuthenticationMiddleware
335        return params

Add user to component params.

class PermissionComponentView(django.contrib.auth.mixins.PermissionRequiredMixin, ComponentView):
338class PermissionComponentView(PermissionRequiredMixin, ComponentView):
339    """
340    Component view with permission checking.
341
342    Returns a JSON 403 response (not a redirect) when permission is denied,
343    making it safe for HTMX/fetch consumers.
344
345    Usage:
346        class MyComponentView(PermissionComponentView):
347            permission_required = 'app.change_model'
348
349        # Or dynamic permissions:
350        class MyComponentView(PermissionComponentView):
351            def get_permission_required(self):
352                # Check component-specific permissions
353                component_name = self.kwargs.get('name')
354                return f'app.use_{component_name}'
355    """
356
357    def handle_no_permission(self) -> JsonResponse:
358        """Return JSON 403 instead of redirecting."""
359        return JsonResponse({"error": "Permission denied"}, status=403)

Component view with permission checking.

Returns a JSON 403 response (not a redirect) when permission is denied, making it safe for HTMX/fetch consumers.

Usage:

class MyComponentView(PermissionComponentView): permission_required = 'app.change_model'

Or dynamic permissions:

class MyComponentView(PermissionComponentView): def get_permission_required(self): # Check component-specific permissions component_name = self.kwargs.get('name') return f'app.use_{component_name}'

def handle_no_permission(self) -> django.http.response.JsonResponse:
357    def handle_no_permission(self) -> JsonResponse:
358        """Return JSON 403 instead of redirecting."""
359        return JsonResponse({"error": "Permission denied"}, status=403)

Return JSON 403 instead of redirecting.

@method_decorator(csrf_exempt, name='dispatch')
class CSRFExemptComponentView(ComponentView):
362@method_decorator(csrf_exempt, name="dispatch")
363class CSRFExemptComponentView(ComponentView):
364    """
365    Component view with CSRF exemption.
366
367    Use with caution! Only for HTMX/AJAX requests with alternative CSRF protection.
368
369    Usage:
370        urlpatterns = [
371            path("api/components/<str:name>/", CSRFExemptComponentView.as_view()),
372        ]
373    """
374
375    pass

Component view with CSRF exemption.

Use with caution! Only for HTMX/AJAX requests with alternative CSRF protection.

Usage:

urlpatterns = [ path("api/components//", CSRFExemptComponentView.as_view()), ]

def dispatch(self, request, *args, **kwargs):
136    def dispatch(self, request, *args, **kwargs):
137        # Try to dispatch to the right method; if a method doesn't exist,
138        # defer to the error handler. Also defer to the error handler if the
139        # request method isn't on the approved list.
140        if request.method.lower() in self.http_method_names:
141            handler = getattr(
142                self, request.method.lower(), self.http_method_not_allowed
143            )
144        else:
145            handler = self.http_method_not_allowed
146        return handler(request, *args, **kwargs)
class SingleComponentView(ComponentView):
378class SingleComponentView(ComponentView):
379    """
380    View for a single, specific component.
381
382    Usage:
383        class CounterView(SingleComponentView):
384            component_name = "counter"
385
386        urlpatterns = [
387            path("counter/", CounterView.as_view()),
388        ]
389    """
390
391    component_name: str | None = None
392
393    def post(self, request: HttpRequest, **kwargs) -> JsonResponse:  # type: ignore[override]
394        """Override to use fixed component name."""
395        if not self.component_name:
396            raise ValueError("component_name must be set")
397        return super().post(request, self.component_name, **kwargs)

View for a single, specific component.

Usage:

class CounterView(SingleComponentView): component_name = "counter"

urlpatterns = [ path("counter/", CounterView.as_view()), ]

component_name: str | None = None
def post( self, request: django.http.request.HttpRequest, **kwargs) -> django.http.response.JsonResponse:
393    def post(self, request: HttpRequest, **kwargs) -> JsonResponse:  # type: ignore[override]
394        """Override to use fixed component name."""
395        if not self.component_name:
396            raise ValueError("component_name must be set")
397        return super().post(request, self.component_name, **kwargs)

Override to use fixed component name.

class ComponentPageView(django.views.generic.base.TemplateView):
400class ComponentPageView(TemplateView):
401    """
402    Template view that renders a page with components.
403
404    Usage:
405        class MyPageView(ComponentPageView):
406            template_name = "my_page.html"
407            components = {
408                "counter": {"initial": 5},
409                "form": {"user_id": 123},
410            }
411
412            def get_component_params(self, component_name):
413                # Override to add dynamic params
414                params = super().get_component_params(component_name)
415                if component_name == "form":
416                    params["user_id"] = self.request.user.id
417                return params
418    """
419
420    components: dict[str, dict] = {}
421
422    def get_context_data(self, **kwargs):
423        """Add component rendering to context."""
424        context = super().get_context_data(**kwargs)
425        context["components"] = {}
426
427        for component_name, params in self.get_components().items():
428            component_cls = registry.get(component_name)
429            if component_cls:
430                # Add custom params
431                params = self.get_component_params(component_name, params)
432
433                # Render component
434                component = component_cls(**params)
435                result = component.dispatch()
436                context["components"][component_name] = result
437
438        return context
439
440    def get_components(self) -> dict[str, dict]:
441        """Get components to render. Override for dynamic components."""
442        return self.components
443
444    def get_component_params(self, component_name: str, params: dict) -> dict:
445        """Get parameters for a specific component. Override to add custom params."""
446        return params.copy()

Template view that renders a page with components.

Usage:

class MyPageView(ComponentPageView): template_name = "my_page.html" components = { "counter": {"initial": 5}, "form": {"user_id": 123}, }

def get_component_params(self, component_name):
    # Override to add dynamic params
    params = super().get_component_params(component_name)
    if component_name == "form":
        params["user_id"] = self.request.user.id
    return params
components: dict[str, dict] = {}
def get_context_data(self, **kwargs):
422    def get_context_data(self, **kwargs):
423        """Add component rendering to context."""
424        context = super().get_context_data(**kwargs)
425        context["components"] = {}
426
427        for component_name, params in self.get_components().items():
428            component_cls = registry.get(component_name)
429            if component_cls:
430                # Add custom params
431                params = self.get_component_params(component_name, params)
432
433                # Render component
434                component = component_cls(**params)
435                result = component.dispatch()
436                context["components"][component_name] = result
437
438        return context

Add component rendering to context.

def get_components(self) -> dict[str, dict]:
440    def get_components(self) -> dict[str, dict]:
441        """Get components to render. Override for dynamic components."""
442        return self.components

Get components to render. Override for dynamic components.

def get_component_params(self, component_name: str, params: dict) -> dict:
444    def get_component_params(self, component_name: str, params: dict) -> dict:
445        """Get parameters for a specific component. Override to add custom params."""
446        return params.copy()

Get parameters for a specific component. Override to add custom params.

class CacheMixin:
452class CacheMixin:
453    """
454    Mixin to add caching to component responses via Django's cache framework.
455
456    Caches the JSON result of component dispatches (mount/hydrate only). On
457    subsequent requests with the same component name, params, and state, the
458    cached result is returned directly without re-dispatching the component.
459
460    Cache bypass rules:
461    - **Event requests** (requests that carry an ``event`` field) are never
462      served from or written to the cache. Events mutate component state, so
463      caching them would cause stale state to be returned on the next request.
464    - **Non-200 responses** are not stored in the cache. Errors, 404s, and
465      permission denials must always go through the full request cycle.
466
467    Attributes:
468        cache_timeout: Cache TTL in seconds. Defaults to 60.
469
470    Customising the cache key:
471        Override ``get_cache_key`` to produce application-specific keys::
472
473            class MyComponentView(CacheMixin, ComponentView):
474                cache_timeout = 300  # 5 minutes
475
476                def get_cache_key(self, name, params, state):
477                    return f"component:{name}:{params.get('id')}"
478
479    MRO note:
480        Always place ``CacheMixin`` *before* ``ComponentView`` in the class
481        bases so that this ``post`` override runs first::
482
483            class CachedView(CacheMixin, ComponentView):
484                pass
485    """
486
487    cache_timeout: int = 60
488
489    def get_cache_key(self, name: str, params: dict, state: dict | None) -> str:
490        """Generate cache key. Override for custom keys."""
491        import hashlib
492
493        params_json = json.dumps(params, sort_keys=True)
494        state_json = json.dumps(state or {}, sort_keys=True)
495        key_data = f"{name}:{params_json}:{state_json}"
496        return f"component:{hashlib.md5(key_data.encode()).hexdigest()}"
497
498    def post(self, request: HttpRequest, name: str, **kwargs) -> JsonResponse:
499        """Check cache before processing. Event requests bypass the cache entirely."""
500        from django.core.cache import cache
501
502        # Parse request data — methods provided by ComponentView via MRO
503        data = self.parse_request_data(request)  # type: ignore[unresolved-attribute]  # cooperative mixin
504
505        # Event requests mutate state and must never be served from or stored
506        # in the cache. Skip all cache logic and go straight to the parent view.
507        if data.get("event"):
508            return super().post(request, name, **kwargs)  # type: ignore[unresolved-attribute]  # cooperative mixin
509
510        params = self.parse_params(data.get("params", "{}"))  # type: ignore[unresolved-attribute]  # cooperative mixin
511        state = self.parse_state(data.get("state"))  # type: ignore[unresolved-attribute]  # cooperative mixin
512
513        cache_key = self.get_cache_key(name, params, state)
514        cached_result = cache.get(cache_key)
515
516        if cached_result is not None:
517            return JsonResponse(cached_result)
518
519        # Process normally
520        response = super().post(request, name, **kwargs)  # type: ignore[unresolved-attribute]  # cooperative mixin
521
522        # Only cache successful responses
523        if response.status_code == 200:
524            cache.set(cache_key, json.loads(response.content), self.cache_timeout)
525
526        return response

Mixin to add caching to component responses via Django's cache framework.

Caches the JSON result of component dispatches (mount/hydrate only). On subsequent requests with the same component name, params, and state, the cached result is returned directly without re-dispatching the component.

Cache bypass rules:

  • Event requests (requests that carry an event field) are never served from or written to the cache. Events mutate component state, so caching them would cause stale state to be returned on the next request.
  • Non-200 responses are not stored in the cache. Errors, 404s, and permission denials must always go through the full request cycle.
Attributes:
  • cache_timeout: Cache TTL in seconds. Defaults to 60.
Customising the cache key:

Override get_cache_key to produce application-specific keys::

class MyComponentView(CacheMixin, ComponentView):
    cache_timeout = 300  # 5 minutes

    def get_cache_key(self, name, params, state):
        return f"component:{name}:{params.get('id')}"
MRO note:

Always place CacheMixin before ComponentView in the class bases so that this post override runs first::

class CachedView(CacheMixin, ComponentView):
    pass
cache_timeout: int = 60
def get_cache_key(self, name: str, params: dict, state: dict | None) -> str:
489    def get_cache_key(self, name: str, params: dict, state: dict | None) -> str:
490        """Generate cache key. Override for custom keys."""
491        import hashlib
492
493        params_json = json.dumps(params, sort_keys=True)
494        state_json = json.dumps(state or {}, sort_keys=True)
495        key_data = f"{name}:{params_json}:{state_json}"
496        return f"component:{hashlib.md5(key_data.encode()).hexdigest()}"

Generate cache key. Override for custom keys.

def post( self, request: django.http.request.HttpRequest, name: str, **kwargs) -> django.http.response.JsonResponse:
498    def post(self, request: HttpRequest, name: str, **kwargs) -> JsonResponse:
499        """Check cache before processing. Event requests bypass the cache entirely."""
500        from django.core.cache import cache
501
502        # Parse request data — methods provided by ComponentView via MRO
503        data = self.parse_request_data(request)  # type: ignore[unresolved-attribute]  # cooperative mixin
504
505        # Event requests mutate state and must never be served from or stored
506        # in the cache. Skip all cache logic and go straight to the parent view.
507        if data.get("event"):
508            return super().post(request, name, **kwargs)  # type: ignore[unresolved-attribute]  # cooperative mixin
509
510        params = self.parse_params(data.get("params", "{}"))  # type: ignore[unresolved-attribute]  # cooperative mixin
511        state = self.parse_state(data.get("state"))  # type: ignore[unresolved-attribute]  # cooperative mixin
512
513        cache_key = self.get_cache_key(name, params, state)
514        cached_result = cache.get(cache_key)
515
516        if cached_result is not None:
517            return JsonResponse(cached_result)
518
519        # Process normally
520        response = super().post(request, name, **kwargs)  # type: ignore[unresolved-attribute]  # cooperative mixin
521
522        # Only cache successful responses
523        if response.status_code == 200:
524            cache.set(cache_key, json.loads(response.content), self.cache_timeout)
525
526        return response

Check cache before processing. Event requests bypass the cache entirely.