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