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