1from __future__ import annotations
2
3from typing import TYPE_CHECKING, Any
4
5from plain.auth.views import AuthView
6from plain.http import ForbiddenError403
7from plain.preflight import get_check_counts
8from plain.runtime import settings
9from plain.urls import reverse
10from plain.utils import timezone
11from plain.views import (
12 TemplateView,
13)
14
15from ..models import PinnedNavItem
16from .registry import registry, track_recent_nav
17from .types import Img
18
19if TYPE_CHECKING:
20 from plain.http import Response
21 from plain.postgres import Model
22
23 from ..cards import Card
24 from .viewsets import AdminViewset
25
26
27_URL_NAMESPACE = "admin"
28
29
30class AdminView(AuthView, TemplateView):
31 admin_required = True
32 user: Model # Always set — admin_required guarantees authentication
33
34 # True for framework-provided views (index, search, settings, etc.)
35 # Available for use in ADMIN_HAS_PERMISSION to make per-view decisions.
36 is_builtin = False
37
38 def check_auth(self) -> None:
39 super().check_auth()
40 if not self.has_permission(self.user):
41 raise ForbiddenError403("You don't have access to this page.")
42
43 @classmethod
44 def has_permission(cls, user: Model) -> bool:
45 if check := settings.ADMIN_HAS_PERMISSION:
46 return check(cls, user)
47 return True
48
49 title: str = ""
50 description: str = "" # Optional description shown below the title
51 path: str = ""
52 image: Img | None = None
53
54 # Leave empty to hide from nav
55 #
56 # An explicit disabling of showing this url/page in the nav
57 # which importantly effects the (future) recent pages list
58 # so you can also use this for pages that can never be bookmarked
59 nav_title = ""
60 nav_section = ""
61 nav_icon = "" # Bootstrap Icons name (e.g., "cart", "person", "flag")
62
63 links: dict[str, str] = {}
64 extra_links: dict[str, str] = {}
65 field_templates: dict[str, str] = {}
66
67 parent_view_class: AdminView | None = None
68
69 # Set dynamically by AdminViewset.get_views()
70 viewset: type[AdminViewset] | None = None
71
72 template_name = "admin/page.html"
73 cards: list[Card] = []
74
75 def before_request(self) -> None:
76 super().before_request()
77 # Track this page visit for recent nav tabs
78 if self.nav_section is not None:
79 track_recent_nav(self.request, self.get_slug())
80
81 def after_response(self, response: Response) -> Response:
82 response = super().after_response(response)
83 response.headers["Cache-Control"] = (
84 "no-cache, no-store, must-revalidate, max-age=0"
85 )
86 return response
87
88 def get_template_context(self) -> dict[str, Any]:
89 context = super().get_template_context()
90 context["title"] = self.get_title()
91 context["description"] = self.get_description()
92 context["image"] = self.get_image()
93 context["slug"] = self.get_slug()
94 context["links"] = self.get_links()
95 context["extra_links"] = self.get_extra_links()
96 context["parent_view_classes"] = self.get_parent_view_classes()
97 context["admin_registry"] = registry
98 context["cards"] = self.get_cards()
99 context["render_card"] = lambda card: card().render(self, self.request)
100 context["time_zone"] = timezone.get_current_timezone_name()
101 context["view_class"] = self.__class__
102 context["app_name"] = settings.NAME
103 context["admin_force_theme"] = settings.ADMIN_FORCE_THEME
104
105 context["nav_tabs"] = registry.get_nav_tabs(self.request)
106 context["pinned_slugs"] = set(
107 PinnedNavItem.query.filter(user=self.user).values_list(
108 "view_slug", flat=True
109 )
110 )
111 context["preflight_counts"] = get_check_counts()
112 context["admin_url"] = registry.get_url
113
114 return context
115
116 @classmethod
117 def view_name(cls) -> str:
118 return f"view_{cls.get_slug()}"
119
120 @classmethod
121 def get_slug(cls) -> str:
122 return f"{cls.__module__}.{cls.__qualname__}".lower().replace(".", "_")
123
124 # Can actually use @classmethod, @staticmethod or regular method for these?
125 def get_title(self) -> str:
126 return self.title
127
128 def get_description(self) -> str:
129 return self.description
130
131 def get_image(self) -> Img | None:
132 return self.image
133
134 @classmethod
135 def get_path(cls) -> str:
136 return cls.path
137
138 @classmethod
139 def get_parent_view_classes(cls) -> list[AdminView]:
140 parents = []
141 parent = cls.parent_view_class
142 while parent:
143 parents.append(parent)
144 parent = parent.parent_view_class
145 return parents
146
147 @classmethod
148 def get_nav_title(cls) -> str:
149 if cls.nav_title:
150 return cls.nav_title
151
152 if cls.title:
153 return cls.title
154
155 raise NotImplementedError(
156 f"Please set a title or nav_title on the {cls} class or implement get_nav_title()."
157 )
158
159 @classmethod
160 def get_view_url(cls, obj: Any = None) -> str:
161 # Check if this view's path expects an id parameter
162 if obj and "<int:id>" in cls.get_path():
163 return reverse(f"{_URL_NAMESPACE}:" + cls.view_name(), id=obj.id)
164 else:
165 return reverse(f"{_URL_NAMESPACE}:" + cls.view_name())
166
167 def get_links(self) -> dict[str, str]:
168 return self.links.copy()
169
170 def get_extra_links(self) -> dict[str, str]:
171 return self.extra_links.copy()
172
173 def get_cards(self) -> list[Card]:
174 return self.cards.copy()
175
176 def get_field_value(self, obj: Any, field: str) -> Any:
177 try:
178 # Try basic dict lookup first
179 if field in obj:
180 return obj[field]
181 except TypeError:
182 pass
183
184 # Try dot notation
185 if "." in field:
186 field, subfield = field.split(".", 1)
187 return self.get_field_value(obj[field], subfield)
188
189 # Try regular object attribute
190 attr = getattr(obj, field)
191
192 # Call if it's callable
193 if callable(attr):
194 return attr()
195 else:
196 return attr
197
198 def format_field_value(self, obj: Any, field: str, value: Any) -> Any:
199 """Format a field value for display. Override this for display formatting
200 like currency symbols, percentages, etc. Sorting and searching use
201 get_field_value directly, so formatting here won't affect sort order."""
202 return value
203
204 def get_field_value_template(self, obj: Any, field: str, value: Any) -> list[str]:
205 templates = []
206
207 # By explicit field_templates mapping
208 if field in self.field_templates:
209 templates.append(self.field_templates[field])
210
211 # By field name
212 templates.append(f"admin/values/{field}.html")
213
214 # By database field type
215 try:
216 field_obj = obj._model_meta.get_field(field)
217 field_type = type(field_obj).__name__
218 templates.append(f"admin/values/{field_type}.html")
219 except Exception:
220 pass
221
222 # By value type (walk MRO for parent classes)
223 for cls in type(value).__mro__:
224 if cls is object:
225 break
226 templates.append(f"admin/values/{cls.__name__}.html")
227
228 # Default
229 templates.append("admin/values/default.html")
230
231 return templates