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

95 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:18 +0800

1"""Standard filter types (range, toggle).""" 

2 

3from __future__ import annotations 

4 

5from typing import TYPE_CHECKING, Any 

6 

7from lexigram.admin.data.filter_specs import GreaterThanOrEqualSpec, LessThanOrEqualSpec 

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

9from lexigram.serialization import dumps_str 

10from lexigram.ui import Zones, el 

11 

12if TYPE_CHECKING: 

13 from collections.abc import Callable 

14 

15 

16class RangeFilter(Filter): 

17 """ 

18 Generic range filter for dates and numbers. 

19 Base class for specialized range filters. 

20 """ 

21 

22 def __init__( 

23 self, 

24 name: str = "", 

25 label: str | None = None, 

26 min_value: Any | None = None, 

27 max_value: Any | None = None, 

28 step: Any | None = None, 

29 default: Any | Callable | None = None, 

30 input_type: str = "text", 

31 ): 

32 super().__init__(name, label) 

33 self._min = min_value 

34 self._max = max_value 

35 self._step = step 

36 self._input_type = input_type 

37 if callable(default): 

38 self._default_callback = default 

39 self._default = None 

40 else: 

41 self._default = default 

42 self._default_callback = None 

43 

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

45 """Range filter uses two fields in the request.""" 

46 return [f"{self.name}_from", f"{self.name}_to"] 

47 

48 def get_default(self) -> Any: 

49 if self._default_callback: 

50 return self._default_callback() 

51 return self.default 

52 

53 def _render_input( 

54 self, 

55 suffix: str, 

56 value: Any, 

57 placeholder: str, 

58 url: str | None, 

59 ) -> str: 

60 """Internal helper to render an input field.""" 

61 from lexigram.ui import NumberInput, TextInput 

62 

63 name = f"filter_{self.name}_{suffix}" 

64 

65 # Get state and resource prefix 

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

67 resource_prefix = getattr(state, "_resource_prefix", None) if state else url 

68 base_url = resource_prefix.rstrip("/") if resource_prefix else "" 

69 

70 # Canonical HTMX attrs: prefer stored from FilterBar, else fallback 

71 stored = self.get_htmx_attrs() 

72 if stored: 

73 htmx_attrs = stored 

74 elif state: 

75 param_key = f"{self.name}_{suffix}" 

76 params = state.to_query_params() 

77 params.pop(param_key, None) 

78 params.pop("page", None) 

79 params.pop("cursor", None) 

80 htmx_attrs = { 

81 "hx-get": f"{base_url}/", 

82 "hx-target": Zones.DATA.selector, 

83 "hx-swap": Zones.DATA.swap_mode.value, 

84 "hx-select": Zones.DATA.selector, 

85 "hx-push-url": "true", 

86 "hx-vals": dumps_str(params), 

87 } 

88 else: 

89 htmx_attrs = { 

90 "hx-get": f"{base_url}/", 

91 "hx-trigger": "change", 

92 "hx-target": Zones.DATA.selector, 

93 "hx-swap": Zones.DATA.swap_mode.value, 

94 "hx-select": Zones.DATA.selector, 

95 "hx-push-url": "true", 

96 "hx-include": f"{Zones.DATA.selector} [data-state='true'], #{Zones.SEARCH.id}", 

97 "hx-params": "*", 

98 } 

99 

100 # Convert to component-friendly names (hx_*) 

101 comp_attrs = {k.replace("-", "_"): v for k, v in htmx_attrs.items()} 

102 

103 common = { 

104 "name": name, 

105 "value": value, 

106 "label": None, 

107 "placeholder": placeholder, 

108 **comp_attrs, 

109 } 

110 

111 if self._input_type == "number": 

112 return NumberInput( 

113 min=self._min, 

114 max=self._max, 

115 step=self._step, 

116 **common, 

117 ).render() 

118 

119 return TextInput(type=self._input_type, **common).render() 

120 

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

122 from_value = "" 

123 to_value = "" 

124 

125 if isinstance(current_value, dict): 

126 from_value = current_value.get("from", "") 

127 to_value = current_value.get("to", "") 

128 elif isinstance(self.value, dict): 

129 from_value = self.value.get("from", "") 

130 to_value = self.value.get("to", "") 

131 

132 from_input = self._render_input("from", from_value, "From", url) 

133 to_input = self._render_input("to", to_value, "To", url) 

134 

135 return el( 

136 "div", 

137 el( 

138 "label", 

139 self.label, 

140 class_="block text-sm font-medium text-foreground mb-1.5", 

141 ), 

142 el("div", from_input, to_input, class_="flex flex-col sm:flex-row gap-2"), 

143 class_="filter-item mb-2", 

144 ) 

145 

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

147 if not value or not isinstance(value, dict): 

148 return query 

149 

150 from_val = value.get("from") 

151 to_val = value.get("to") 

152 

153 if from_val: 

154 query = query.filter(getattr(query.model, self.name) >= from_val) 

155 if to_val: 

156 query = query.filter(getattr(query.model, self.name) <= to_val) 

157 

158 return query 

159 

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

161 from_val = request_params.get(f"{self.name}_from") 

162 to_val = request_params.get(f"{self.name}_to") 

163 

164 # Only return the range if BOTH values are present and non-empty 

165 if not from_val or not to_val: 

166 return None 

167 

168 return {"from": from_val, "to": to_val} 

169 

170 def from_url_param(self, param: Any) -> Any: 

171 return param 

172 

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

174 if not isinstance(value, dict): 

175 return None 

176 

177 from_val = value.get("from") 

178 to_val = value.get("to") 

179 

180 specs = [] 

181 if from_val: 

182 specs.append(GreaterThanOrEqualSpec(self.name, from_val)) 

183 if to_val: 

184 specs.append(LessThanOrEqualSpec(self.name, to_val)) # type: ignore[arg-type] 

185 

186 if not specs: 

187 return None 

188 

189 if len(specs) == 1: 

190 return specs[0] 

191 

192 return specs[0] & specs[1] 

193 

194 

195class NumericRangeFilter(RangeFilter): 

196 """Filter for numeric ranges.""" 

197 

198 def __init__(self, *args: Any, **kwargs: Any) -> None: 

199 kwargs["input_type"] = "number" 

200 super().__init__(*args, **kwargs)