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,79 @@
1
+ <form
2
+ data-querystats
3
+ action="."
4
+ method="get"
5
+ target="querystats"
6
+ class="relative group/querystats"
7
+ style="display: none;">
8
+ <input type="hidden" name="querystats" value="store">
9
+ <button type="submit" class="px-2 py-px text-xs rounded-full bg-stone-700 text-stone-300 whitespace-nowrap" data-querystats-summary></button>
10
+ <div data-querystats-list style="display: none;" class="absolute right-0 z-50 hidden translate-y-full -bottom-1 group/querystats-hover:block">
11
+ <div class="p-2 text-xs border rounded shadow-md bg-zinc-900 border-zinc-700"><table><tbody></tbody></table></div>
12
+ </div>
13
+ <script async defer>
14
+ // Catch errors since some browsers throw when using the new `type` option.
15
+ // https://bugs.webkit.org/show_bug.cgi?id=209216
16
+ var querystatsTimings = [];
17
+ function renderQuerystats() {
18
+ // Render the most recent timing call
19
+ const latestTiming = querystatsTimings[querystatsTimings.length - 1];
20
+ let summary = latestTiming.description;
21
+ if (querystatsTimings.length > 1) {
22
+ summary += ` *`;
23
+ }
24
+ document.querySelector('[data-querystats-summary]').innerText = summary;
25
+
26
+ // Make sure the elements are visible
27
+ document.querySelector('[data-querystats]').style.display = 'inline';
28
+
29
+ // Render the table rows for all timings
30
+ const list = document.querySelector('[data-querystats-list]');
31
+ if (querystatsTimings.length > 1) {
32
+ const tableRows = querystatsTimings.map(timing => {
33
+ let url = timing.url;
34
+ if (url.startsWith(window.location.origin)) {
35
+ // Make the url relative if possible (usually is)
36
+ url = url.slice(window.location.origin.length);
37
+ }
38
+ return `<tr>
39
+ <td class="pr-2 font-medium whitespace-nowrap">${url}</td>
40
+ <td class="whitespace-nowrap">${timing.description}</td>
41
+ </tr>`;
42
+ }).join('');
43
+ list.querySelector("tbody").innerHTML = tableRows;
44
+ list.style.display = '';
45
+ } else {
46
+ list.style.display = 'none';
47
+ }
48
+ }
49
+ try {
50
+ // Create the performance observer.
51
+ const po = new PerformanceObserver((list) => {
52
+ for (const entry of list.getEntries()) {
53
+ if (!entry.serverTiming) {
54
+ console.warn("Server timing not available for querystats.")
55
+ return;
56
+ }
57
+ for (const timing of entry.serverTiming) {
58
+ if (querystatsTimings.length > 0) {
59
+ if (querystatsTimings[querystatsTimings.length - 1] === timing) {
60
+ // Skip duplicate timings (happens on initial load...)
61
+ continue;
62
+ }
63
+ }
64
+ if (timing.name === "querystats") {
65
+ console.log("Querystats timing", entry)
66
+ timing.url = entry.name; // Store this for reference later
67
+ querystatsTimings.push(timing);
68
+ renderQuerystats();
69
+ }
70
+ }
71
+ }
72
+ });
73
+ po.observe({type: 'navigation', buffered: true}); // Catch the regular page loads
74
+ po.observe({type: 'resource', buffered: true}); // Catch future ajax requests
75
+ } catch (e) {
76
+ // Do nothing if the browser doesn't support this API.
77
+ }
78
+ </script>
79
+ </form>
@@ -0,0 +1,91 @@
1
+ {% if toolbar.should_render() %}
2
+ {% set exception=toolbar.request_exception() %}
3
+ <script src="{{ asset('toolbar/toolbar.js') }}"></script>
4
+ <div id="plaintoolbar" class="print:hidden text-sm py-1.5 text-stone-300 bg-stone-950 fixed bottom-3 mx-3 max-w-full drop-shadow-md z-30 rounded-2xl lg:flex lg:flex-col -translate-x-1/2 left-1/2 max-h-[90vh]">
5
+ <div class="flex justify-between px-4 mx-auto space-x-4">
6
+ <div class="flex items-center">
7
+ <svg class="h-4 w-4" width="160" height="125" viewBox="0 0 160 125" fill="none" xmlns="http://www.w3.org/2000/svg">
8
+ <rect x="4.78467" y="4.79785" width="150.978" height="115.404" rx="5" stroke="#ffffff" stroke-width="8"/>
9
+ <path d="M151.762 60.3705C99.2596 39.3233 80.202 66.8232 8.78467 60.3705V116.2H151.762V60.3705Z" fill="#ffffff"/>
10
+ <path d="M51.104 8.08887H108.179V10.7668C108.179 12.6998 106.612 14.2668 104.679 14.2668H54.604C52.671 14.2668 51.104 12.6998 51.104 10.7668V8.08887Z" fill="#ffffff" stroke="#ffffff"/>
11
+ </svg>
12
+ <code class="ml-2 text-xs whitespace-nowrap text-mono">{{ toolbar.version }}</code>
13
+
14
+ {% if request.impersonator is defined %}
15
+ <div class="flex items-center ml-1 font-light">
16
+ Impersonating&nbsp;<span class="font-medium">{{ request.user }}</span>
17
+ </span>
18
+ <a href="{{ url('admin:impersonate:stop') }}" title="Stop impersonating" class="flex items-center px-1 ml-1 text-red-300 hover:text-white">
19
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4 bi bi-x-octagon-fill" viewBox="0 0 16 16">
20
+ <path d="M11.46.146A.5.5 0 0 0 11.107 0H4.893a.5.5 0 0 0-.353.146L.146 4.54A.5.5 0 0 0 0 4.893v6.214a.5.5 0 0 0 .146.353l4.394 4.394a.5.5 0 0 0 .353.146h6.214a.5.5 0 0 0 .353-.146l4.394-4.394a.5.5 0 0 0 .146-.353V4.893a.5.5 0 0 0-.146-.353L11.46.146zm-6.106 4.5L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 1 1 .708-.708z"/>
21
+ </svg>
22
+ </a>
23
+ </div>
24
+ {% endif %}
25
+ </div>
26
+ <div class="flex items-center space-x-4">
27
+ {% include "querystats/toolbar.html" %}
28
+
29
+ <div class="flex items-center space-x-3 transition-all">
30
+ <a href="{{ url('admin:index') }}" class="hover:underline">Admin</a>
31
+ {% if object|default(false) and object|get_admin_model_detail_url %}
32
+ <a class="inline-flex items-center p-1 text-blue-500 hover:text-blue-400" href="{{ object|get_admin_model_detail_url }}">
33
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-3 h-3 bi bi-database-fill" viewBox="0 0 16 16">
34
+ <path d="M3.904 1.777C4.978 1.289 6.427 1 8 1s3.022.289 4.096.777C13.125 2.245 14 2.993 14 4s-.875 1.755-1.904 2.223C11.022 6.711 9.573 7 8 7s-3.022-.289-4.096-.777C2.875 5.755 2 5.007 2 4s.875-1.755 1.904-2.223"/>
35
+ <path d="M2 6.161V7c0 1.007.875 1.755 1.904 2.223C4.978 9.71 6.427 10 8 10s3.022-.289 4.096-.777C13.125 8.755 14 8.007 14 7v-.839c-.457.432-1.004.751-1.49.972C11.278 7.693 9.682 8 8 8s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
36
+ <path d="M2 9.161V10c0 1.007.875 1.755 1.904 2.223C4.978 12.711 6.427 13 8 13s3.022-.289 4.096-.777C13.125 11.755 14 11.007 14 10v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
37
+ <path d="M2 12.161V13c0 1.007.875 1.755 1.904 2.223C4.978 15.711 6.427 16 8 16s3.022-.289 4.096-.777C13.125 14.755 14 14.007 14 13v-.839c-.457.432-1.004.751-1.49.972-1.232.56-2.828.867-4.51.867s-3.278-.307-4.51-.867c-.486-.22-1.033-.54-1.49-.972"/>
38
+ </svg>
39
+ </a>
40
+ {% endif %}
41
+ {% include "toolbar/links.html" ignore missing %}
42
+ <button data-plaintoolbar-expand class="hover:text-orange-500" type="button">
43
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4" viewBox="0 0 16 16">
44
+ <path fill-rule="evenodd" d="M3.646 9.146a.5.5 0 0 1 .708 0L8 12.793l3.646-3.647a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 0-.708zm0-2.292a.5.5 0 0 0 .708 0L8 3.207l3.646 3.647a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 0 0 0 .708z"/>
45
+ </svg>
46
+ </button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+ <div id="plaintoolbar-details" class="{% if not exception %}hidden{% endif %} p-4 overflow-auto text-sm space-y-2">
51
+
52
+ {% if exception %}
53
+ <div class="p-2 border-amber-500 border rounded">
54
+ <div class="text-amber-500 text-lg flex justify-between items-center">
55
+ <div>
56
+ <span class="font-bold">Exception</span>
57
+ {{ exception }}
58
+ </div>
59
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-5 h-5 bi bi-exclamation-triangle-fill" viewBox="0 0 16 16">
60
+ <path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5m.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
61
+ </svg>
62
+ </div>
63
+ <div class="text-amber-400 text-xs mt-3 bg-white/5 p-2 rounded overflow-auto">
64
+ <pre><code>{{ exception._traceback_string }}</code></pre>
65
+ </div>
66
+ </div>
67
+ {% endif %}
68
+
69
+ <table>
70
+ <tbody>
71
+ {% for k, v in toolbar.metadata.items() %}
72
+ <tr>
73
+ <td class="pr-2 font-medium whitespace-nowrap">{{ k }}</td>
74
+ <td class="whitespace-nowrap">{{ v }}</td>
75
+ </tr>
76
+ {% endfor %}
77
+ </tbody>
78
+ </table>
79
+
80
+ {% if object|default(false) %}
81
+ <div class="font-mono" title="PK: {{ object.pk|default('unknown') }}">
82
+ {{ object.__repr__() }}
83
+ </div>
84
+ {% endif %}
85
+
86
+ <button data-plaintoolbar-hide class="hover:text-red-500" type="button">
87
+ Hide toolbar for 1 hour
88
+ </button>
89
+ </div>
90
+ </div>
91
+ {% endif %}
@@ -0,0 +1,25 @@
1
+ from plain.runtime import settings
2
+ from plain.templates import register_template_extension, register_template_filter
3
+ from plain.templates.jinja.extensions import InclusionTagExtension
4
+ from plain.utils.module_loading import import_string
5
+
6
+ from .views.registry import registry
7
+
8
+
9
+ @register_template_extension
10
+ class ToolbarExtension(InclusionTagExtension):
11
+ tags = {"toolbar"}
12
+ template_name = "toolbar/toolbar.html"
13
+
14
+ def get_context(self, context, *args, **kwargs):
15
+ if isinstance(settings.TOOLBAR_CLASS, str):
16
+ cls = import_string(settings.TOOLBAR_CLASS)
17
+ else:
18
+ cls = settings.TOOLBAR_CLASS
19
+ context.vars["toolbar"] = cls(request=context["request"])
20
+ return context
21
+
22
+
23
+ @register_template_filter
24
+ def get_admin_model_detail_url(obj):
25
+ return registry.get_model_detail_url(obj)
plain/admin/toolbar.py ADDED
@@ -0,0 +1,36 @@
1
+ import sys
2
+ import traceback
3
+
4
+ from plain.runtime import settings
5
+ from plain.urls.exceptions import Resolver404
6
+
7
+
8
+ class Toolbar:
9
+ def __init__(self, request):
10
+ self.request = request
11
+ self.version = settings.TOOLBAR_VERSION
12
+ self.metadata = {
13
+ "Request ID": request.unique_id,
14
+ }
15
+
16
+ def should_render(self):
17
+ if settings.DEBUG:
18
+ return True
19
+
20
+ if hasattr(self.request, "impersonator"):
21
+ return self.request.impersonator.is_admin
22
+
23
+ if self.request.user:
24
+ return self.request.user.is_admin
25
+
26
+ return False
27
+
28
+ def request_exception(self):
29
+ # We can capture the exception currently being handled here, if any.
30
+ exception = sys.exception()
31
+
32
+ if exception and not isinstance(exception, Resolver404):
33
+ exception._traceback_string = "".join(
34
+ traceback.format_tb(exception.__traceback__)
35
+ )
36
+ return exception
plain/admin/urls.py ADDED
@@ -0,0 +1,45 @@
1
+ from plain.http import ResponseRedirect
2
+ from plain.urls import include, path
3
+
4
+ from .impersonate import urls as impersonate_urls
5
+ from .querystats import urls as querystats_urls
6
+ from .views.base import AdminView
7
+ from .views.registry import registry
8
+
9
+
10
+ class AdminIndexView(AdminView):
11
+ template_name = "admin/index.html"
12
+ title = "Dashboard"
13
+ slug = ""
14
+
15
+ def get(self):
16
+ # Slight hack to redirect to the first view that doesn't
17
+ # require any url params...
18
+ if views := registry.get_searchable_views():
19
+ return ResponseRedirect(list(views)[0].get_view_url())
20
+
21
+ return super().get()
22
+
23
+
24
+ class AdminSearchView(AdminView):
25
+ template_name = "admin/search.html"
26
+ title = "Search"
27
+ slug = "search"
28
+
29
+ def get_template_context(self):
30
+ context = super().get_template_context()
31
+ context["searchable_views"] = registry.get_searchable_views()
32
+ context["global_search_query"] = self.request.GET.get("query", "")
33
+ return context
34
+
35
+
36
+ default_namespace = "admin"
37
+
38
+
39
+ urlpatterns = [
40
+ path("search/", AdminSearchView, name="search"),
41
+ path("impersonate/", include(impersonate_urls)),
42
+ path("querystats/", include(querystats_urls)),
43
+ path("", include(registry.get_urls())),
44
+ path("", AdminIndexView, name="index"),
45
+ ]
@@ -0,0 +1,41 @@
1
+ from .base import AdminView
2
+ from .models import (
3
+ AdminModelCreateView,
4
+ AdminModelDeleteView,
5
+ AdminModelDetailView,
6
+ AdminModelListView,
7
+ AdminModelUpdateView,
8
+ )
9
+ from .objects import (
10
+ AdminCreateView,
11
+ AdminDeleteView,
12
+ AdminDetailView,
13
+ AdminListView,
14
+ AdminUpdateView,
15
+ )
16
+ from .registry import (
17
+ get_model_detail_url,
18
+ register_view,
19
+ register_viewset,
20
+ )
21
+ from .types import Img
22
+ from .viewsets import AdminViewset
23
+
24
+ __all__ = [
25
+ "AdminView",
26
+ "AdminListView",
27
+ "AdminCreateView",
28
+ "AdminUpdateView",
29
+ "AdminDetailView",
30
+ "AdminDeleteView",
31
+ "AdminViewset",
32
+ "AdminModelListView",
33
+ "AdminModelCreateView",
34
+ "AdminModelDetailView",
35
+ "AdminModelUpdateView",
36
+ "AdminModelDeleteView",
37
+ "register_viewset",
38
+ "register_view",
39
+ "get_model_detail_url",
40
+ "Img",
41
+ ]
@@ -0,0 +1,140 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from plain.auth.views import AuthViewMixin
4
+ from plain.urls import reverse
5
+ from plain.utils import timezone
6
+ from plain.utils.text import slugify
7
+ from plain.views import (
8
+ TemplateView,
9
+ )
10
+
11
+ from .registry import registry
12
+ from .types import Img
13
+
14
+ if TYPE_CHECKING:
15
+ from ..cards import Card
16
+
17
+
18
+ URL_NAMESPACE = "admin"
19
+
20
+
21
+ class AdminView(AuthViewMixin, TemplateView):
22
+ admin_required = True
23
+
24
+ title: str = ""
25
+ slug: str = ""
26
+ path: str = ""
27
+ description: str = ""
28
+ image: Img | None = None
29
+
30
+ # Leave empty to hide from nav
31
+ #
32
+ # An explicit disabling of showing this url/page in the nav
33
+ # which importantly effects the (future) recent pages list
34
+ # so you can also use this for pages that can never be bookmarked
35
+ nav_section = "App"
36
+ nav_title = ""
37
+
38
+ links: dict[str] = {}
39
+
40
+ parent_view_class: "AdminView" = None
41
+
42
+ template_name = "admin/page.html"
43
+ cards: list["Card"] = []
44
+
45
+ def get_template_context(self):
46
+ context = super().get_template_context()
47
+ context["title"] = self.get_title()
48
+ context["image"] = self.get_image()
49
+ context["slug"] = self.get_slug()
50
+ context["description"] = self.get_description()
51
+ context["links"] = self.get_links()
52
+ context["parent_view_classes"] = self.get_parent_view_classes()
53
+ context["admin_registry"] = registry
54
+ context["cards"] = self.get_cards()
55
+ context["render_card"] = lambda card: card().render(self, self.request)
56
+ context["time_zone"] = timezone.get_current_timezone_name()
57
+ return context
58
+
59
+ @classmethod
60
+ def view_name(cls) -> str:
61
+ return f"view_{cls.get_slug()}"
62
+
63
+ @classmethod
64
+ def get_slug(cls) -> str:
65
+ if cls.slug:
66
+ return cls.slug
67
+
68
+ if cls.title:
69
+ return slugify(cls.title)
70
+
71
+ raise NotImplementedError(
72
+ f"Please set a slug on the {cls} class or implement get_slug()."
73
+ )
74
+
75
+ # Can actually use @classmethod, @staticmethod or regular method for these?
76
+ def get_title(self) -> str:
77
+ return self.title
78
+
79
+ def get_image(self) -> Img | None:
80
+ return self.image
81
+
82
+ def get_description(self) -> str:
83
+ return self.description
84
+
85
+ @classmethod
86
+ def get_path(cls) -> str:
87
+ if cls.path:
88
+ return cls.path
89
+
90
+ if slug := cls.get_slug():
91
+ return slug
92
+
93
+ raise NotImplementedError(
94
+ f"Please set a path on the {cls} class or implement get_slug() or get_path()."
95
+ )
96
+
97
+ @classmethod
98
+ def get_parent_view_classes(cls) -> list["AdminView"]:
99
+ parents = []
100
+ parent = cls.parent_view_class
101
+ while parent:
102
+ parents.append(parent)
103
+ parent = parent.parent_view_class
104
+ return parents
105
+
106
+ @classmethod
107
+ def get_nav_section(cls) -> bool:
108
+ if not cls.nav_section:
109
+ return ""
110
+
111
+ if cls.parent_view_class:
112
+ # Don't show child views by default
113
+ return ""
114
+
115
+ return cls.nav_section
116
+
117
+ @classmethod
118
+ def get_nav_title(cls) -> str:
119
+ if cls.nav_title:
120
+ return cls.nav_title
121
+
122
+ if cls.title:
123
+ return cls.title
124
+
125
+ raise NotImplementedError(
126
+ f"Please set a title or nav_title on the {cls} class or implement get_nav_title()."
127
+ )
128
+
129
+ @classmethod
130
+ def get_view_url(cls, obj=None) -> str:
131
+ if obj:
132
+ return reverse(f"{URL_NAMESPACE}:" + cls.view_name(), kwargs={"pk": obj.pk})
133
+ else:
134
+ return reverse(f"{URL_NAMESPACE}:" + cls.view_name())
135
+
136
+ def get_links(self) -> dict[str]:
137
+ return self.links.copy()
138
+
139
+ def get_cards(self):
140
+ return self.cards.copy()