v0.151.1
 1from __future__ import annotations
 2
 3from collections.abc import Callable
 4from http import HTTPMethod
 5from typing import Any
 6
 7from plain.http import Response
 8from plain.templates.views import TemplateView
 9from plain.utils.cache import patch_vary_headers
10
11from .templates import render_template_fragment
12
13__all__ = ["HTMXView"]
14
15
16class HTMXView(TemplateView):
17    """View with HTMX-specific functionality.
18
19    Action handlers (`htmx_post_<action>` etc.) may return `None` to mean
20    "re-render the current template (or active fragment)". Return an
21    explicit `Response` only when the action diverges from a re-render —
22    e.g. a redirect, a 204, or a custom payload.
23    """
24
25    def render(self, **context: Any) -> Response:
26        """Render the active fragment on a fragment request, the full template otherwise."""
27        if self.is_htmx_request() and (fragment_name := self.get_htmx_fragment_name()):
28            return Response(
29                render_template_fragment(
30                    template=self.get_template()._jinja_template,
31                    fragment_name=fragment_name,
32                    context={**self.get_template_context(), **context},
33                )
34            )
35
36        return super().render(**context)
37
38    def convert_result_to_response(self, result: Response | None) -> Response:
39        if result is None:
40            return self.render()
41        if isinstance(result, Response):
42            return result
43        raise TypeError(
44            f"{type(self).__name__} action handlers must return a Response or None "
45            f"(got {type(result).__name__}). "
46            "Return None to re-render the current template/fragment, or a Response "
47            "to diverge (redirect, 204, custom payload)."
48        )
49
50    def after_response(self, response: Response) -> Response:
51        response = super().after_response(response)
52        # Tell browser caching to also consider the fragment header,
53        # not just the url/cookie.
54        patch_vary_headers(
55            response, ["HX-Request", "Plain-HX-Fragment", "Plain-HX-Action"]
56        )
57        return response
58
59    def get_request_handler(self) -> Callable[[], Any] | None:
60        if (
61            self.is_htmx_request()
62            and self.request.method
63            and self.request.method in HTTPMethod.__members__
64        ):
65            # You can use an htmx_{method} method on views
66            # (or htmx_{method}_{action} for specific actions)
67            method = f"htmx_{self.request.method.lower()}"
68
69            if action := self.get_htmx_action_name():
70                # Action must be a plain identifier to be a valid attribute name
71                if not action.isidentifier():
72                    return None
73                return getattr(self, f"{method}_{action}", None)
74
75            if handler := getattr(self, method, None):
76                # If it's just an htmx post, for example,
77                # we can use a custom method or we can let it fall back
78                # to a regular post method if it's not found
79                return handler
80
81        return super().get_request_handler()
82
83    def is_htmx_request(self) -> bool:
84        return self.request.headers.get("HX-Request") == "true"
85
86    def get_htmx_fragment_name(self) -> str:
87        # A custom header that we pass with the {% htmxfragment %} tag
88        return self.request.headers.get("Plain-HX-Fragment", "")
89
90    def get_htmx_action_name(self) -> str:
91        return self.request.headers.get("Plain-HX-Action", "")