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]