v0.165.0
  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