Coverage for src / lexigram / admin / ui / filters / base.py: 0%

80 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 18:58 +0800

1""" 

2Filter base class for DataTable filtering. 

3 

4Provides a fluent API (Builder pattern) for defining filters with URL state persistence, 

5query application, and custom rendering. All configuration methods return `self` 

6to enable method chaining. 

7 

8Example: 

9 >>> from lexigram.admin.ui.filters import SelectFilter, DateFilter 

10 >>> 

11 >>> # Fluent API with method chaining 

12 >>> role_filter = (SelectFilter("role") 

13 ... .placeholder("Select Role") 

14 ... .default("user") 

15 ... .visible(lambda ctx: ctx.user.is_admin)) 

16""" 

17 

18from __future__ import annotations 

19 

20from abc import abstractmethod 

21from typing import TYPE_CHECKING, Any, Self 

22 

23from lexigram.admin.data.query import EqualSpec 

24from lexigram.admin.forms.fields import AbstractField 

25 

26if TYPE_CHECKING: 

27 from collections.abc import Callable 

28 

29 

30class Filter(AbstractField): 

31 """Base class for all table filters with fluent API. 

32 

33 This class implements the Builder pattern, allowing configuration 

34 through method chaining. All configuration methods return `self` 

35 to enable fluent syntax. 

36 

37 Attributes: 

38 name: Filter field name (query parameter name) 

39 label: Display label for filter UI 

40 

41 Example: 

42 >>> SelectFilter("status", options={"active": "Active"}) 

43 >>> DateFilter("created_at").placeholder("Filter by Date") 

44 """ 

45 

46 def __init__(self, name: str, label: str | None = None): 

47 """ 

48 Initialize a filter. 

49 

50 Args: 

51 name: Filter field name (used as query parameter key) 

52 label: Display label (defaults to title-cased name) 

53 """ 

54 # Initialize Field with label 

55 generated_label = label or name.replace("_", " ").title() 

56 super().__init__(label=generated_label, name=name) 

57 

58 # Additional Filter-specific state 

59 self._default_callback: Callable | None = None 

60 self._visible = True 

61 self._visible_callback: Callable | None = None 

62 

63 def default(self, value: Any | Callable) -> Self: 

64 """Set default value for the filter. 

65 

66 The default value is used when no value is present in the request 

67 parameters. Can be a static value or a callable that returns a value. 

68 

69 Args: 

70 value: The default value to use, or a callable that returns it 

71 

72 Returns: 

73 Self for method chaining 

74 

75 Example: 

76 >>> SelectFilter("status").default("active") 

77 >>> 

78 >>> # Dynamic default 

79 >>> DateFilter("created").default(lambda: datetime.now() - timedelta(days=30)) 

80 """ 

81 if callable(value): 

82 self._default_callback = value 

83 self.default = None # type: ignore[method-assign, assignment] 

84 else: 

85 self.default = value # type: ignore[method-assign] 

86 self._default_callback = None 

87 return self 

88 

89 def get_default(self) -> Any: 

90 """Get default value, calling callback if dynamic. 

91 

92 Returns: 

93 Default value or result of callback 

94 """ 

95 if self._default_callback: 

96 return self._default_callback() 

97 return self.default 

98 

99 def placeholder(self, text: str) -> Self: # type: ignore[override] 

100 """Set placeholder text for the filter input. 

101 

102 Args: 

103 text: Placeholder text to display 

104 

105 Returns: 

106 Self for method chaining 

107 

108 Example: 

109 >>> TextFilter("search").placeholder("Search users...") 

110 >>> SelectFilter("role").placeholder("All Roles") 

111 """ 

112 self.placeholder = text # type: ignore[method-assign, assignment] 

113 return self 

114 

115 def visible(self, visible: bool | Callable = True) -> Self: 

116 """ 

117 Control filter visibility. 

118 

119 Can accept a boolean or a callable that returns a boolean, 

120 allowing for dynamic visibility based on context (e.g., user permissions). 

121 

122 Args: 

123 visible: Boolean or callable that returns boolean 

124 

125 Returns: 

126 Self for method chaining 

127 

128 Example: 

129 >>> # Static visibility 

130 >>> DateFilter("archived_at").visible(False) 

131 >>> 

132 >>> # Dynamic visibility 

133 >>> def show_if_manager(context): 

134 ... return context.user.role == "manager" 

135 >>> SelectFilter("department").visible(show_if_manager) 

136 """ 

137 if callable(visible): 

138 self._visible_callback = visible 

139 else: 

140 self._visible = visible 

141 return self 

142 

143 def is_visible(self, context: dict | None = None) -> bool: 

144 """Check if filter should be visible. 

145 

146 Args: 

147 context: Context dictionary (e.g., with current user) 

148 

149 Returns: 

150 True if filter should be displayed 

151 """ 

152 if self._visible_callback: 

153 return self._visible_callback(context or {}) 

154 return self._visible 

155 

156 def get_value_from_request(self, request_params: dict) -> Any: 

157 """ 

158 Extract filter value from request parameters. 

159 

160 Args: 

161 request_params: Request query parameters dict 

162 

163 Returns: 

164 Filter value or default if not present 

165 """ 

166 return request_params.get(self.name, self.get_default()) 

167 

168 def get_consumed_params(self) -> list[str]: 

169 """ 

170 Return a list of request parameter keys that this filter consumes. 

171 Used to prevent these parameters from being included as extra filters. 

172 """ 

173 return [self.name] 

174 

175 @abstractmethod 

176 def render(self, current_value: Any = None, url: str | None = None) -> str: # type: ignore[override] 

177 """ 

178 Render the filter UI as HTML. 

179 

180 Args: 

181 current_value: Current filter value to display 

182 

183 Returns: 

184 HTML string of the filter component 

185 """ 

186 

187 @abstractmethod 

188 def apply(self, query: Any, value: Any) -> Any: 

189 """ 

190 Apply filter to a query. 

191 

192 Args: 

193 query: Query object (SQLAlchemy, Django ORM, etc.) 

194 value: Filter value to apply 

195 

196 Returns: 

197 Modified query with filter applied 

198 """ 

199 

200 def to_spec(self, value: Any) -> Any | None: 

201 """ 

202 Convert filter value to a specification object. 

203 

204 Args: 

205 value: Filter value from request 

206 

207 Returns: 

208 SpecificationProtocol object (e.g. EqualSpec) or None 

209 """ 

210 

211 parsed = self.from_url_param(value) 

212 if parsed is None or parsed == "": 

213 return None 

214 return EqualSpec(self.name, parsed) 

215 

216 def to_url_param(self, value: Any) -> str: 

217 """ 

218 Convert filter value to URL parameter string. 

219 

220 Args: 

221 value: Filter value 

222 

223 Returns: 

224 String representation safe for URL parameters 

225 """ 

226 if value is None: 

227 return "" 

228 return str(value) 

229 

230 def from_url_param(self, param: str) -> Any: 

231 """ 

232 Parse filter value from URL parameter. 

233 

234 Args: 

235 param: URL parameter string 

236 

237 Returns: 

238 Parsed value suitable for filtering logic 

239 """ 

240 return param if param else None 

241 

242 def set_state(self, state: Any) -> None: 

243 """Set the table state for URL generation.""" 

244 self._state = state 

245 

246 def get_state(self) -> Any: 

247 """Get the table state for URL generation.""" 

248 return getattr(self, "_state", None) 

249 

250 def set_htmx_attrs(self, attrs: dict[str, str]) -> None: 

251 """Set canonical HTMX attributes to use instead of building inline.""" 

252 self._htmx_attrs = attrs 

253 

254 def get_htmx_attrs(self) -> dict[str, str] | None: 

255 """Get stored canonical HTMX attributes if set.""" 

256 return getattr(self, "_htmx_attrs", None) 

257 

258 def build_state_url(self, new_value: Any = None, base_url: str = "") -> str: 

259 """ 

260 Build a complete URL with all state parameters baked in. 

261 

262 This follows the Pagination pattern: construct URLs that contain 

263 the full state, eliminating the need for hx-include. 

264 

265 Args: 

266 new_value: The new value for this filter (replaces current) 

267 base_url: Base URL path (defaults to state's resource prefix) 

268 

269 Returns: 

270 Complete URL with all query parameters 

271 """ 

272 from urllib.parse import urlencode 

273 

274 state = getattr(self, "_state", None) 

275 if not state: 

276 # Fallback: just return base with filter param 

277 if new_value: 

278 return f"{base_url}?{self.name}={new_value}" 

279 return base_url or "?" 

280 

281 # Get all current state params 

282 params = state.to_query_params() 

283 

284 # Update with new filter value 

285 if new_value is not None and new_value != "": 

286 params[self.name] = str(new_value) 

287 else: 

288 # Remove filter if value is empty 

289 params.pop(self.name, None) 

290 

291 # Reset page when filtering 

292 params.pop("page", None) 

293 params.pop("cursor", None) 

294 

295 # Construct URL 

296 resource_prefix = getattr(state, "_resource_prefix", None) or base_url 

297 query = urlencode(params, doseq=True) if params else "" 

298 

299 if query: 

300 return f"{resource_prefix}?{query}" 

301 return f"{resource_prefix}/" if resource_prefix else "?"