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