plain.admin 0.14.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. plain/admin/README.md +260 -0
  2. plain/admin/__init__.py +5 -0
  3. plain/admin/assets/admin/admin.css +108 -0
  4. plain/admin/assets/admin/admin.js +79 -0
  5. plain/admin/assets/admin/chart.js +19 -0
  6. plain/admin/assets/admin/jquery-3.6.1.slim.min.js +2 -0
  7. plain/admin/assets/admin/list.js +57 -0
  8. plain/admin/assets/admin/popper.min.js +5 -0
  9. plain/admin/assets/admin/tippy-bundle.umd.min.js +1 -0
  10. plain/admin/assets/toolbar/toolbar.js +51 -0
  11. plain/admin/cards/__init__.py +10 -0
  12. plain/admin/cards/base.py +86 -0
  13. plain/admin/cards/charts.py +153 -0
  14. plain/admin/cards/tables.py +26 -0
  15. plain/admin/config.py +21 -0
  16. plain/admin/dates.py +254 -0
  17. plain/admin/default_settings.py +4 -0
  18. plain/admin/impersonate/README.md +44 -0
  19. plain/admin/impersonate/__init__.py +3 -0
  20. plain/admin/impersonate/middleware.py +38 -0
  21. plain/admin/impersonate/models.py +0 -0
  22. plain/admin/impersonate/permissions.py +16 -0
  23. plain/admin/impersonate/settings.py +8 -0
  24. plain/admin/impersonate/urls.py +10 -0
  25. plain/admin/impersonate/views.py +23 -0
  26. plain/admin/middleware.py +12 -0
  27. plain/admin/querystats/README.md +191 -0
  28. plain/admin/querystats/__init__.py +3 -0
  29. plain/admin/querystats/core.py +153 -0
  30. plain/admin/querystats/middleware.py +99 -0
  31. plain/admin/querystats/urls.py +9 -0
  32. plain/admin/querystats/views.py +27 -0
  33. plain/admin/templates/admin/base.html +160 -0
  34. plain/admin/templates/admin/cards/base.html +30 -0
  35. plain/admin/templates/admin/cards/card.html +17 -0
  36. plain/admin/templates/admin/cards/chart.html +25 -0
  37. plain/admin/templates/admin/cards/table.html +35 -0
  38. plain/admin/templates/admin/delete.html +17 -0
  39. plain/admin/templates/admin/detail.html +24 -0
  40. plain/admin/templates/admin/form.html +13 -0
  41. plain/admin/templates/admin/index.html +5 -0
  42. plain/admin/templates/admin/list.html +194 -0
  43. plain/admin/templates/admin/page.html +3 -0
  44. plain/admin/templates/admin/search.html +27 -0
  45. plain/admin/templates/admin/values/UUID.html +1 -0
  46. plain/admin/templates/admin/values/bool.html +9 -0
  47. plain/admin/templates/admin/values/datetime.html +1 -0
  48. plain/admin/templates/admin/values/default.html +5 -0
  49. plain/admin/templates/admin/values/dict.html +1 -0
  50. plain/admin/templates/admin/values/get_display.html +1 -0
  51. plain/admin/templates/admin/values/img.html +4 -0
  52. plain/admin/templates/admin/values/list.html +1 -0
  53. plain/admin/templates/admin/values/model.html +15 -0
  54. plain/admin/templates/admin/values/queryset.html +7 -0
  55. plain/admin/templates/elements/admin/Checkbox.html +8 -0
  56. plain/admin/templates/elements/admin/CheckboxField.html +7 -0
  57. plain/admin/templates/elements/admin/FieldErrors.html +5 -0
  58. plain/admin/templates/elements/admin/Input.html +9 -0
  59. plain/admin/templates/elements/admin/InputField.html +5 -0
  60. plain/admin/templates/elements/admin/Label.html +3 -0
  61. plain/admin/templates/elements/admin/Select.html +11 -0
  62. plain/admin/templates/elements/admin/SelectField.html +5 -0
  63. plain/admin/templates/elements/admin/Submit.html +6 -0
  64. plain/admin/templates/querystats/querystats.html +78 -0
  65. plain/admin/templates/querystats/toolbar.html +79 -0
  66. plain/admin/templates/toolbar/toolbar.html +91 -0
  67. plain/admin/templates.py +25 -0
  68. plain/admin/toolbar.py +36 -0
  69. plain/admin/urls.py +45 -0
  70. plain/admin/views/__init__.py +41 -0
  71. plain/admin/views/base.py +140 -0
  72. plain/admin/views/models.py +254 -0
  73. plain/admin/views/objects.py +399 -0
  74. plain/admin/views/registry.py +117 -0
  75. plain/admin/views/types.py +6 -0
  76. plain/admin/views/viewsets.py +54 -0
  77. plain_admin-0.14.1.dist-info/METADATA +275 -0
  78. plain_admin-0.14.1.dist-info/RECORD +80 -0
  79. plain_admin-0.14.1.dist-info/WHEEL +4 -0
  80. plain_admin-0.14.1.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,254 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from plain import models
4
+ from plain.models import Manager, Q
5
+
6
+ from .objects import (
7
+ AdminCreateView,
8
+ AdminDeleteView,
9
+ AdminDetailView,
10
+ AdminListView,
11
+ AdminUpdateView,
12
+ )
13
+
14
+ if TYPE_CHECKING:
15
+ from plain import models
16
+
17
+
18
+ def get_model_field(instance, field):
19
+ if "__" in field:
20
+ # Allow __ syntax like querysets use,
21
+ # also automatically calling callables (like __date)
22
+ result = instance
23
+ for part in field.split("__"):
24
+ result = getattr(result, part)
25
+
26
+ # If we hit a None, just return it
27
+ if not result:
28
+ return result
29
+
30
+ if callable(result):
31
+ result = result()
32
+
33
+ return result
34
+
35
+ attr = getattr(instance, field)
36
+
37
+ if isinstance(attr, Manager):
38
+ # Automatically get .all() of related managers
39
+ return attr.all()
40
+
41
+ return attr
42
+
43
+
44
+ class AdminModelListView(AdminListView):
45
+ show_search = True
46
+ allow_global_search = True
47
+
48
+ model: "models.Model"
49
+
50
+ fields: list = ["pk"]
51
+ queryset_order = []
52
+ search_fields: list = ["pk"]
53
+
54
+ def get_title(self) -> str:
55
+ if title := super().get_title():
56
+ return title
57
+
58
+ return self.model._meta.model_name.capitalize() + "s"
59
+
60
+ @classmethod
61
+ def get_nav_title(cls) -> str:
62
+ if cls.nav_title:
63
+ return cls.nav_title
64
+
65
+ if cls.title:
66
+ return cls.title
67
+
68
+ return cls.model._meta.model_name.capitalize() + "s"
69
+
70
+ @classmethod
71
+ def get_slug(cls) -> str:
72
+ return cls.model._meta.model_name
73
+
74
+ def get_template_context(self):
75
+ context = super().get_template_context()
76
+
77
+ order_by = self.request.GET.get("order_by", "")
78
+ if order_by.startswith("-"):
79
+ order_by_field = order_by[1:]
80
+ order_by_direction = "-"
81
+ else:
82
+ order_by_field = order_by
83
+ order_by_direction = ""
84
+
85
+ context["order_by_field"] = order_by_field
86
+ context["order_by_direction"] = order_by_direction
87
+
88
+ return context
89
+
90
+ def get_objects(self):
91
+ queryset = self.get_initial_queryset()
92
+ queryset = self.order_queryset(queryset)
93
+ queryset = self.search_queryset(queryset)
94
+ return queryset
95
+
96
+ def get_initial_queryset(self):
97
+ # Separate override for the initial queryset
98
+ # so that annotations can be added BEFORE order_by, etc.
99
+ return self.model.objects.all()
100
+
101
+ def order_queryset(self, queryset):
102
+ if order_by := self.request.GET.get("order_by"):
103
+ queryset = queryset.order_by(order_by)
104
+ elif self.queryset_order:
105
+ queryset = queryset.order_by(*self.queryset_order)
106
+
107
+ return queryset
108
+
109
+ def search_queryset(self, queryset):
110
+ if search := self.request.GET.get("search"):
111
+ filters = Q()
112
+ for field in self.search_fields:
113
+ filters |= Q(**{f"{field}__icontains": search})
114
+
115
+ queryset = queryset.filter(filters)
116
+
117
+ return queryset
118
+
119
+ def get_field_value(self, obj, field: str):
120
+ try:
121
+ return super().get_field_value(obj, field)
122
+ except (AttributeError, TypeError):
123
+ return get_model_field(obj, field)
124
+
125
+ def get_field_value_template(self, obj, field: str, value):
126
+ templates = super().get_field_value_template(obj, field, value)
127
+ if hasattr(obj, f"get_{field}_display"):
128
+ # Insert before the last default template,
129
+ # so it can still be overriden by the user
130
+ templates.insert(-1, "admin/values/get_display.html")
131
+ return templates
132
+
133
+
134
+ class AdminModelDetailView(AdminDetailView):
135
+ model: "models.Model"
136
+
137
+ def get_title(self) -> str:
138
+ return str(self.object)
139
+
140
+ @classmethod
141
+ def get_slug(cls) -> str:
142
+ return f"{cls.model._meta.model_name}_detail"
143
+
144
+ @classmethod
145
+ def get_path(cls) -> str:
146
+ return f"{cls.model._meta.model_name}/<int:pk>/"
147
+
148
+ def get_fields(self):
149
+ if fields := super().get_fields():
150
+ return fields
151
+
152
+ return ["pk"] + [f.name for f in self.object._meta.get_fields() if f.concrete]
153
+
154
+ def get_field_value(self, obj, field: str):
155
+ try:
156
+ return super().get_field_value(obj, field)
157
+ except (AttributeError, TypeError):
158
+ return get_model_field(obj, field)
159
+
160
+ def get_object(self):
161
+ return self.model.objects.get(pk=self.url_kwargs["pk"])
162
+
163
+ def get_template_names(self) -> list[str]:
164
+ template_names = super().get_template_names()
165
+
166
+ if not self.template_name and isinstance(self.object, models.Model):
167
+ object_meta = self.object._meta
168
+ template_names = [
169
+ f"admin/{object_meta.package_label}/{object_meta.model_name}{self.template_name_suffix}.html"
170
+ ] + template_names
171
+
172
+ return template_names
173
+
174
+
175
+ class AdminModelCreateView(AdminCreateView):
176
+ model: "models.Model"
177
+ form_class = None # TODO type annotation
178
+
179
+ def get_title(self) -> str:
180
+ if title := super().get_title():
181
+ return title
182
+
183
+ return f"New {self.model._meta.model_name}"
184
+
185
+ @classmethod
186
+ def get_slug(cls) -> str:
187
+ return f"{cls.model._meta.model_name}_create"
188
+
189
+ @classmethod
190
+ def get_path(cls) -> str:
191
+ return f"{cls.model._meta.model_name}/create/"
192
+
193
+ def get_template_names(self):
194
+ template_names = super().get_template_names()
195
+
196
+ if not self.template_name and issubclass(self.model, models.Model):
197
+ model_meta = self.model._meta
198
+ template_names = [
199
+ f"admin/{model_meta.package_label}/{model_meta.model_name}{self.template_name_suffix}.html"
200
+ ] + template_names
201
+
202
+ return template_names
203
+
204
+
205
+ class AdminModelUpdateView(AdminUpdateView):
206
+ model: "models.Model"
207
+ form_class = None # TODO type annotation
208
+ success_url = "." # Redirect back to the same update page by default
209
+
210
+ def get_title(self) -> str:
211
+ if title := super().get_title():
212
+ return title
213
+
214
+ return f"Update {self.object}"
215
+
216
+ @classmethod
217
+ def get_slug(cls) -> str:
218
+ return f"{cls.model._meta.model_name}_update"
219
+
220
+ @classmethod
221
+ def get_path(cls) -> str:
222
+ return f"{cls.model._meta.model_name}/<int:pk>/update/"
223
+
224
+ def get_object(self):
225
+ return self.model.objects.get(pk=self.url_kwargs["pk"])
226
+
227
+ def get_template_names(self):
228
+ template_names = super().get_template_names()
229
+
230
+ if not self.template_name and isinstance(self.object, models.Model):
231
+ object_meta = self.object._meta
232
+ template_names = [
233
+ f"admin/{object_meta.package_label}/{object_meta.model_name}{self.template_name_suffix}.html"
234
+ ] + template_names
235
+
236
+ return template_names
237
+
238
+
239
+ class AdminModelDeleteView(AdminDeleteView):
240
+ model: "models.Model"
241
+
242
+ def get_title(self) -> str:
243
+ return f"Delete {self.object}"
244
+
245
+ @classmethod
246
+ def get_slug(cls) -> str:
247
+ return f"{cls.model._meta.model_name}_delete"
248
+
249
+ @classmethod
250
+ def get_path(cls) -> str:
251
+ return f"{cls.model._meta.model_name}/<int:pk>/delete/"
252
+
253
+ def get_object(self):
254
+ return self.model.objects.get(pk=self.url_kwargs["pk"])
@@ -0,0 +1,399 @@
1
+ from plain.htmx.views import HTMXViewMixin
2
+ from plain.http import Response, ResponseRedirect
3
+ from plain.models import Model
4
+ from plain.paginator import Paginator
5
+ from plain.views import (
6
+ CreateView,
7
+ DeleteView,
8
+ DetailView,
9
+ UpdateView,
10
+ )
11
+
12
+ from .base import AdminView
13
+
14
+
15
+ class AdminListView(HTMXViewMixin, AdminView):
16
+ template_name = "admin/list.html"
17
+ fields: list[str]
18
+ actions: list[str] = []
19
+ displays: list[str] = []
20
+ page_size = 100
21
+ show_search = False
22
+ allow_global_search = False
23
+
24
+ def get_template_context(self):
25
+ context = super().get_template_context()
26
+
27
+ # Make this available on self for usage in get_objects and other methods
28
+ self.display = self.request.GET.get("display", "")
29
+
30
+ # Make this available to get_displays and stuff
31
+ self.objects = self.get_objects()
32
+
33
+ page_size = self.request.GET.get("page_size", self.page_size)
34
+ paginator = Paginator(self.objects, page_size)
35
+ self._page = paginator.get_page(self.request.GET.get("page", 1))
36
+
37
+ context["paginator"] = paginator
38
+ context["page"] = self._page
39
+ context["objects"] = self._page # alias
40
+ context["fields"] = self.get_fields()
41
+ context["actions"] = self.get_actions()
42
+ context["displays"] = self.get_displays()
43
+
44
+ context["current_display"] = self.display
45
+
46
+ # Implement search yourself in get_objects
47
+ context["search_query"] = self.request.GET.get("search", "")
48
+ context["show_search"] = self.show_search
49
+
50
+ context["table_style"] = getattr(self, "_table_style", "default")
51
+
52
+ context["get_object_pk"] = self.get_object_pk
53
+ context["get_field_value"] = self.get_field_value
54
+ context["get_field_value_template"] = self.get_field_value_template
55
+
56
+ context["get_object_url"] = self.get_object_url
57
+ context["get_object_links"] = self.get_object_links
58
+
59
+ return context
60
+
61
+ def get(self) -> Response:
62
+ if self.is_htmx_request:
63
+ hx_from_this_page = self.request.path in self.request.headers.get(
64
+ "HX-Current-Url", ""
65
+ )
66
+ if not hx_from_this_page:
67
+ self._table_style = "simple"
68
+ else:
69
+ hx_from_this_page = False
70
+
71
+ response = super().get()
72
+
73
+ if self.is_htmx_request and not hx_from_this_page and not self._page:
74
+ # Don't render anything
75
+ return Response(status=204)
76
+
77
+ return response
78
+
79
+ def post(self) -> Response:
80
+ # won't be "key" anymore, just list
81
+ action_name = self.request.POST.get("action_name")
82
+ actions = self.get_actions()
83
+ if action_name and action_name in actions:
84
+ target_pks = self.request.POST["action_pks"].split(",")
85
+ response = self.perform_action(action_name, target_pks)
86
+ if response:
87
+ return response
88
+ else:
89
+ # message in session first
90
+ return ResponseRedirect(".")
91
+
92
+ raise ValueError("Invalid action")
93
+
94
+ def perform_action(self, action: str, target_pks: list) -> Response | None:
95
+ raise NotImplementedError
96
+
97
+ def get_objects(self) -> list:
98
+ return []
99
+
100
+ def get_fields(self) -> list:
101
+ return (
102
+ self.fields.copy()
103
+ ) # Avoid mutating the class attribute if using append etc
104
+
105
+ def get_actions(self) -> dict[str]:
106
+ return self.actions.copy() # Avoid mutating the class attribute itself
107
+
108
+ def get_displays(self) -> list[str]:
109
+ return self.displays.copy() # Avoid mutating the class attribute itself
110
+
111
+ def get_field_value(self, obj, field: str):
112
+ try:
113
+ # Try basic dict lookup first
114
+ if field in obj:
115
+ return obj[field]
116
+ except TypeError:
117
+ pass
118
+
119
+ # Try dot notation
120
+ if "." in field:
121
+ field, subfield = field.split(".", 1)
122
+ return self.get_field_value(obj[field], subfield)
123
+
124
+ # Try regular object attribute
125
+ attr = getattr(obj, field)
126
+
127
+ # Call if it's callable
128
+ if callable(attr):
129
+ return attr()
130
+ else:
131
+ return attr
132
+
133
+ def get_object_pk(self, obj):
134
+ try:
135
+ return self.get_field_value(obj, "pk")
136
+ except AttributeError:
137
+ return self.get_field_value(obj, "id")
138
+
139
+ def get_field_value_template(self, obj, field: str, value):
140
+ type_str = type(value).__name__.lower()
141
+ return [
142
+ f"admin/values/{type_str}.html", # Create a template per-type
143
+ f"admin/values/{field}.html", # Or for specific field names
144
+ "admin/values/default.html",
145
+ ]
146
+
147
+ def get_list_url(self) -> str | None:
148
+ return None
149
+
150
+ def get_create_url(self) -> str | None:
151
+ return None
152
+
153
+ def get_detail_url(self, obj) -> str | None:
154
+ return None
155
+
156
+ def get_update_url(self, obj) -> str | None:
157
+ return None
158
+
159
+ def get_delete_url(self, obj) -> str | None:
160
+ return None
161
+
162
+ def get_object_url(self, obj) -> str | None:
163
+ if url := self.get_detail_url(obj):
164
+ return url
165
+ if url := self.get_update_url(obj):
166
+ return url
167
+ if url := self.get_delete_url(obj):
168
+ return url
169
+ return None
170
+
171
+ def get_object_links(self, obj) -> dict[str]:
172
+ links = {}
173
+ if self.get_detail_url(obj):
174
+ links["View"] = self.get_detail_url(obj)
175
+ if self.get_update_url(obj):
176
+ links["Edit"] = self.get_update_url(obj)
177
+ if self.get_delete_url(obj):
178
+ links["Delete"] = self.get_delete_url(obj)
179
+ return links
180
+
181
+ def get_links(self):
182
+ links = super().get_links()
183
+
184
+ # Not tied to a specific object
185
+ if create_url := self.get_create_url():
186
+ links["New"] = create_url
187
+
188
+ return links
189
+
190
+
191
+ class AdminCreateView(AdminView, CreateView):
192
+ template_name = None
193
+
194
+ def get_list_url(self) -> str | None:
195
+ return None
196
+
197
+ def get_create_url(self) -> str | None:
198
+ return None
199
+
200
+ def get_detail_url(self, obj) -> str | None:
201
+ return None
202
+
203
+ def get_update_url(self, obj) -> str | None:
204
+ return None
205
+
206
+ def get_delete_url(self, obj) -> str | None:
207
+ return None
208
+
209
+ def get_success_url(self, form):
210
+ if list_url := self.get_list_url():
211
+ return list_url
212
+
213
+ return super().get_success_url(form)
214
+
215
+
216
+ class AdminDetailView(AdminView, DetailView):
217
+ template_name = None
218
+ nav_section = ""
219
+ fields: list[str] = []
220
+
221
+ def get_template_context(self):
222
+ context = super().get_template_context()
223
+ context["get_field_value"] = self.get_field_value
224
+ context["get_field_value_template"] = self.get_field_value_template
225
+ context["fields"] = self.get_fields()
226
+ return context
227
+
228
+ def get_template_names(self) -> list[str]:
229
+ return super().get_template_names() + [
230
+ "admin/detail.html",
231
+ ]
232
+
233
+ def get_description(self):
234
+ return repr(self.object)
235
+
236
+ def get_field_value(self, obj, field: str):
237
+ try:
238
+ # Try basic dict lookup first
239
+ if field in obj:
240
+ return obj[field]
241
+ except TypeError:
242
+ pass
243
+
244
+ # Try dot notation
245
+ if "." in field:
246
+ field, subfield = field.split(".", 1)
247
+ return self.get_field_value(obj[field], subfield)
248
+
249
+ # Try regular object attribute
250
+ attr = getattr(obj, field)
251
+
252
+ # Call if it's callable
253
+ if callable(attr):
254
+ return attr()
255
+ else:
256
+ return attr
257
+
258
+ def get_field_value_template(self, obj, field: str, value):
259
+ templates = []
260
+
261
+ # By type name
262
+ type_str = type(value).__name__.lower()
263
+ templates.append(f"admin/values/{type_str}.html")
264
+
265
+ # By field name
266
+ templates.append(f"admin/values/{field}.html")
267
+
268
+ # As any model
269
+ if isinstance(value, Model):
270
+ templates.append("admin/values/model.html")
271
+
272
+ # Default
273
+ templates.append("admin/values/default.html")
274
+
275
+ return templates
276
+
277
+ def get_list_url(self) -> str | None:
278
+ return None
279
+
280
+ def get_create_url(self) -> str | None:
281
+ return None
282
+
283
+ def get_detail_url(self, obj) -> str | None:
284
+ return None
285
+
286
+ def get_update_url(self, obj) -> str | None:
287
+ return None
288
+
289
+ def get_delete_url(self, obj) -> str | None:
290
+ return None
291
+
292
+ def get_fields(self):
293
+ return self.fields.copy() # Avoid mutating the class attribute itself
294
+
295
+ def get_links(self):
296
+ links = super().get_links()
297
+
298
+ if hasattr(self.object, "get_absolute_url"):
299
+ links["View in app"] = self.object.get_absolute_url()
300
+
301
+ if update_url := self.get_update_url(self.object):
302
+ links["Edit"] = update_url
303
+
304
+ if delete_url := self.get_delete_url(self.object):
305
+ links["Delete"] = delete_url
306
+
307
+ return links
308
+
309
+ def get_success_url(self, form):
310
+ if detail_url := self.get_detail_url(self.object):
311
+ return detail_url
312
+
313
+ if list_url := self.get_list_url():
314
+ return list_url
315
+
316
+ if update_url := self.get_update_url(self.object):
317
+ return update_url
318
+
319
+ return super().get_success_url(form)
320
+
321
+
322
+ class AdminUpdateView(AdminView, UpdateView):
323
+ template_name = None
324
+ nav_section = ""
325
+
326
+ def get_list_url(self) -> str | None:
327
+ return None
328
+
329
+ def get_create_url(self) -> str | None:
330
+ return None
331
+
332
+ def get_detail_url(self, obj) -> str | None:
333
+ return None
334
+
335
+ def get_update_url(self, obj) -> str | None:
336
+ return None
337
+
338
+ def get_delete_url(self, obj) -> str | None:
339
+ return None
340
+
341
+ def get_description(self):
342
+ return repr(self.object)
343
+
344
+ def get_links(self):
345
+ links = super().get_links()
346
+
347
+ if hasattr(self.object, "get_absolute_url"):
348
+ links["View in app"] = self.object.get_absolute_url()
349
+
350
+ if detail_url := self.get_detail_url(self.object):
351
+ links["View"] = detail_url
352
+
353
+ if delete_url := self.get_delete_url(self.object):
354
+ links["Delete"] = delete_url
355
+
356
+ return links
357
+
358
+
359
+ class AdminDeleteView(AdminView, DeleteView):
360
+ template_name = "admin/delete.html"
361
+ nav_section = ""
362
+
363
+ def get_description(self):
364
+ return repr(self.object)
365
+
366
+ def get_list_url(self) -> str | None:
367
+ return None
368
+
369
+ def get_create_url(self) -> str | None:
370
+ return None
371
+
372
+ def get_detail_url(self, obj) -> str | None:
373
+ return None
374
+
375
+ def get_update_url(self, obj) -> str | None:
376
+ return None
377
+
378
+ def get_delete_url(self, obj) -> str | None:
379
+ return None
380
+
381
+ def get_links(self):
382
+ links = super().get_links()
383
+
384
+ if hasattr(self.object, "get_absolute_url"):
385
+ links["View in app"] = self.object.get_absolute_url()
386
+
387
+ if detail_url := self.get_detail_url(self.object):
388
+ links["View"] = detail_url
389
+
390
+ if update_url := self.get_update_url(self.object):
391
+ links["Edit"] = update_url
392
+
393
+ return links
394
+
395
+ def get_success_url(self, form):
396
+ if list_url := self.get_list_url():
397
+ return list_url
398
+
399
+ return super().get_success_url(form)