Plain is headed towards 1.0! Subscribe for development updates →

  1from typing import TYPE_CHECKING
  2
  3from plain import models
  4from plain.auth.views import AuthViewMixin
  5from plain.htmx.views import HTMXViewMixin
  6from plain.http import Response, ResponseRedirect
  7from plain.paginator import Paginator
  8from plain.staff.dates import DatetimeRange, DatetimeRangeAliases
  9from plain.urls import reverse
 10from plain.utils import timezone
 11from plain.utils.text import slugify
 12from plain.views import (
 13    CreateView,
 14    DeleteView,
 15    DetailView,
 16    TemplateView,
 17    UpdateView,
 18)
 19
 20from .registry import registry
 21
 22if TYPE_CHECKING:
 23    from ..cards import Card
 24
 25
 26URL_NAMESPACE = "staff"
 27
 28
 29class StaffView(AuthViewMixin, TemplateView):
 30    staff_required = True
 31
 32    title: str
 33    slug: str = ""
 34    path: str = ""
 35    description: str = ""
 36
 37    # Leave empty to hide from nav
 38    #
 39    # An explicit disabling of showing this url/page in the nav
 40    # which importantly effects the (future) recent pages list
 41    # so you can also use this for pages that can never be bookmarked
 42    nav_section = "App"
 43
 44    links: dict[str] = {}
 45
 46    parent_view_class: "StaffView" = None
 47
 48    template_name = "staff/page.html"
 49    cards: list["Card"] = []
 50
 51    default_datetime_range = DatetimeRangeAliases.LAST_365_DAYS
 52
 53    def get_template_context(self):
 54        context = super().get_template_context()
 55        context["title"] = self.get_title()
 56        context["slug"] = self.get_slug()
 57        context["description"] = self.get_description()
 58        context["links"] = self.get_links()
 59        context["parent_view_classes"] = self.get_parent_view_classes()
 60        context["admin_registry"] = registry
 61        context["cards"] = self.get_cards()
 62        context["render_card"] = self.render_card
 63        context["from_datetime"] = self.datetime_range.start
 64        context["to_datetime"] = self.datetime_range.end
 65        context["time_zone"] = timezone.get_current_timezone_name()
 66        return context
 67
 68    def get_response(self):
 69        default_range = DatetimeRangeAliases.to_range(self.default_datetime_range)
 70        from_datetime = self.request.GET.get("from", default_range.start)
 71        to_datetime = self.request.GET.get("to", default_range.end)
 72        self.datetime_range = DatetimeRange(from_datetime, to_datetime)
 73        return super().get_response()
 74
 75    @classmethod
 76    def view_name(cls) -> str:
 77        return f"view_{cls.get_slug()}"
 78
 79    @classmethod
 80    def get_title(cls) -> str:
 81        return cls.title
 82
 83    @classmethod
 84    def get_slug(cls) -> str:
 85        return cls.slug or slugify(cls.get_title())
 86
 87    @classmethod
 88    def get_description(cls) -> str:
 89        return cls.description
 90
 91    @classmethod
 92    def get_path(cls) -> str:
 93        return cls.path or cls.get_slug()
 94
 95    @classmethod
 96    def get_parent_view_classes(cls) -> list["StaffView"]:
 97        parents = []
 98        parent = cls.parent_view_class
 99        while parent:
100            parents.append(parent)
101            parent = parent.parent_view_class
102        return parents
103
104    @classmethod
105    def get_nav_section(cls) -> bool:
106        if not cls.nav_section:
107            return ""
108
109        if cls.parent_view_class:
110            # Don't show child views by default
111            return ""
112
113        return cls.nav_section
114
115    @classmethod
116    def get_absolute_url(cls) -> str:
117        return reverse(f"{URL_NAMESPACE}:" + cls.view_name())
118
119    def get_links(self) -> dict[str]:
120        return self.links.copy()
121
122    def get_cards(self):
123        return self.cards.copy()
124
125    def render_card(self, card: "Card"):
126        """Render card as a subview"""
127        # response = card.as_view()(self.request)
128        # response.render()
129        # content = response.content.decode()
130        return card().render(self, self.request, self.datetime_range)
131
132
133class StaffListView(HTMXViewMixin, StaffView):
134    template_name = "staff/list.html"
135    fields: list[str]
136    actions: list[str] = []
137    filters: list[str] = []
138    page_size = 100
139    show_search = False
140    allow_global_search = False
141
142    def get_template_context(self):
143        context = super().get_template_context()
144
145        # Make this available on self for usage in get_objects and other methods
146        self.filter = self.request.GET.get("filter", "")
147
148        # Make this available to get_filters and stuff
149        self.objects = self.get_objects()
150
151        page_size = self.request.GET.get("page_size", self.page_size)
152        paginator = Paginator(self.objects, page_size)
153        self._page = paginator.get_page(self.request.GET.get("page", 1))
154
155        context["paginator"] = paginator
156        context["page"] = self._page
157        context["objects"] = self._page  # alias
158        context["fields"] = self.get_fields()
159        context["actions"] = self.get_actions()
160        context["filters"] = self.get_filters()
161
162        context["active_filter"] = self.filter
163
164        # Implement search yourself in get_objects
165        context["search_query"] = self.request.GET.get("search", "")
166        context["show_search"] = self.show_search
167
168        context["table_style"] = getattr(self, "_table_style", "default")
169
170        context["get_object_pk"] = self.get_object_pk
171        context["get_field_value"] = self.get_field_value
172        context["get_field_value_template"] = self.get_field_value_template
173
174        context["get_create_url"] = self.get_create_url
175        context["get_object_links"] = self.get_object_links
176
177        return context
178
179    def get(self) -> Response:
180        if self.is_htmx_request:
181            hx_from_this_page = self.request.path in self.request.headers.get(
182                "HX-Current-Url", ""
183            )
184            if not hx_from_this_page:
185                self._table_style = "simple"
186        else:
187            hx_from_this_page = False
188
189        response = super().get()
190
191        if self.is_htmx_request and not hx_from_this_page and not self._page:
192            # Don't render anything
193            return Response(status=204)
194
195        return response
196
197    def post(self) -> Response:
198        # won't be "key" anymore, just list
199        action_name = self.request.POST.get("action_name")
200        actions = self.get_actions()
201        if action_name and action_name in actions:
202            target_pks = self.request.POST["action_pks"].split(",")
203            response = self.perform_action(action_name, target_pks)
204            if response:
205                return response
206            else:
207                # message in session first
208                return ResponseRedirect(".")
209
210        raise ValueError("Invalid action")
211
212    def perform_action(self, action: str, target_pks: list) -> Response | None:
213        raise NotImplementedError
214
215    def get_objects(self) -> list:
216        return []
217
218    def get_fields(self) -> list:
219        return (
220            self.fields.copy()
221        )  # Avoid mutating the class attribute if using append etc
222
223    def get_actions(self) -> dict[str]:
224        return self.actions.copy()  # Avoid mutating the class attribute itself
225
226    def get_filters(self) -> list[str]:
227        return self.filters.copy()  # Avoid mutating the class attribute itself
228
229    def get_field_value(self, obj, field: str):
230        # Try basic dict lookup first
231        if field in obj:
232            return obj[field]
233
234        # Try dot notation
235        if "." in field:
236            field, subfield = field.split(".", 1)
237            return self.get_field_value(obj[field], subfield)
238
239        # Try regular object attribute
240        return getattr(obj, field)
241
242    def get_object_pk(self, obj):
243        try:
244            return self.get_field_value(obj, "pk")
245        except AttributeError:
246            return self.get_field_value(obj, "id")
247
248    def get_field_value_template(self, obj, field: str, value):
249        type_str = type(value).__name__.lower()
250        return [
251            f"staff/values/{type_str}.html",  # Create a template per-type
252            f"staff/values/{field}.html",  # Or for specific field names
253            "staff/values/default.html",
254        ]
255
256    def get_create_url(self) -> str | None:
257        return None
258
259    def get_detail_url(self, obj) -> str | None:
260        return None
261
262    def get_update_url(self, obj) -> str | None:
263        return None
264
265    def get_object_links(self, obj) -> dict[str]:
266        links = {}
267        if self.get_detail_url(obj):
268            links["Detail"] = self.get_detail_url(obj)
269        if self.get_update_url(obj):
270            links["Update"] = self.get_update_url(obj)
271        return links
272
273
274class StaffDetailView(StaffView, DetailView):
275    template_name = None
276    nav_section = ""
277
278    def get_template_context(self):
279        context = super().get_template_context()
280        context["get_field_value"] = self.get_field_value
281        return context
282
283    def get_template_names(self) -> list[str]:
284        # TODO move these to model views
285        if not self.template_name and isinstance(self.object, models.Model):
286            object_meta = self.object._meta
287            return [
288                f"staff/{object_meta.package_label}/{object_meta.model_name}{self.template_name_suffix}.html"
289            ]
290
291        return super().get_template_names()
292
293    def get_field_value(self, obj, field: str):
294        return getattr(obj, field)
295
296    def get_update_url(self, obj) -> str | None:
297        return None
298
299
300class StaffUpdateView(StaffView, UpdateView):
301    template_name = None
302    nav_section = ""
303
304    def get_template_names(self) -> list[str]:
305        if not self.template_name and isinstance(self.object, models.Model):
306            object_meta = self.object._meta
307            return [
308                f"staff/{object_meta.package_label}/{object_meta.model_name}{self.template_name_suffix}.html"
309            ]
310
311        return super().get_template_names()
312
313    def get_detail_url(self, obj) -> str | None:
314        return None
315
316
317class StaffCreateView(StaffView, CreateView):
318    template_name = None
319
320    def get_template_names(self) -> list[str]:
321        if not self.template_name and isinstance(self.object, models.Model):
322            object_meta = self.object._meta
323            return [
324                f"staff/{object_meta.package_label}/{object_meta.model_name}{self.template_name_suffix}.html"
325            ]
326
327        return super().get_template_names()
328
329
330class StaffDeleteView(StaffView, DeleteView):
331    template_name = "staff/confirm_delete.html"
332    nav_section = ""