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