Coverage for src/lexigram/admin/views/_views.py: 88%

164 statements  

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

1"""Alternative resource list view implementations — Calendar, Kanban, Tree. 

2 

3EXPERIMENTAL: These view types are paused. See the admin evolution plan 

4for the path forward (Page abstraction). 

5""" 

6 

7from __future__ import annotations 

8 

9from collections import defaultdict 

10from dataclasses import dataclass, field 

11from datetime import date, datetime 

12from typing import Any, Protocol 

13 

14from lexigram.ui import el, render_to_string 

15 

16 

17class ResourceView(Protocol): 

18 """Protocol for alternative resource list views.""" 

19 

20 def render(self, records: list[dict[str, Any]]) -> str: 

21 """Render the view to an HTML string. 

22 

23 Args: 

24 records: List of resource record dicts. 

25 

26 Returns: 

27 HTML fragment ready for insertion into the admin layout. 

28 """ 

29 ... 

30 

31 @property 

32 def view_type(self) -> str: 

33 """Machine-readable view type identifier.""" 

34 ... 

35 

36 

37@dataclass 

38class CalendarView: 

39 """Groups resource records onto a monthly calendar grid. 

40 

41 Args: 

42 date_field: Field containing the date/datetime for placement. 

43 title_field: Field used as the card label. 

44 month: Target month (1-12). Defaults to current month. 

45 year: Target year. Defaults to current year. 

46 css_class: Extra CSS class applied to the calendar container. 

47 """ 

48 

49 date_field: str = "created_at" 

50 title_field: str = "name" 

51 month: int = field(default_factory=lambda: date.today().month) 

52 year: int = field(default_factory=lambda: date.today().year) 

53 css_class: str = "" 

54 

55 view_type: str = "calendar" 

56 

57 def _parse_date(self, value: Any) -> date | None: 

58 if isinstance(value, datetime): 

59 return value.date() 

60 if isinstance(value, date): 

61 return value 

62 if isinstance(value, str): 

63 for fmt in ("%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): 

64 try: 

65 return datetime.strptime(value[:19], fmt).date() 

66 except ValueError: 

67 pass 

68 # Last resort: try just the date portion 

69 try: 

70 return datetime.strptime(value[:10], "%Y-%m-%d").date() 

71 except ValueError: 

72 pass 

73 return None 

74 

75 def _days_in_month(self) -> int: 

76 import calendar as cal 

77 

78 return cal.monthrange(self.year, self.month)[1] 

79 

80 def _first_weekday(self) -> int: 

81 """Return weekday of the 1st (Monday=0, Sunday=6).""" 

82 return date(self.year, self.month, 1).weekday() 

83 

84 def group_by_day( 

85 self, records: list[dict[str, Any]] 

86 ) -> dict[int, list[dict[str, Any]]]: 

87 """Group records by day-of-month for this calendar's month/year. 

88 

89 Args: 

90 records: Resource records to group. 

91 

92 Returns: 

93 Dict mapping day number (1-31) → list of matching records. 

94 """ 

95 grouped: dict[int, list[dict[str, Any]]] = defaultdict(list) 

96 for record in records: 

97 raw = record.get(self.date_field) 

98 d = self._parse_date(raw) 

99 if d and d.year == self.year and d.month == self.month: 

100 grouped[d.day].append(record) 

101 return dict(grouped) 

102 

103 def render(self, records: list[dict[str, Any]]) -> str: 

104 """Render records onto a monthly calendar grid. 

105 

106 Args: 

107 records: Resource records. 

108 

109 Returns: 

110 HTML fragment with a month calendar grid. 

111 """ 

112 grouped = self.group_by_day(records) 

113 days_in_month = self._days_in_month() 

114 first_weekday = self._first_weekday() # Monday=0 

115 

116 month_name = date(self.year, self.month, 1).strftime("%B %Y") 

117 day_headers = [ 

118 el("th", d, class_="cal-header") 

119 for d in ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] 

120 ] 

121 

122 cells: list[Any] = [el("td") for _ in range(first_weekday)] 

123 for day in range(1, days_in_month + 1): 

124 day_records = grouped.get(day, []) 

125 events = [ 

126 el("div", r.get(self.title_field, r.get("id", "")), class_="cal-event") 

127 for r in day_records 

128 ] 

129 day_num_span = el("span", str(day), class_="cal-day-num") 

130 cells.append(el("td", day_num_span, *events, class_="cal-day")) 

131 

132 # Pad to complete last row 

133 remainder = (first_weekday + days_in_month) % 7 

134 if remainder: 

135 cells.extend([el("td") for _ in range(7 - remainder)]) 

136 

137 rows = [] 

138 for i in range(0, len(cells), 7): 

139 row_cells = cells[i : i + 7] 

140 rows.append(el("tr", *row_cells)) 

141 

142 extra = f" {self.css_class}" if self.css_class else "" 

143 return render_to_string( 

144 el( 

145 "div", 

146 el("div", month_name, class_="cal-title"), 

147 el( 

148 "table", 

149 el("thead", el("tr", *day_headers)), 

150 el("tbody", *rows), 

151 class_="cal-table", 

152 ), 

153 class_=f"admin-calendar-view{extra}", 

154 ) 

155 ) 

156 

157 

158@dataclass 

159class KanbanView: 

160 """Groups resource records into status-based kanban columns. 

161 

162 Args: 

163 status_field: Field used to determine which column a record belongs to. 

164 columns: Ordered list of status values / column names. 

165 title_field: Field used as the card label. 

166 subtitle_field: Optional field shown as a subtitle on the card. 

167 css_class: Extra CSS class applied to the board container. 

168 """ 

169 

170 status_field: str = "status" 

171 columns: list[str] = field(default_factory=lambda: ["todo", "in_progress", "done"]) 

172 title_field: str = "name" 

173 subtitle_field: str = "" 

174 css_class: str = "" 

175 

176 view_type: str = "kanban" 

177 

178 def group_by_status( 

179 self, records: list[dict[str, Any]] 

180 ) -> dict[str, list[dict[str, Any]]]: 

181 """Group records by their status value. 

182 

183 Records whose status is not in :attr:`columns` are placed in an 

184 ``"_other"`` bucket. 

185 

186 Args: 

187 records: Resource records. 

188 

189 Returns: 

190 Dict mapping status → list of records. 

191 """ 

192 grouped: dict[str, list[dict[str, Any]]] = {col: [] for col in self.columns} 

193 grouped["_other"] = [] 

194 for record in records: 

195 status = str(record.get(self.status_field, "")) 

196 if status in grouped: 

197 grouped[status].append(record) 

198 else: 

199 grouped["_other"].append(record) 

200 return grouped 

201 

202 def render(self, records: list[dict[str, Any]]) -> str: 

203 """Render records as a kanban board. 

204 

205 Args: 

206 records: Resource records. 

207 

208 Returns: 

209 HTML fragment with draggable column cards (Alpine/HTMX ready). 

210 """ 

211 grouped = self.group_by_status(records) 

212 

213 columns: list[Any] = [] 

214 for col in self.columns: 

215 col_records = grouped.get(col, []) 

216 cards: list[Any] = [] 

217 for record in col_records: 

218 title = record.get(self.title_field, record.get("id", "")) 

219 subtitle = ( 

220 record.get(self.subtitle_field, "") if self.subtitle_field else "" 

221 ) 

222 record_id = record.get("id", "") 

223 subtitle_el = ( 

224 el("p", subtitle, class_="kanban-subtitle") if subtitle else None 

225 ) 

226 card_children: list[Any] = [ 

227 el("div", title, class_="kanban-card-title") 

228 ] 

229 if subtitle_el is not None: 

230 card_children.append(subtitle_el) 

231 cards.append( 

232 el( 

233 "div", 

234 *card_children, 

235 **{ 

236 "class": "kanban-card", 

237 "data-id": str(record_id), 

238 "draggable": "true", 

239 }, 

240 ) 

241 ) 

242 

243 col_label = col.replace("_", " ").title() 

244 columns.append( 

245 el( 

246 "div", 

247 el( 

248 "div", 

249 f"{col_label} ", 

250 el("span", str(len(col_records)), class_="kanban-count"), 

251 class_="kanban-col-header", 

252 ), 

253 el("div", *cards, class_="kanban-cards"), 

254 **{ 

255 "class": "kanban-column", 

256 "data-status": col, 

257 "hx-post": "", 

258 "hx-trigger": "drop", 

259 }, 

260 ) 

261 ) 

262 

263 extra = f" {self.css_class}" if self.css_class else "" 

264 return render_to_string( 

265 el( 

266 "div", 

267 *columns, 

268 **{"class": f"admin-kanban-view{extra}", "x-data": "kanbanBoard()"}, 

269 ) 

270 ) 

271 

272 

273@dataclass 

274class TreeView: 

275 """Renders resource records as a collapsible parent/child tree. 

276 

277 Args: 

278 id_field: Field containing the record's unique ID. 

279 parent_field: Field containing the parent record's ID (``None`` for 

280 root nodes). 

281 label_field: Field used as the node label. 

282 css_class: Extra CSS class applied to the tree container. 

283 """ 

284 

285 id_field: str = "id" 

286 parent_field: str = "parent_id" 

287 label_field: str = "name" 

288 css_class: str = "" 

289 

290 view_type: str = "tree" 

291 

292 def build_tree( 

293 self, records: list[dict[str, Any]] 

294 ) -> dict[Any, list[dict[str, Any]]]: 

295 """Build a parent → children mapping from flat records. 

296 

297 Args: 

298 records: Flat list of resource records. 

299 

300 Returns: 

301 Dict mapping parent_id → list of direct children. Root nodes 

302 are stored under the key ``None``. 

303 """ 

304 children: dict[Any, list[dict[str, Any]]] = defaultdict(list) 

305 for record in records: 

306 parent_id = record.get(self.parent_field) 

307 children[parent_id].append(record) 

308 return dict(children) 

309 

310 def _render_nodes( 

311 self, 

312 children_map: dict[Any, list[dict[str, Any]]], 

313 parent_id: Any, 

314 depth: int = 0, 

315 ) -> list[Any]: 

316 nodes = children_map.get(parent_id, []) 

317 if not nodes: 

318 return [] 

319 

320 result: list[Any] = [] 

321 for node in nodes: 

322 node_id = node.get(self.id_field, "") 

323 label = node.get(self.label_field, str(node_id)) 

324 has_children = node_id in children_map 

325 toggle_attrs: dict[str, Any] = {} 

326 if has_children: 

327 toggle_attrs = {"x-data": "{open: true}", "@click": "open = !open"} 

328 child_nodes = self._render_nodes(children_map, node_id, depth + 1) 

329 children_ul = ( 

330 el("ul", *child_nodes, class_="tree-children") if child_nodes else None 

331 ) 

332 label_el = el("div", label, class_="tree-node-label", **toggle_attrs) 

333 li_children: list[Any] = [label_el] 

334 if children_ul: 

335 li_children.append(children_ul) 

336 result.append( 

337 el( 

338 "li", 

339 *li_children, 

340 **{ 

341 "class": "tree-node", 

342 "data-id": str(node_id), 

343 "data-depth": str(depth), 

344 }, 

345 ) 

346 ) 

347 return result 

348 

349 def render(self, records: list[dict[str, Any]]) -> str: 

350 """Render records as a collapsible tree. 

351 

352 Args: 

353 records: Resource records. 

354 

355 Returns: 

356 HTML fragment with a nested ``<ul>`` tree (Alpine ``x-data`` for 

357 collapse/expand). 

358 """ 

359 children_map = self.build_tree(records) 

360 root_nodes = self._render_nodes(children_map, None) 

361 

362 extra = f" {self.css_class}" if self.css_class else "" 

363 return render_to_string( 

364 el( 

365 "div", 

366 el("ul", *root_nodes, class_="tree-root"), 

367 class_=f"admin-tree-view{extra}", 

368 ) 

369 ) 

370 

371 

372@dataclass 

373class AuditLogView: 

374 """Renders audit log entries as a timeline of user actions. 

375 

376 Args: 

377 user_field: Field for the user/actor name. 

378 action_field: Field for the action description. 

379 timestamp_field: Field for the event timestamp. 

380 css_class: Extra CSS class on container. 

381 """ 

382 

383 user_field: str = "user" 

384 action_field: str = "action" 

385 timestamp_field: str = "created_at" 

386 css_class: str = "" 

387 view_type: str = "audit_log" 

388 

389 def render(self, records: list[dict[str, Any]]) -> str: 

390 """Render audit log entries as a ``<ul class="audit-timeline">`` list. 

391 

392 Each entry contains a timestamp badge, a user avatar initial, and the 

393 action text. 

394 

395 Args: 

396 records: List of audit-log record dicts. 

397 

398 Returns: 

399 HTML fragment ready for insertion into the admin layout. 

400 """ 

401 items: list[Any] = [] 

402 for record in records: 

403 timestamp = str(record.get(self.timestamp_field, "")) 

404 user = str(record.get(self.user_field, "?")) 

405 action = str(record.get(self.action_field, "")) 

406 

407 # Derive initials for the avatar (first letter of first word) 

408 avatar_letter = user[0].upper() if user else "?" 

409 

410 timestamp_badge = el( 

411 "span", 

412 timestamp, 

413 class_="audit-timestamp text-xs text-muted-foreground tabular-nums", 

414 ) 

415 avatar = el( 

416 "span", 

417 avatar_letter, 

418 class_=( 

419 "audit-avatar inline-flex items-center justify-center " 

420 "w-7 h-7 rounded-full bg-primary-100 dark:bg-primary-900 " 

421 "text-primary-700 dark:text-primary-300 text-xs font-semibold " 

422 "shrink-0" 

423 ), 

424 title=user, 

425 ) 

426 action_text = el( 

427 "span", 

428 action, 

429 class_="audit-action text-sm text-foreground", 

430 ) 

431 entry_inner = el( 

432 "div", 

433 avatar, 

434 el( 

435 "div", 

436 action_text, 

437 timestamp_badge, 

438 class_="flex flex-col gap-0.5", 

439 ), 

440 class_="flex items-start gap-2", 

441 ) 

442 items.append(el("li", entry_inner, class_="audit-entry py-2")) 

443 

444 extra = f" {self.css_class}" if self.css_class else "" 

445 return render_to_string( 

446 el( 

447 "ul", 

448 *items, 

449 class_=f"audit-timeline divide-y divide-border{extra}", 

450 ) 

451 ) 

452 

453 

454__all__ = [ 

455 "AuditLogView", 

456 "CalendarView", 

457 "KanbanView", 

458 "ResourceView", 

459 "TreeView", 

460]