Coverage for src/lexigram/admin/handlers/admin_command_handlers.py: 0%
138 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"""Command handlers for lexigram-admin.
3Handlers process commands and emit corresponding events.
4They integrate with lexigram-events CommandBusProtocol.
5"""
7from __future__ import annotations
9from typing import TYPE_CHECKING, Any
11from lexigram.admin.events import (
12 BulkOperationCompleted,
13 ExportCompleted,
14 ExportStarted,
15 ResourceCreated,
16 ResourceDeleted,
17 ResourceUpdated,
18)
19from lexigram.admin.exceptions import AdminError, NotFoundError
20from lexigram.contracts.events import CommandHandlerProtocol as BaseCommandHandler
21from lexigram.contracts.events import EventBusProtocol
22from lexigram.di.decorators import inject
23from lexigram.logging import get_logger
24from lexigram.result import Err, Ok, Result
26logger = get_logger(__name__)
28if TYPE_CHECKING:
29 from lexigram.admin.cqrs.commands import (
30 BulkDeleteResources,
31 CreateResource,
32 DeleteResource,
33 ExportResources,
34 UpdateResource,
35 )
36 from lexigram.contracts.data.data_source import DataSourceProtocol
39@inject
40class ResourceCommandHandler(BaseCommandHandler):
41 """Handler for resource CRUD commands."""
43 def __init__(
44 self,
45 data_sources: dict[str, DataSourceProtocol],
46 event_bus: EventBusProtocol | None = None,
47 ):
48 self.data_sources = data_sources
49 self.event_bus = event_bus
51 def get_data_source(self, resource_type: str) -> DataSourceProtocol:
52 """Get data source for resource type."""
53 if resource_type not in self.data_sources:
54 raise ValueError(f"Unknown resource type: {resource_type}")
55 return self.data_sources[resource_type]
57 async def handle_create(self, command: CreateResource) -> Result[Any, AdminError]:
58 """Handle CreateResource command."""
59 try:
60 data_source = self.get_data_source(command.resource_type)
62 result = await data_source.create(command.data)
63 resource_id = getattr(result, "id", None)
65 if self.event_bus:
66 event = ResourceCreated(
67 resource_type=command.resource_type,
68 resource_id=resource_id,
69 data=command.data,
70 actor_id=command.user_id,
71 correlation_id=command.correlation_id,
72 )
73 await self.event_bus.publish(event)
75 logger.info(
76 "Created %s: %s",
77 command.resource_type,
78 resource_id,
79 )
81 return Ok(result)
83 except ValueError as e:
84 logger.exception("Failed to create %s", command.resource_type)
85 return Err(AdminError(str(e)))
87 async def handle_update(
88 self, command: UpdateResource
89 ) -> Result[Any, NotFoundError | AdminError]:
90 """Handle UpdateResource command."""
91 try:
92 data_source = self.get_data_source(command.resource_type)
94 old_item = await data_source.find_one(command.resource_id)
95 if old_item is None:
96 return Err(NotFoundError(f"{command.resource_type} not found"))
98 result = await data_source.update(command.resource_id, command.data)
100 changes = {}
101 for key, new_val in command.data.items():
102 old_val = getattr(old_item, key, None)
103 if old_val != new_val:
104 changes[key] = (old_val, new_val)
106 if self.event_bus and changes:
107 event = ResourceUpdated(
108 resource_type=command.resource_type,
109 resource_id=command.resource_id,
110 changes=changes,
111 actor_id=command.user_id,
112 correlation_id=command.correlation_id,
113 )
114 await self.event_bus.publish(event)
116 logger.info(
117 "Updated %s: %s",
118 command.resource_type,
119 command.resource_id,
120 )
122 return Ok(result)
124 except ValueError as e:
125 logger.exception("Failed to update %s", command.resource_type)
126 return Err(AdminError(str(e)))
128 async def handle_delete(
129 self, command: DeleteResource
130 ) -> Result[Any, NotFoundError | AdminError]:
131 """Handle DeleteResource command."""
132 try:
133 data_source = self.get_data_source(command.resource_type)
135 success = await data_source.delete(command.resource_id)
137 if not success:
138 return Err(NotFoundError(f"{command.resource_type} not found"))
140 if self.event_bus:
141 event = ResourceDeleted(
142 resource_type=command.resource_type,
143 resource_id=command.resource_id,
144 soft_delete=command.soft_delete,
145 actor_id=command.user_id,
146 correlation_id=command.correlation_id,
147 )
148 await self.event_bus.publish(event)
150 logger.info(
151 "Deleted %s: %s",
152 command.resource_type,
153 command.resource_id,
154 )
156 return Ok(None)
158 except ValueError as e:
159 logger.exception("Failed to delete %s", command.resource_type)
160 return Err(AdminError(str(e)))
162 async def handle_bulk_delete(
163 self, command: BulkDeleteResources
164 ) -> Result[Any, AdminError]:
165 """Handle BulkDeleteResources command."""
166 try:
167 data_source = self.get_data_source(command.resource_type)
169 count = await data_source.bulk_delete(command.resource_ids) # type: ignore[arg-type]
171 if self.event_bus:
172 event = BulkOperationCompleted(
173 operation="delete",
174 resource_type=command.resource_type,
175 resource_ids=command.resource_ids,
176 success_count=count,
177 failure_count=len(command.resource_ids) - count,
178 actor_id=command.user_id,
179 correlation_id=command.correlation_id,
180 )
181 await self.event_bus.publish(event)
183 logger.info(
184 "Bulk deleted %d %s",
185 count,
186 command.resource_type,
187 )
189 return Ok({"deleted": count})
191 except ValueError as e:
192 logger.exception("Failed to bulk delete %s", command.resource_type)
193 return Err(AdminError(str(e)))
196@inject
197class ExportCommandHandler(BaseCommandHandler):
198 """Handler for export commands."""
200 def __init__(
201 self,
202 data_sources: dict[str, DataSourceProtocol],
203 event_bus: EventBusProtocol | None = None,
204 export_dir: str | None = None,
205 ):
206 self.data_sources = data_sources
207 self.event_bus = event_bus
208 if export_dir:
209 self.export_dir = export_dir
210 else:
211 from pathlib import Path
212 import tempfile
214 temp_dir = Path(tempfile.gettempdir()) / "lexigram_exports"
215 temp_dir.mkdir(parents=True, exist_ok=True)
216 self.export_dir = str(temp_dir)
218 async def handle_export(self, command: ExportResources) -> Result[Any, AdminError]:
219 """Handle ExportResources command."""
220 import csv
221 from pathlib import Path
222 from uuid import uuid4
224 from lexigram import serialization as json
226 export_id = str(uuid4())
228 try:
229 if command.resource_type not in self.data_sources:
230 return Err(
231 AdminError(f"Unknown resource type: {command.resource_type}"),
232 )
234 data_source = self.data_sources[command.resource_type]
236 from lexigram.admin.data.query import QuerySpec
238 qs = QuerySpec()
239 for field, value in command.query.items():
240 qs = qs.with_where_eq(field, value)
241 if self.event_bus:
242 await self.event_bus.publish(
243 ExportStarted(
244 export_id=export_id,
245 resource_type=command.resource_type,
246 format=command.format,
247 actor_id=command.user_id,
248 ),
249 )
251 result = await data_source.find_many(qs) # type: ignore[arg-type]
253 import aiofiles
255 export_path = Path(self.export_dir) / f"{export_id}.{command.format}"
256 export_path.parent.mkdir(parents=True, exist_ok=True)
258 if command.format == "json":
259 items = [
260 {k: v for k, v in vars(item).items() if not k.startswith("_")}
261 for item in result.items # type: ignore[attr-defined]
262 ]
263 import asyncio
265 content = await asyncio.to_thread(
266 json.dumps_str,
267 items,
268 default=str,
269 indent=2,
270 )
271 async with aiofiles.open(export_path, "w") as f:
272 await f.write(content)
274 elif command.format == "csv":
275 import io
277 output = io.StringIO()
278 if result.items: # type: ignore[attr-defined]
279 first_item = result.items[0] # type: ignore[attr-defined]
280 fieldnames = command.columns or [
281 k for k in vars(first_item) if not k.startswith("_")
282 ]
283 writer = csv.DictWriter(output, fieldnames=fieldnames)
284 writer.writeheader()
285 for item in result.items: # type: ignore[attr-defined]
286 row = {k: getattr(item, k, "") for k in fieldnames}
287 writer.writerow(row)
289 async with aiofiles.open(export_path, "w") as f:
290 await f.write(output.getvalue())
292 file_size = export_path.stat().st_size
294 total_records = (
295 result.total
296 if hasattr(result, "total")
297 else len(result.items)
298 if hasattr(result, "items")
299 else 0
300 )
302 if self.event_bus:
303 await self.event_bus.publish(
304 ExportCompleted(
305 export_id=export_id,
306 resource_type=command.resource_type,
307 format=command.format,
308 total_records=total_records,
309 file_path=str(export_path),
310 file_size=file_size,
311 actor_id=command.user_id,
312 ),
313 )
315 logger.info(
316 "Exported %d %s to %s",
317 total_records,
318 command.resource_type,
319 export_path,
320 )
322 return Ok(
323 {
324 "export_id": export_id,
325 "file_path": str(export_path),
326 "total_records": total_records,
327 },
328 )
330 except ValueError as e:
331 logger.exception("Export failed")
332 return Err(AdminError(str(e)))