v0.152.0
  1from __future__ import annotations
  2
  3from abc import ABC, abstractmethod
  4from collections.abc import Callable
  5from functools import cached_property
  6from typing import Any, NoReturn
  7
  8from plain.exceptions import ImproperlyConfigured
  9from plain.forms import BaseForm, Form
 10from plain.http import HTTPException, NotFoundError404, RedirectResponse, Response
 11from plain.logs import get_framework_logger
 12from plain.runtime import settings
 13from plain.views import View
 14
 15from .core import Template, TemplateFileMissing
 16
 17logger = get_framework_logger("plain.templates")
 18
 19try:
 20    from plain.postgres.exceptions import ObjectDoesNotExist
 21except ImportError:
 22    ObjectDoesNotExist = None  # ty: ignore[invalid-assignment]
 23
 24
 25class TemplateView(View):
 26    """
 27    Render a template.
 28    """
 29
 30    template_name: str | None = None
 31
 32    def get_template_context(self) -> dict[str, Any]:
 33        return {
 34            "request": self.request,
 35            "template_names": self.get_template_names(),
 36            "DEBUG": settings.DEBUG,
 37        }
 38
 39    def get_template_names(self) -> list[str]:
 40        """
 41        Return a list of template names to be used for the request.
 42        """
 43        if self.template_name:
 44            return [self.template_name]
 45
 46        return []
 47
 48    def get_template(self) -> Template:
 49        template_names = self.get_template_names()
 50
 51        if isinstance(template_names, str):
 52            raise ImproperlyConfigured(
 53                f"{self.__class__.__name__}.get_template_names() must return a list of strings, "
 54                f"not a string. Did you mean to return ['{template_names}']?"
 55            )
 56
 57        if not template_names:
 58            raise ImproperlyConfigured(
 59                f"{self.__class__.__name__} requires a template_name or get_template_names()."
 60            )
 61
 62        for template_name in template_names:
 63            try:
 64                return Template(template_name)
 65            except TemplateFileMissing:
 66                pass
 67
 68        raise TemplateFileMissing(template_names)
 69
 70    def render(self, **context: Any) -> Response:
 71        """Render the template to a `Response`, layering `context` over `get_template_context()`.
 72
 73        A handler passes what the template needs straight in —
 74        `self.render(form=form)` — rather than stashing it on `self` for
 75        `get_template_context()` to read back. Called with no arguments it
 76        renders `get_template_context()` as-is, which is what `get()` does.
 77        """
 78        return Response(
 79            self.get_template().render({**self.get_template_context(), **context})
 80        )
 81
 82    def get(self) -> Response:
 83        return self.render()
 84
 85    def handle_exception(self, exc: Exception) -> Response:
 86        """Render `{status}.html` for the exception, falling through on missing template."""
 87        status = exc.status_code if isinstance(exc, HTTPException) else 500
 88        try:
 89            body = Template(f"{status}.html").render(
 90                {
 91                    "request": self.request,
 92                    "status_code": status,
 93                    "exception": exc,
 94                    "DEBUG": settings.DEBUG,
 95                }
 96            )
 97            return Response(body, status_code=status)
 98        except TemplateFileMissing:
 99            # Defer to the framework default for plain-text rendering.
100            # `from None` keeps observability tools from seeing
101            # `TemplateFileMissing` as the suppressed cause of `exc`.
102            raise exc from None
103        except Exception as render_exc:
104            if settings.DEBUG:
105                raise
106            logger.error(
107                "Error template render failed",
108                extra={
109                    "path": self.request.path,
110                    "status_code": status,
111                    "request": self.request,
112                },
113                exc_info=render_exc,
114            )
115            return Response(status_code=status)
116
117
118class NotFoundView(TemplateView):
119    """Catchall view: raises 404 before method dispatch, renders `404.html`."""
120
121    def before_request(self) -> NoReturn:
122        raise NotFoundError404
123
124
125class FormView[F: "BaseForm"](TemplateView):
126    """A view for displaying a form and rendering a template response.
127
128    Generic over the form type. Subclasses that want type-safe access to
129    their specific form should parameterize: `FormView[MyForm]`. The
130    `form_class` attribute must still be set separately at runtime.
131    """
132
133    form_class: type[F] | None = None
134    success_url: Callable | str | None = None
135
136    def get_form(self) -> F:
137        """Return an instance of the form to be used in this view."""
138        if not self.form_class:
139            raise ImproperlyConfigured(
140                f"No form class provided. Define {self.__class__.__name__}.form_class or override "
141                f"{self.__class__.__name__}.get_form()."
142            )
143        return self.form_class(**self.get_form_kwargs())
144
145    def get_form_kwargs(self) -> dict[str, Any]:
146        """Return the keyword arguments for instantiating the form."""
147        return {
148            "initial": {},
149            "request": self.request,
150        }
151
152    def get_success_url(self, form: F) -> str:
153        """Return the URL to redirect to after processing a valid form."""
154        if not self.success_url:
155            raise ImproperlyConfigured("No URL to redirect to. Provide a success_url.")
156        return str(self.success_url)  # success_url may be lazy
157
158    def form_valid(self, form: F) -> Response:
159        """If the form is valid, redirect to the supplied URL."""
160        return RedirectResponse(self.get_success_url(form))
161
162    def get_template_context(self) -> dict[str, Any]:
163        """Insert the form into the context dict."""
164        context = super().get_template_context()
165        context["form"] = self.get_form()
166        return context
167
168    def post(self) -> Response:
169        """Hand a valid form to `form_valid`; re-render an invalid one."""
170        form = self.get_form()
171        if form.is_valid():
172            return self.form_valid(form)
173        return self.render(form=form)
174
175
176class CreateView(FormView):
177    """
178    View for creating a new object, with a response rendered by a template.
179    """
180
181    def get_success_url(self, form: BaseForm) -> str:
182        """Return the URL to redirect to after processing a valid form."""
183        if self.success_url:
184            url = str(self.success_url).format(**self.object.__dict__)
185        else:
186            try:
187                url = self.object.get_absolute_url()
188            except AttributeError:
189                raise ImproperlyConfigured(
190                    "No URL to redirect to.  Either provide a url or define"
191                    " a get_absolute_url method on the Model."
192                )
193        return url
194
195    def form_valid(self, form: BaseForm) -> Response:
196        """If the form is valid, create the associated model."""
197        self.object = form.create()  # ty: ignore[unresolved-attribute]
198        return super().form_valid(form)
199
200
201class DetailView(TemplateView, ABC):
202    """
203    Render a "detail" view of an object.
204
205    By default this is a model instance looked up from `self.queryset`, but the
206    view will support display of *any* object by overriding `self.get_object()`.
207    """
208
209    context_object_name = ""
210
211    @cached_property
212    def object(self) -> Any:
213        try:
214            obj = self.get_object()
215        except Exception as e:
216            # If ObjectDoesNotExist is available and this is that exception, raise 404
217            if ObjectDoesNotExist and isinstance(e, ObjectDoesNotExist):
218                raise NotFoundError404
219            # Otherwise, let other exceptions bubble up
220            raise
221
222        # Also raise 404 if get_object() returns None
223        if not obj:
224            raise NotFoundError404
225
226        return obj
227
228    @abstractmethod
229    def get_object(self) -> Any: ...
230
231    def get_template_context(self) -> dict[str, Any]:
232        """Insert the single object into the context dict."""
233        context = super().get_template_context()
234        context["object"] = (
235            self.object
236        )  # Some templates can benefit by always knowing a primary "object" can be present
237        if self.context_object_name:
238            context[self.context_object_name] = self.object
239        return context
240
241
242class UpdateView(DetailView, FormView):
243    """View for updating an object, with a response rendered by a template."""
244
245    def get_success_url(self, form: BaseForm) -> str:
246        """Return the URL to redirect to after processing a valid form."""
247        if self.success_url:
248            url = str(self.success_url).format(**self.object.__dict__)
249        else:
250            try:
251                url = self.object.get_absolute_url()
252            except AttributeError:
253                raise ImproperlyConfigured(
254                    "No URL to redirect to.  Either provide a url or define"
255                    " a get_absolute_url method on the Model."
256                )
257        return url
258
259    def form_valid(self, form: BaseForm) -> Response:
260        """If the form is valid, update the associated model."""
261        self.object = form.update()  # ty: ignore[unresolved-attribute]
262        return super().form_valid(form)
263
264    def get_form_kwargs(self) -> dict[str, Any]:
265        """Return the keyword arguments for instantiating the form."""
266        kwargs = super().get_form_kwargs()
267        kwargs.update({"instance": self.object})
268        return kwargs
269
270
271class DeleteView(DetailView, FormView):
272    """
273    View for deleting an object retrieved with self.get_object(), with a
274    response rendered by a template.
275    """
276
277    # An empty confirmation form -- deletion is the view's job, not the
278    # form's, so it carries no fields and no model write.
279    class EmptyDeleteForm(Form):
280        pass
281
282    form_class = EmptyDeleteForm
283
284    def form_valid(self, form: BaseForm) -> Response:
285        """If the confirmation form is valid, delete the object."""
286        self.object.delete()
287        return super().form_valid(form)
288
289
290class ListView(TemplateView, ABC):
291    """
292    Render some list of objects, set by `self.get_queryset()`, with a response
293    rendered by a template.
294    """
295
296    context_object_name = ""
297
298    @cached_property
299    def objects(self) -> Any:
300        return self.get_objects()
301
302    @abstractmethod
303    def get_objects(self) -> Any: ...
304
305    def get_template_context(self) -> dict[str, Any]:
306        """Insert the single object into the context dict."""
307        context = super().get_template_context()
308        context["objects"] = self.objects
309        if self.context_object_name:
310            context[self.context_object_name] = self.objects
311        return context
312
313
314__all__ = [
315    "TemplateView",
316    "NotFoundView",
317    "FormView",
318    "CreateView",
319    "UpdateView",
320    "DeleteView",
321    "DetailView",
322    "ListView",
323]