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 = ""