Coverage for src / lexigram / admin / resources / lenses.py: 56%
62 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""Resource Lenses — alternative filtered/customised views on a resource.
3Inspired by Laravel Nova's Lens feature. A **Lens** is a named view of a
4resource that applies a pre-defined query, restricts columns, or changes
5the default sort — without creating a separate resource class.
7Example::
9 from lexigram.admin.resources.lenses import ResourceLens
10 from lexigram.ui.columns import TextColumn, DateColumn, BadgeColumn
12 class ActiveUsersLens(ResourceLens):
13 name = "active_users"
14 label = "Active Users"
15 icon = "user-check"
17 # Extra filters applied *on top of* the resource's base query
18 query_filters: dict = {"is_active": True}
20 # Optional: restrict which columns appear in this lens
21 columns = [
22 TextColumn("name").sortable(),
23 TextColumn("email").sortable(),
24 DateColumn("last_active").label("Last seen").sortable(),
25 ]
27 default_sort = "-last_active"
29Usage in a Resource::
31 class UserResource(Resource):
32 lenses = [ActiveUsersLens, InactiveUsersLens]
33"""
35from __future__ import annotations
37from typing import TYPE_CHECKING, Any
39if TYPE_CHECKING:
40 from lexigram.admin.ui.filters.base import Filter
41 from lexigram.ui.columns import Column
44class ResourceLens:
45 """Alternative filtered/sorted view on a parent resource.
47 Subclass to create a lens. Attach to a :class:`~lexigram.admin.resources.base.Resource`
48 via its ``lenses`` class attribute.
50 Attributes:
51 name: Machine-readable identifier (must be unique within a resource).
52 label: Human-readable tab label shown in the admin UI.
53 icon: Optional icon name (from the icon library configured in the admin).
54 description: Optional short description shown on hover.
55 query_filters: Static ``dict`` of ORM filter kwargs pre-applied to
56 every query executed through this lens.
57 columns: Override the parent resource's column set. ``None`` means
58 inherit the parent's columns unchanged.
59 filters: Override the parent resource's sidebar filters. ``None``
60 means inherit.
61 default_sort: Override the default sort field (prefix with ``-`` for
62 descending). ``None`` means inherit.
63 page_size: Override rows-per-page. ``None`` means inherit.
64 """
66 name: str = ""
67 label: str = ""
68 icon: str | None = None
69 description: str | None = None
71 # Pre-applied query constraints
72 query_filters: dict[str, Any] = {}
74 # Optional overrides (None = inherit from parent resource)
75 columns: list[Column] | None = None
76 filters: list[Filter] | None = None
77 default_sort: str | None = None
78 page_size: int | None = None
80 # ------------------------------------------------------------------
81 # Public helpers
82 # ------------------------------------------------------------------
84 @classmethod
85 def get_name(cls) -> str:
86 """Return the lens name, falling back to the class name in snake_case."""
87 if cls.name:
88 return cls.name
89 import re
91 s = cls.__name__
92 return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower().removesuffix("_lens")
94 @classmethod
95 def get_label(cls) -> str:
96 """Return the lens label, falling back to a title-cased version of the name."""
97 if cls.label:
98 return cls.label
99 return cls.get_name().replace("_", " ").title()
101 @classmethod
102 def apply_to_queryset(cls, queryset: Any) -> Any:
103 """Apply :attr:`query_filters` to *queryset*.
105 The default implementation calls ``queryset.filter(**cls.query_filters)``
106 which is compatible with Django ORM, SQLAlchemy ``Select`` objects
107 that expose a ``.filter()`` method, and the Lexigram
108 ``RepositoryProtocol`` query builder.
110 Override for custom query logic::
112 @classmethod
113 def apply_to_queryset(cls, queryset):
114 return queryset.filter(status="active").order_by("-created_at")
116 Args:
117 queryset: The base queryset from the parent resource.
119 Returns:
120 Modified queryset with lens constraints applied.
121 """
122 if cls.query_filters:
123 return queryset.filter(**cls.query_filters)
124 return queryset
126 @classmethod
127 def resolve_columns(cls, parent_columns: list[Column]) -> list[Column]:
128 """Return the effective column list for this lens.
130 Args:
131 parent_columns: Columns from the parent resource.
133 Returns:
134 Lens-specific columns if defined, otherwise *parent_columns*.
135 """
136 return cls.columns if cls.columns is not None else parent_columns
138 @classmethod
139 def resolve_filters(cls, parent_filters: list[Filter]) -> list[Filter]:
140 """Return the effective filter list for this lens.
142 Args:
143 parent_filters: Filters from the parent resource.
145 Returns:
146 Lens-specific filters if defined, otherwise *parent_filters*.
147 """
148 return cls.filters if cls.filters is not None else parent_filters
150 @classmethod
151 def resolve_sort(cls, parent_sort: str | None) -> str | None:
152 """Return the effective default sort for this lens."""
153 return cls.default_sort if cls.default_sort is not None else parent_sort
155 @classmethod
156 def resolve_page_size(cls, parent_page_size: int) -> int:
157 """Return the effective page size for this lens."""
158 return cls.page_size if cls.page_size is not None else parent_page_size
160 @classmethod
161 def to_dict(cls) -> dict[str, Any]:
162 """Serialise lens metadata for JSON / API responses.
164 Returns:
165 Dict with ``name``, ``label``, ``icon``, ``description`` keys.
166 """
167 return {
168 "name": cls.get_name(),
169 "label": cls.get_label(),
170 "icon": cls.icon,
171 "description": cls.description,
172 }
175class LensRegistry:
176 """Registry for lenses attached to a resource class.
178 Resources that want to expose lenses declare a ``lenses`` class attribute::
180 class UserResource(Resource):
181 lenses = [ActiveUsersLens, InactiveUsersLens]
183 :class:`LensRegistry` is then used by the controller/data layer to
184 look up and apply the active lens.
185 """
187 def __init__(self, lenses: list[type[ResourceLens]] | None = None) -> None:
188 self._lenses: dict[str, type[ResourceLens]] = {}
189 for lens in lenses or []:
190 self.register(lens)
192 def register(self, lens: type[ResourceLens]) -> None:
193 """Register a lens class.
195 Args:
196 lens: A :class:`ResourceLens` subclass (not an instance).
198 Raises:
199 ValueError: If a lens with the same name is already registered.
200 """
201 name = lens.get_name()
202 if name in self._lenses:
203 raise ValueError(f"A lens named {name!r} is already registered.")
204 self._lenses[name] = lens
206 def get(self, name: str) -> type[ResourceLens] | None:
207 """Look up a lens by name.
209 Args:
210 name: Machine-readable lens name.
212 Returns:
213 The lens class, or ``None`` if not found.
214 """
215 return self._lenses.get(name)
217 def all(self) -> list[type[ResourceLens]]:
218 """Return all registered lenses in registration order."""
219 return list(self._lenses.values())
221 def names(self) -> list[str]:
222 """Return all registered lens names."""
223 return list(self._lenses.keys())
225 def to_list(self) -> list[dict[str, Any]]:
226 """Serialise all lenses to a list of metadata dicts."""
227 return [lens.to_dict() for lens in self._lenses.values()]