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 template_name = "admin/page.html"
72 cards: tuple[Card, ...] = ()
73
74 def __init_subclass__(cls, **kwargs: Any) -> None:
75 super().__init_subclass__(**kwargs)
76 # Declarative attributes are tuples; converge legacy list declarations
77 # so the get_* accessors can safely return them as-is (a shared list
78 # would be silently mutable through an append-style override).
79 for attr in ("cards", "fields", "search_fields", "actions", "filters"):
80 value = cls.__dict__.get(attr)
81 if isinstance(value, list):
82 setattr(cls, attr, tuple(value))
83
84 def before_request(self) -> None:
85 super().before_request()
86 # Track this page visit for recent nav tabs
87 if self.nav_section is not None:
88 track_recent_nav(self.request, self.get_slug())
89
90 def after_response(self, response: Response) -> Response:
91 response = super().after_response(response)
92 response.headers["Cache-Control"] = (
93 "no-cache, no-store, must-revalidate, max-age=0"
94 )
95 return response
96
97 def get_template_context(self) -> dict[str, Any]:
98 context = super().get_template_context()
99 context["title"] = self.get_title()
100 context["description"] = self.get_description()
101 context["image"] = self.get_image()
102 context["slug"] = self.get_slug()
103 context["links"] = self.get_links()
104 context["extra_links"] = self.get_extra_links()
105 context["parent_view_classes"] = self.get_parent_view_classes()
106 context["admin_registry"] = registry
107 context["cards"] = self.get_cards()
108 context["render_card"] = lambda card: card().render(self, self.request)
109 context["time_zone"] = timezone.get_current_timezone_name()
110 context["view_class"] = self.__class__
111 context["app_name"] = settings.NAME
112 context["admin_force_theme"] = settings.ADMIN_FORCE_THEME
113
114 context["nav_tabs"] = registry.get_nav_tabs(self.request)
115 context["pinned_slugs"] = set(
116 PinnedNavItem.query.filter(user=self.user).values_list(
117 "view_slug", flat=True
118 )
119 )
120 context["preflight_counts"] = get_check_counts()
121 context["admin_url"] = registry.get_url
122
123 return context
124
125 @classmethod
126 def view_name(cls) -> str:
127 return f"view_{cls.get_slug()}"
128
129 @classmethod
130 def get_slug(cls) -> str:
131 return f"{cls.__module__}.{cls.__qualname__}".lower().replace(".", "_")
132
133 # Can actually use @classmethod, @staticmethod or regular method for these?
134 def get_title(self) -> str:
135 return self.title
136
137 def get_description(self) -> str:
138 return self.description
139
140 def get_image(self) -> Img | None:
141 return self.image
142
143 @classmethod
144 def get_path(cls) -> str:
145 return cls.path
146
147 @classmethod
148 def get_parent_view_classes(cls) -> list[AdminView]:
149 parents = []
150 parent = cls.parent_view_class
151 while parent:
152 parents.append(parent)
153 parent = parent.parent_view_class
154 return parents
155
156 @classmethod
157 def get_nav_title(cls) -> str:
158 if cls.nav_title:
159 return cls.nav_title
160
161 if cls.title:
162 return cls.title
163
164 raise NotImplementedError(
165 f"Please set a title or nav_title on the {cls} class or implement get_nav_title()."
166 )
167
168 @classmethod
169 def get_view_url(cls, obj: Any = None) -> str:
170 # Check if this view's path expects an id parameter
171 if obj and "<int:id>" in cls.get_path():
172 return reverse(f"{_URL_NAMESPACE}:" + cls.view_name(), id=obj.id)
173 else:
174 return reverse(f"{_URL_NAMESPACE}:" + cls.view_name())
175
176 def get_links(self) -> dict[str, str]:
177 return self.links.copy()
178
179 def get_extra_links(self) -> dict[str, str]:
180 return self.extra_links.copy()
181
182 def get_cards(self) -> tuple[Card, ...]:
183 return self.cards
184
185 def get_field_value(self, obj: Any, field: str) -> Any:
186 try:
187 # Try basic dict lookup first
188 if field in obj:
189 return obj[field]
190 except TypeError:
191 pass
192
193 # Try dot notation
194 if "." in field:
195 field, subfield = field.split(".", 1)
196 return self.get_field_value(obj[field], subfield)
197
198 # Try regular object attribute
199 attr = getattr(obj, field)
200
201 # Call if it's callable
202 if callable(attr):
203 return attr()
204 else:
205 return attr
206
207 def format_field_value(self, obj: Any, field: str, value: Any) -> Any:
208 """Format a field value for display. Override this for display formatting
209 like currency symbols, percentages, etc. Sorting and searching use
210 get_field_value directly, so formatting here won't affect sort order."""
211 return value
212
213 def get_field_value_template(self, obj: Any, field: str, value: Any) -> list[str]:
214 templates = []
215
216 # By explicit field_templates mapping
217 if field in self.field_templates:
218 templates.append(self.field_templates[field])
219
220 # By field name
221 templates.append(f"admin/values/{field}.html")
222
223 # By database field type
224 try:
225 field_obj = obj._model_meta.get_field(field)
226 field_type = type(field_obj).__name__
227 templates.append(f"admin/values/{field_type}.html")
228 except (AttributeError, FieldDoesNotExist):
229 # Not a model instance, or not a database field on it.
230 pass
231
232 # By value type (walk MRO for parent classes)
233 for cls in type(value).__mro__:
234 if cls is object:
235 break
236 templates.append(f"admin/values/{cls.__name__}.html")
237
238 # Default
239 templates.append("admin/values/default.html")
240
241 return templates