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