Coverage for src/lexigram/admin/controllers/resource/bulk.py: 0%
78 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"""Bulk actions for the resource controller."""
3from __future__ import annotations
5from typing import TYPE_CHECKING, Any
7from starlette.requests import Request
8from starlette.responses import HTMLResponse, RedirectResponse, Response
10from lexigram.admin.controllers.resource.meta import ResourceMeta
11from lexigram.admin.state.context import AdminContextManager
12from lexigram.admin.ui.organisms.admin_slide_over import render_bulk_delete_confirm
13from lexigram.ui import el, render_to_string
18class ResourceBulkMixin:
19 """Bulk action confirmations and execution."""
21 # Host attributes provided by sibling mixins on ResourceController.
22 meta: ResourceMeta
24 get_data_source: Any
26 async def bulk_delete_confirm(self, request: Request) -> Response:
27 """Render a bulk delete confirmation slide-over panel.
29 Called via HTMX GET from a BulkAction button. Reads the selected
30 record IDs from the query string (passed via ``hx-include`` of the
31 checked checkboxes) and renders a slide-over confirmation panel.
32 """
33 ids = request.query_params.getlist("ids")
34 record_count = len(ids)
36 bulk_url = f"{self.meta.prefix}/{self.meta.name}/bulk"
37 html = render_bulk_delete_confirm(
38 record_count=record_count,
39 bulk_url=bulk_url,
40 )
41 return HTMLResponse(html)
43 async def bulk_purge_confirm(self, request: Request) -> Response:
44 """Render a bulk purge confirmation slide-over panel.
46 Called via HTMX GET from a PurgeBulkAction button. Reads the
47 selected record IDs from the query string and renders a
48 slide-over confirmation panel posting ``action=purge``.
49 """
50 ids = request.query_params.getlist("ids")
51 record_count = len(ids)
53 bulk_url = f"{self.meta.prefix}/{self.meta.name}/bulk"
54 html = render_bulk_delete_confirm(
55 record_count=record_count,
56 bulk_url=bulk_url,
57 action="purge",
58 title="Purge Records",
59 heading="Confirm Bulk Purge",
60 confirm_phrase="PURGE",
61 subtitle=f"Purging {record_count} record{'s' if record_count != 1 else ''}",
62 confirm_label="Purge",
63 message=(
64 f"You are about to permanently purge <strong>{record_count}</strong> "
65 f"record{'s' if record_count != 1 else ''}. "
66 "This action <strong>cannot be undone</strong>."
67 ),
68 )
69 return HTMLResponse(html)
71 async def bulk_restore_confirm(self, request: Request) -> Response:
72 """Render a bulk restore confirmation slide-over panel.
74 Called via HTMX GET from a RestoreBulkAction button. Reads the
75 selected record IDs from the query string and renders a
76 slide-over confirmation panel posting ``action=restore``.
77 """
78 ids = request.query_params.getlist("ids")
79 record_count = len(ids)
81 bulk_url = f"{self.meta.prefix}/{self.meta.name}/bulk"
82 html = render_bulk_delete_confirm(
83 record_count=record_count,
84 bulk_url=bulk_url,
85 action="restore",
86 title="Restore Records",
87 heading="Confirm Bulk Restore",
88 confirm_phrase="RESTORE",
89 subtitle=f"Restoring {record_count} record{'s' if record_count != 1 else ''}",
90 confirm_label="Restore",
91 message=(
92 f"You are about to restore <strong>{record_count}</strong> "
93 f"soft-deleted record{'s' if record_count != 1 else ''}."
94 ),
95 variant="default",
96 confirm_button_class=(
97 "inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium "
98 "text-white bg-success hover:bg-success/90 "
99 "focus:outline-none focus:ring-2 focus:ring-success focus:ring-offset-2 "
100 "transition-colors shadow-sm "
101 "disabled:opacity-50 disabled:cursor-not-allowed"
102 ),
103 )
104 return HTMLResponse(html)
105 async def bulk_action(self, request: Request) -> Response:
106 """Handle bulk actions."""
107 async with AdminContextManager(request) as ctx:
108 form_data = request.scope.get("admin_form_data")
109 if form_data is None:
110 form_data = await request.form()
111 action = form_data.get("action")
112 ids = form_data.getlist("ids")
114 if not action or not ids:
115 return HTMLResponse("Missing action or ids", status_code=400)
117 result = await self.execute_bulk_action(action, ids) # type: ignore[arg-type]
119 ctx.add_flash(result, "success")
120 if ctx.is_htmx:
121 response = HTMLResponse(render_to_string(el("p", str(result))))
122 response.headers["HX-Trigger"] = (
123 '{"refresh-list":true,"show-toast":{"message":"'
124 + result.replace('"', '\\"')
125 + '","type":"success"}}'
126 )
127 return response
129 return RedirectResponse(
130 url=f"{self.meta.prefix}/{self.meta.name}",
131 status_code=302,
132 )
134 async def execute_bulk_action(self, action: str, ids: list[str]) -> str:
135 """Execute bulk action. Override to add custom actions.
137 When the record count meets or exceeds the configured
138 ``bulk_threshold`` (from ``TasksIntegrationConfig``), the action is
139 dispatched through the tasks integration instead of running inline.
140 """
141 if self._should_dispatch_via_tasks(len(ids)):
142 return await self._dispatch_via_tasks(action, ids)
144 data_source = self.get_data_source()
146 if action == "delete":
147 count = await data_source.bulk_delete(ids)
148 return f"Deleted {count} items"
150 if action == "purge":
151 count = await data_source.bulk_delete(ids)
152 return f"Purged {count} items"
154 if action == "restore":
155 restored = 0
156 for item_id in ids:
157 updated = await data_source.update(item_id, {"deleted_at": None})
158 if updated is not None:
159 restored += 1
160 return f"Restored {restored} items"
162 return f"Unknown action: {action}"
164 def _should_dispatch_via_tasks(self, count: int) -> bool:
165 """Check if the bulk count exceeds the tasks threshold."""
166 from lexigram.admin.integrations import get as get_integration
168 tasks = get_integration("TasksIntegration")
169 if not tasks:
170 return False
171 if not tasks._enabled:
172 return False
173 return count >= tasks.threshold
175 async def _dispatch_via_tasks(self, action: str, ids: list[str]) -> str:
176 """Dispatch a bulk action through the tasks integration."""
177 from lexigram.admin.integrations import get as get_integration
179 tasks = get_integration("TasksIntegration")
180 if not tasks:
181 return "Task system unavailable"
183 result = await tasks.dispatch(
184 runner=action,
185 action_name=action,
186 record_ids=ids,
187 ctx_summary=f"Bulk {action} of {len(ids)} records",
188 )
189 return f"Scheduled bulk {action} for {len(ids)} records (task: {result.get('status', 'unknown')})"