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