Coverage for src/lexigram/admin/actions/standard/imports.py: 24%
97 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1"""Import actions (import, import bulk) with failed-import reports.
3Part of the ``lexigram.admin.actions.standard`` package.
4"""
6from __future__ import annotations
8import csv
9import io
10from typing import TYPE_CHECKING, Any
12from lexigram.admin.actions.base import BulkAction, HeaderAction
13from lexigram.admin.actions.standard.utils import _resolve_data_source
14from lexigram.admin.actions.exceptions import ActionError
15from lexigram.admin.actions.types import (
16 ActionColor,
17 ActionContext,
18 ConfirmationConfig,
19)
20from lexigram.result import Err, Ok, Result
22if TYPE_CHECKING:
23 from lexigram.admin.services.import_ import AdminImportService
24async def _run_import(
25 service: AdminImportService,
26 content: bytes,
27 filename: str,
28) -> Result[Any, Any]:
29 """Parse and commit an import, returning the result summary."""
30 parsed = await service.parse(content, filename)
31 if parsed.is_err():
32 return Err(parsed.unwrap_err())
33 committed = await service.commit(parsed.unwrap())
34 if committed.is_err():
35 return Err(committed.unwrap_err())
36 result = committed.unwrap()
37 payload: dict[str, Any] = {
38 "message": f"Imported {result.created} of {result.total} record(s)",
39 "created": result.created,
40 "failed": result.failed,
41 "total": result.total,
42 }
43 if result.failed:
44 reports = service.reports()
45 if reports:
46 report = reports[-1]
47 stem = report.source_filename.rpartition(".")[0] or "import"
48 payload["report_id"] = report.id
49 payload["report_filename"] = f"{stem}-import-errors.csv"
50 return Ok(payload)
51class _ImportReportMixin:
52 """Shared failed-import report download helpers for import actions.
54 Depends on ``self._import_service`` (an :class:`AdminImportService`
55 with stored reports).
56 """
58 _import_service: AdminImportService | None = None
60 def report_csv(self, report_id: str) -> str | None:
61 """Return CSV content of a stored failed-import report.
63 Args:
64 report_id: Report identifier from the import service.
66 Returns:
67 CSV content, or None when no service is configured or the
68 report id is unknown.
69 """
70 service = self._import_service
71 if service is None:
72 return None
73 report = service.get_report(report_id)
74 if report is None:
75 return None
76 return report.to_csv()
78 def report_filename(self, report_id: str) -> str | None:
79 """Derive a download filename for a stored failed-import report.
81 Args:
82 report_id: Report identifier from the import service.
84 Returns:
85 ``{source}-import-errors.csv`` filename, or None when no
86 service is configured or the report id is unknown.
87 """
88 service = self._import_service
89 if service is None:
90 return None
91 report = service.get_report(report_id)
92 if report is None:
93 return None
94 stem = report.source_filename.rpartition(".")[0] or "import"
95 return f"{stem}-import-errors.csv"
96class ImportAction(_ImportReportMixin, HeaderAction):
97 """Import records into a resource through the admin import service."""
99 def __init__(
100 self,
101 name: str = "import",
102 label: str | None = None,
103 import_service: AdminImportService | None = None,
104 data_source: Any | None = None,
105 file_content: bytes | None = None,
106 filename: str | None = None,
107 example_columns: list[str] | None = None,
108 example_filename: str = "import-example.csv",
109 ) -> None:
110 super().__init__(
111 name=name,
112 label=label or "Import",
113 icon="upload",
114 color=ActionColor.GRAY,
115 )
116 self._import_service = import_service
117 self._data_source = data_source
118 self._file_content = file_content
119 self._filename = filename
120 self._example_columns = example_columns or []
121 self._example_filename = example_filename
123 def example_csv(self) -> str:
124 """Build a header-only example CSV from ``example_columns``.
126 Mirrors Filament's ``ImportAction::exampleCsv()``. Returns an empty
127 string when no example columns are configured.
128 """
129 if not self._example_columns:
130 return ""
131 buffer = io.StringIO()
132 writer = csv.writer(buffer, lineterminator="\n")
133 writer.writerow(self._example_columns)
134 return buffer.getvalue()
136 @property
137 def example_filename(self) -> str:
138 """Download filename for the example CSV template."""
139 return self._example_filename
141 async def execute(self, record: None, ctx: ActionContext) -> Result[Any, Any]:
142 content = self._file_content or ctx.metadata.get("file_content")
143 if content is None:
144 return Err(
145 ActionError(
146 "Import requires file content; pass file_content to the action "
147 "or set ctx.metadata['file_content']."
148 )
149 )
150 filename = self._filename or ctx.metadata.get("filename") or "import.csv"
151 service = self._import_service
152 if service is None:
153 data_source = _resolve_data_source(ctx, self._data_source)
154 if data_source is None:
155 return Err(
156 ActionError(
157 "Import requires an AdminImportService or a data source; "
158 "inject one or set ctx.data_source."
159 )
160 )
161 from lexigram.admin.services.import_ import AdminImportService
163 service = AdminImportService(data_source=data_source)
164 return await _run_import(service, content, filename)
165class ImportBulkAction(_ImportReportMixin, BulkAction):
166 """Import multiple records through the admin import service."""
168 def __init__(
169 self,
170 name: str = "import",
171 label: str | None = None,
172 import_service: AdminImportService | None = None,
173 data_source: Any | None = None,
174 file_content: bytes | None = None,
175 filename: str | None = None,
176 ) -> None:
177 super().__init__(
178 name=name,
179 label=label or "Import Selected",
180 icon="upload",
181 color=ActionColor.GRAY,
182 )
183 self._import_service = import_service
184 self._data_source = data_source
185 self._file_content = file_content
186 self._filename = filename
188 async def execute(self, records: list[Any], ctx: ActionContext) -> Result[Any, Any]:
189 content = self._file_content or ctx.metadata.get("file_content")
190 if content is None:
191 return Err(
192 ActionError(
193 "Import requires file content; pass file_content to the action "
194 "or set ctx.metadata['file_content']."
195 )
196 )
197 filename = self._filename or ctx.metadata.get("filename") or "import.csv"
198 service = self._import_service
199 if service is None:
200 data_source = _resolve_data_source(ctx, self._data_source)
201 if data_source is None:
202 return Err(
203 ActionError(
204 "Import requires an AdminImportService or a data source; "
205 "inject one or set ctx.data_source."
206 )
207 )
208 from lexigram.admin.services.import_ import AdminImportService
210 service = AdminImportService(data_source=data_source)
211 return await _run_import(service, content, filename)