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