Coverage for src/lexigram/admin/views/_views.py: 0%
164 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Alternative resource list view implementations — Calendar, Kanban, Tree.
3EXPERIMENTAL: These view types are paused. See the admin evolution plan
4for the path forward (Page abstraction).
5"""
7from __future__ import annotations
9from collections import defaultdict
10from dataclasses import dataclass, field
11from datetime import date, datetime
12from typing import Any, Protocol
14from lexigram.ui import el, render_to_string
17class ResourceView(Protocol):
18 """Protocol for alternative resource list views."""
20 def render(self, records: list[dict[str, Any]]) -> str:
21 """Render the view to an HTML string.
23 Args:
24 records: List of resource record dicts.
26 Returns:
27 HTML fragment ready for insertion into the admin layout.
28 """
29 ...
31 @property
32 def view_type(self) -> str:
33 """Machine-readable view type identifier."""
34 ...
37@dataclass
38class CalendarView:
39 """Groups resource records onto a monthly calendar grid.
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 """
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 = ""
55 view_type: str = "calendar"
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
75 def _days_in_month(self) -> int:
76 import calendar as cal
78 return cal.monthrange(self.year, self.month)[1]
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()
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.
89 Args:
90 records: Resource records to group.
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)
103 def render(self, records: list[dict[str, Any]]) -> str:
104 """Render records onto a monthly calendar grid.
106 Args:
107 records: Resource records.
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
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 ]
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"))
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)])
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))
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 )
158@dataclass
159class KanbanView:
160 """Groups resource records into status-based kanban columns.
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 """
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 = ""
176 view_type: str = "kanban"
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.
183 Records whose status is not in :attr:`columns` are placed in an
184 ``"_other"`` bucket.
186 Args:
187 records: Resource records.
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
202 def render(self, records: list[dict[str, Any]]) -> str:
203 """Render records as a kanban board.
205 Args:
206 records: Resource records.
208 Returns:
209 HTML fragment with draggable column cards (Alpine/HTMX ready).
210 """
211 grouped = self.group_by_status(records)
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 )
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 )
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 )
273@dataclass
274class TreeView:
275 """Renders resource records as a collapsible parent/child tree.
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 """
285 id_field: str = "id"
286 parent_field: str = "parent_id"
287 label_field: str = "name"
288 css_class: str = ""
290 view_type: str = "tree"
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.
297 Args:
298 records: Flat list of resource records.
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)
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 []
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
349 def render(self, records: list[dict[str, Any]]) -> str:
350 """Render records as a collapsible tree.
352 Args:
353 records: Resource records.
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)
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 )
372@dataclass
373class AuditLogView:
374 """Renders audit log entries as a timeline of user actions.
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 """
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"
389 def render(self, records: list[dict[str, Any]]) -> str:
390 """Render audit log entries as a ``<ul class="audit-timeline">`` list.
392 Each entry contains a timestamp badge, a user avatar initial, and the
393 action text.
395 Args:
396 records: List of audit-log record dicts.
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, ""))
407 # Derive initials for the avatar (first letter of first word)
408 avatar_letter = user[0].upper() if user else "?"
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"))
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 )
454__all__ = [
455 "AuditLogView",
456 "CalendarView",
457 "KanbanView",
458 "ResourceView",
459 "TreeView",
460]