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