Coverage for src/lexigram/admin/resources/lenses.py: 98%

62 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Resource Lenses — alternative filtered/customised views on a resource. 

2 

3A **Lens** is a named view of a resource that applies a pre-defined query, 

4restricts columns, or changes the default sort — without creating a 

5separate resource class. 

6 

7Example:: 

8 

9 from lexigram.admin.resources.lenses import ResourceLens 

10 from lexigram.ui.columns import TextColumn, DateColumn, BadgeColumn 

11 

12 class ActiveUsersLens(ResourceLens): 

13 name = "active_users" 

14 label = "Active Users" 

15 icon = "user-check" 

16 

17 # Extra filters applied *on top of* the resource's base query 

18 query_filters: dict = {"is_active": True} 

19 

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 ] 

26 

27 default_sort = "-last_active" 

28 

29Usage in a Resource:: 

30 

31 class UserResource(Resource): 

32 lenses = [ActiveUsersLens, InactiveUsersLens] 

33""" 

34 

35from __future__ import annotations 

36 

37from typing import TYPE_CHECKING, Any 

38 

39if TYPE_CHECKING: 

40 from lexigram.admin.ui.filters.base import Filter 

41 from lexigram.ui.columns import Column 

42 

43 

44class ResourceLens: 

45 """Alternative filtered/sorted view on a parent resource. 

46 

47 Subclass to create a lens. Attach to a :class:`~lexigram.admin.resources.base.Resource` 

48 via its ``lenses`` class attribute. 

49 

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 """ 

65 

66 name: str = "" 

67 label: str = "" 

68 icon: str | None = None 

69 description: str | None = None 

70 

71 # Pre-applied query constraints 

72 query_filters: dict[str, Any] = {} 

73 

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 

79 

80 # ------------------------------------------------------------------ 

81 # Public helpers 

82 # ------------------------------------------------------------------ 

83 

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 

90 

91 s = cls.__name__ 

92 return re.sub(r"(?<!^)(?=[A-Z])", "_", s).lower().removesuffix("_lens") 

93 

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() 

100 

101 @classmethod 

102 def apply_to_queryset(cls, queryset: Any) -> Any: 

103 """Apply :attr:`query_filters` to *queryset*. 

104 

105 The default implementation calls ``queryset.filter(**cls.query_filters)`` 

106 which is compatible with SQLAlchemy ``Select`` objects that expose 

107 a ``.filter()`` method, and the Lexigram ``RepositoryProtocol`` 

108 query builder. 

109 

110 Override for custom query logic:: 

111 

112 @classmethod 

113 def apply_to_queryset(cls, queryset): 

114 return queryset.filter(status="active").order_by("-created_at") 

115 

116 Args: 

117 queryset: The base queryset from the parent resource. 

118 

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 

125 

126 @classmethod 

127 def resolve_columns(cls, parent_columns: list[Column]) -> list[Column]: 

128 """Return the effective column list for this lens. 

129 

130 Args: 

131 parent_columns: Columns from the parent resource. 

132 

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 

137 

138 @classmethod 

139 def resolve_filters(cls, parent_filters: list[Filter]) -> list[Filter]: 

140 """Return the effective filter list for this lens. 

141 

142 Args: 

143 parent_filters: Filters from the parent resource. 

144 

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 

149 

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 

154 

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 

159 

160 @classmethod 

161 def to_dict(cls) -> dict[str, Any]: 

162 """Serialise lens metadata for JSON / API responses. 

163 

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 } 

173 

174 

175class LensRegistry: 

176 """Registry for lenses attached to a resource class. 

177 

178 Resources that want to expose lenses declare a ``lenses`` class attribute:: 

179 

180 class UserResource(Resource): 

181 lenses = [ActiveUsersLens, InactiveUsersLens] 

182 

183 :class:`LensRegistry` is then used by the controller/data layer to 

184 look up and apply the active lens. 

185 """ 

186 

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) 

191 

192 def register(self, lens: type[ResourceLens]) -> None: 

193 """Register a lens class. 

194 

195 Args: 

196 lens: A :class:`ResourceLens` subclass (not an instance). 

197 

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 

205 

206 def get(self, name: str) -> type[ResourceLens] | None: 

207 """Look up a lens by name. 

208 

209 Args: 

210 name: Machine-readable lens name. 

211 

212 Returns: 

213 The lens class, or ``None`` if not found. 

214 """ 

215 return self._lenses.get(name) 

216 

217 def all(self) -> list[type[ResourceLens]]: 

218 """Return all registered lenses in registration order.""" 

219 return list(self._lenses.values()) 

220 

221 def names(self) -> list[str]: 

222 """Return all registered lens names.""" 

223 return list(self._lenses.keys()) 

224 

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()]