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