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

83 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +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.filter_specs import EqualSpec 

24 

25if TYPE_CHECKING: 

26 from collections.abc import Callable 

27 

28 

29class Filter: 

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

31 

32 This class implements the Builder pattern, allowing configuration 

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

34 to enable fluent syntax. 

35 

36 Attributes: 

37 name: Filter field name (query parameter name) 

38 label: Display label for filter UI 

39 

40 Example: 

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

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

43 """ 

44 

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

46 """ 

47 Initialize a filter. 

48 

49 Args: 

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

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

52 """ 

53 # Initialize filter state 

54 self.name = name 

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

56 self._placeholder: str | None = None 

57 self._default: Any = None 

58 self.value: Any = None 

59 self.errors: list[str] = [] 

60 

61 # Additional Filter-specific state 

62 self._default_callback: Callable | None = None 

63 self._visible = True 

64 self._visible_callback: Callable | None = None 

65 

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

67 """Set default value for the filter. 

68 

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

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

71 

72 Args: 

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

74 

75 Returns: 

76 Self for method chaining 

77 

78 Example: 

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

80 >>> 

81 >>> # Dynamic default 

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

83 """ 

84 if callable(value): 

85 self._default_callback = value 

86 self._default = None 

87 else: 

88 self._default = value 

89 self._default_callback = None 

90 return self 

91 

92 def get_default(self) -> Any: 

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

94 

95 Returns: 

96 Default value or result of callback 

97 """ 

98 if self._default_callback: 

99 return self._default_callback() 

100 return self._default 

101 

102 def placeholder(self, text: str) -> Self: 

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

104 

105 Args: 

106 text: Placeholder text to display 

107 

108 Returns: 

109 Self for method chaining 

110 

111 Example: 

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

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

114 """ 

115 self._placeholder = text 

116 return self 

117 

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

119 """ 

120 Control filter visibility. 

121 

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

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

124 

125 Args: 

126 visible: Boolean or callable that returns boolean 

127 

128 Returns: 

129 Self for method chaining 

130 

131 Example: 

132 >>> # Static visibility 

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

134 >>> 

135 >>> # Dynamic visibility 

136 >>> def show_if_manager(context): 

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

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

139 """ 

140 if callable(visible): 

141 self._visible_callback = visible 

142 else: 

143 self._visible = visible 

144 return self 

145 

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

147 """Check if filter should be visible. 

148 

149 Args: 

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

151 

152 Returns: 

153 True if filter should be displayed 

154 """ 

155 if self._visible_callback: 

156 return self._visible_callback(context or {}) 

157 return self._visible 

158 

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

160 """ 

161 Extract filter value from request parameters. 

162 

163 Args: 

164 request_params: Request query parameters dict 

165 

166 Returns: 

167 Filter value or default if not present 

168 """ 

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

170 

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

172 """ 

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

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

175 """ 

176 return [self.name] 

177 

178 @abstractmethod 

179 def render(self, current_value: Any = None, url: str | None = None) -> str: 

180 """ 

181 Render the filter UI as HTML. 

182 

183 Args: 

184 current_value: Current filter value to display 

185 

186 Returns: 

187 HTML string of the filter component 

188 """ 

189 

190 @abstractmethod 

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

192 """ 

193 Apply filter to a query. 

194 

195 Args: 

196 query: Query object (SQLAlchemy, etc.) 

197 value: Filter value to apply 

198 

199 Returns: 

200 Modified query with filter applied 

201 """ 

202 

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

204 """ 

205 Convert filter value to a specification object. 

206 

207 Args: 

208 value: Filter value from request 

209 

210 Returns: 

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

212 """ 

213 

214 parsed = self.from_url_param(value) 

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

216 return None 

217 return EqualSpec(self.name, parsed) 

218 

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

220 """ 

221 Convert filter value to URL parameter string. 

222 

223 Args: 

224 value: Filter value 

225 

226 Returns: 

227 String representation safe for URL parameters 

228 """ 

229 if value is None: 

230 return "" 

231 return str(value) 

232 

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

234 """ 

235 Parse filter value from URL parameter. 

236 

237 Args: 

238 param: URL parameter string 

239 

240 Returns: 

241 Parsed value suitable for filtering logic 

242 """ 

243 return param if param else None 

244 

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

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

247 self._state = state 

248 

249 def get_state(self) -> Any: 

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

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

252 

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

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

255 self._htmx_attrs = attrs 

256 

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

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

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

260 

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

262 """ 

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

264 

265 This follows the Pagination pattern: construct URLs that contain 

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

267 

268 Args: 

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

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

271 

272 Returns: 

273 Complete URL with all query parameters 

274 """ 

275 from urllib.parse import urlencode 

276 

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

278 if not state: 

279 # Fallback: just return base with filter param 

280 if new_value: 

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

282 return base_url or "?" 

283 

284 # Get all current state params 

285 params = state.to_query_params() 

286 

287 # Update with new filter value 

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

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

290 else: 

291 # Remove filter if value is empty 

292 params.pop(self.name, None) 

293 

294 # Reset page when filtering 

295 params.pop("page", None) 

296 params.pop("cursor", None) 

297 

298 # Construct URL 

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

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

301 

302 if query: 

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

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