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.urls import reverse
 10from plain.utils import timezone
 11from plain.views import (
 12    TemplateView,
 13)
 14
 15from ..models import PinnedNavItem
 16from .registry import registry, track_recent_nav
 17from .types import Img
 18
 19if TYPE_CHECKING:
 20    from plain.http import ResponseBase
 21    from plain.postgres import Model
 22
 23    from ..cards import Card
 24    from .viewsets import AdminViewset
 25
 26
 27_URL_NAMESPACE = "admin"
 28
 29
 30class AdminView(AuthView, TemplateView):
 31    admin_required = True
 32    user: Model  # Always set — admin_required guarantees authentication
 33
 34    # True for framework-provided views (index, search, settings, etc.)
 35    # Available for use in ADMIN_HAS_PERMISSION to make per-view decisions.
 36    is_builtin = False
 37
 38    def check_auth(self) -> None:
 39        super().check_auth()
 40        if not self.has_permission(self.user):
 41            raise ForbiddenError403("You don't have access to this page.")
 42
 43    @classmethod
 44    def has_permission(cls, user: Model) -> bool:
 45        if check := settings.ADMIN_HAS_PERMISSION:
 46            return check(cls, user)
 47        return True
 48
 49    title: str = ""
 50    description: str = ""  # Optional description shown below the title
 51    path: str = ""
 52    image: Img | None = None
 53
 54    # Leave empty to hide from nav
 55    #
 56    # An explicit disabling of showing this url/page in the nav
 57    # which importantly effects the (future) recent pages list
 58    # so you can also use this for pages that can never be bookmarked
 59    nav_title = ""
 60    nav_section = ""
 61    nav_icon = ""  # Bootstrap Icons name (e.g., "cart", "person", "flag")
 62
 63    links: dict[str, str] = {}
 64    extra_links: dict[str, str] = {}
 65    field_templates: dict[str, str] = {}
 66
 67    parent_view_class: AdminView | None = None
 68
 69    # Set dynamically by AdminViewset.get_views()
 70    viewset: type[AdminViewset] | None = None
 71
 72    template_name = "admin/page.html"
 73    cards: list[Card] = []
 74
 75    def get_response(self) -> ResponseBase:
 76        # Track this page visit for recent nav tabs
 77        if self.nav_section is not None:
 78            track_recent_nav(self.request, self.get_slug())
 79
 80        response = super().get_response()
 81        response.headers["Cache-Control"] = (
 82            "no-cache, no-store, must-revalidate, max-age=0"
 83        )
 84
 85        return response
 86
 87    def get_template_context(self) -> dict[str, Any]:
 88        context = super().get_template_context()
 89        context["title"] = self.get_title()
 90        context["description"] = self.get_description()
 91        context["image"] = self.get_image()
 92        context["slug"] = self.get_slug()
 93        context["links"] = self.get_links()
 94        context["extra_links"] = self.get_extra_links()
 95        context["parent_view_classes"] = self.get_parent_view_classes()
 96        context["admin_registry"] = registry
 97        context["cards"] = self.get_cards()
 98        context["render_card"] = lambda card: card().render(self, self.request)
 99        context["time_zone"] = timezone.get_current_timezone_name()
100        context["view_class"] = self.__class__
101        context["app_name"] = settings.NAME
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