v0.143.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
  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]