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