chore: excel导入导出模板
parent
fde94665fb
commit
1525f35036
@ -0,0 +1,53 @@
|
||||
from .base import (
|
||||
DataExchangeModule,
|
||||
ExchangeField,
|
||||
ExchangePlaceholder,
|
||||
ExchangePlan,
|
||||
ExchangeTemplate,
|
||||
ExchangeTemplateBinding,
|
||||
ExchangeTemplateKind,
|
||||
ExchangeTemplateSource,
|
||||
ExchangeTemplateSourceKind,
|
||||
ExchangeTemplateSnapshot,
|
||||
ExchangeTaskKind,
|
||||
)
|
||||
from .plan import ExchangeMappingPlanInput
|
||||
from .registry import (
|
||||
ExchangeRegistry,
|
||||
get_exchange_registry,
|
||||
get_exchange_source_by_name,
|
||||
register_exchange_source,
|
||||
)
|
||||
from .sources import (
|
||||
ExchangeSource,
|
||||
LocalExchangeSource,
|
||||
MappingExchangeSource,
|
||||
RemoteExchangeSource,
|
||||
get_exchange_source,
|
||||
)
|
||||
from .tasks import register_exchange_task
|
||||
|
||||
__all__ = [
|
||||
"DataExchangeModule",
|
||||
"ExchangeField",
|
||||
"ExchangeMappingPlanInput",
|
||||
"ExchangePlaceholder",
|
||||
"ExchangePlan",
|
||||
"ExchangeRegistry",
|
||||
"ExchangeTemplate",
|
||||
"ExchangeTemplateBinding",
|
||||
"ExchangeTemplateKind",
|
||||
"ExchangeTemplateSource",
|
||||
"ExchangeTemplateSourceKind",
|
||||
"ExchangeTemplateSnapshot",
|
||||
"ExchangeTaskKind",
|
||||
"ExchangeSource",
|
||||
"LocalExchangeSource",
|
||||
"get_exchange_registry",
|
||||
"get_exchange_source_by_name",
|
||||
"get_exchange_source",
|
||||
"MappingExchangeSource",
|
||||
"register_exchange_source",
|
||||
"register_exchange_task",
|
||||
"RemoteExchangeSource",
|
||||
]
|
||||
@ -0,0 +1,247 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Protocol, Sequence
|
||||
|
||||
|
||||
class ExchangeTemplateKind(str, Enum):
|
||||
IMPORT = "import"
|
||||
EXPORT = "export"
|
||||
|
||||
|
||||
class ExchangeTaskKind(str, Enum):
|
||||
IMPORT = "import"
|
||||
EXPORT = "export"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangePlaceholder:
|
||||
key: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
required: bool = False
|
||||
example: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeField:
|
||||
key: str
|
||||
label: str
|
||||
placeholder: str | None = None
|
||||
required: bool = False
|
||||
example: str | None = None
|
||||
width: int | None = None
|
||||
format: str | None = None
|
||||
source: str | None = None
|
||||
target: str | None = None
|
||||
options: tuple[tuple[str, str], ...] = ()
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def workbook_header(self) -> str:
|
||||
return self.placeholder or self.label or self.key
|
||||
|
||||
def export_source_key(self) -> str:
|
||||
return self.source or self.key
|
||||
|
||||
def import_target_key(self) -> str:
|
||||
return self.target or self.key
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeTemplateBinding:
|
||||
entity: str
|
||||
template_kind: ExchangeTemplateKind
|
||||
handler: str | None = None
|
||||
description: str | None = None
|
||||
default_sheet_name: str | None = None
|
||||
default_file_name: str | None = None
|
||||
title: str | None = None
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ExchangeTemplateSourceKind(str, Enum):
|
||||
LOCAL = "local"
|
||||
REMOTE = "remote"
|
||||
MAPPING = "mapping"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeTemplateSnapshot:
|
||||
id: str
|
||||
version: str
|
||||
template_id: str
|
||||
template_kind: ExchangeTemplateKind
|
||||
bindings: tuple[ExchangeTemplateBinding, ...] = ()
|
||||
published_at: str | None = None
|
||||
file_key: str | None = None
|
||||
checksum: str | None = None
|
||||
fields: tuple[ExchangeField, ...] = ()
|
||||
placeholders: tuple[ExchangePlaceholder, ...] = ()
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_plan(self) -> "ExchangePlan":
|
||||
meta = dict(self.meta)
|
||||
return ExchangePlan(
|
||||
template_kind=self.template_kind,
|
||||
template_id=self.template_id,
|
||||
version_id=self.id,
|
||||
version=self.version,
|
||||
bindings=self.bindings,
|
||||
fields=self.fields,
|
||||
placeholders=self.placeholders,
|
||||
title=meta.get("title"),
|
||||
description=meta.get("description"),
|
||||
sheet_name=meta.get("sheet_name"),
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangePlan:
|
||||
template_kind: ExchangeTemplateKind
|
||||
template_id: str | None = None
|
||||
version_id: str | None = None
|
||||
version: str | None = None
|
||||
bindings: tuple[ExchangeTemplateBinding, ...] = ()
|
||||
fields: tuple[ExchangeField, ...] = ()
|
||||
placeholders: tuple[ExchangePlaceholder, ...] = ()
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
sheet_name: str | None = None
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls,
|
||||
*,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
bindings: Sequence[ExchangeTemplateBinding] | None = None,
|
||||
fields: Sequence[ExchangeField] | None = None,
|
||||
placeholders: Sequence[ExchangePlaceholder] | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
sheet_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> "ExchangePlan":
|
||||
return cls(
|
||||
template_kind=ExchangeTemplateKind(template_kind),
|
||||
template_id=template_id,
|
||||
version_id=version_id,
|
||||
version=version,
|
||||
bindings=tuple(bindings or ()),
|
||||
fields=tuple(fields or ()),
|
||||
placeholders=tuple(placeholders or ()),
|
||||
title=title,
|
||||
description=description,
|
||||
sheet_name=sheet_name,
|
||||
meta=meta or {},
|
||||
)
|
||||
|
||||
def resolved_meta(self) -> dict[str, Any]:
|
||||
meta = dict(self.meta)
|
||||
if self.template_id is not None:
|
||||
meta.setdefault("template_id", self.template_id)
|
||||
if self.version_id is not None:
|
||||
meta.setdefault("version_id", self.version_id)
|
||||
if self.version is not None:
|
||||
meta.setdefault("version", self.version)
|
||||
if self.title is not None:
|
||||
meta.setdefault("title", self.title)
|
||||
if self.description is not None:
|
||||
meta.setdefault("description", self.description)
|
||||
if self.sheet_name is not None:
|
||||
meta.setdefault("sheet_name", self.sheet_name)
|
||||
return meta
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeTemplateSource:
|
||||
kind: ExchangeTemplateSourceKind
|
||||
template_kind: ExchangeTemplateKind
|
||||
template_id: str | None = None
|
||||
version_id: str | None = None
|
||||
version: str | None = None
|
||||
service: str | None = None
|
||||
bindings: tuple[ExchangeTemplateBinding, ...] = ()
|
||||
fields: tuple[ExchangeField, ...] = ()
|
||||
placeholders: tuple[ExchangePlaceholder, ...] = ()
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
sheet_name: str | None = None
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_plan(self) -> ExchangePlan:
|
||||
meta = dict(self.meta)
|
||||
return ExchangePlan(
|
||||
template_kind=self.template_kind,
|
||||
template_id=self.template_id,
|
||||
version_id=self.version_id,
|
||||
version=self.version,
|
||||
bindings=self.bindings,
|
||||
fields=self.fields,
|
||||
placeholders=self.placeholders,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
sheet_name=self.sheet_name,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeTemplate:
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
template_kind: ExchangeTemplateKind
|
||||
entity: str
|
||||
status: str = "draft"
|
||||
description: str | None = None
|
||||
current_version: str | None = None
|
||||
bindings: tuple[ExchangeTemplateBinding, ...] = ()
|
||||
fields: tuple[ExchangeField, ...] = ()
|
||||
placeholders: tuple[ExchangePlaceholder, ...] = ()
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_plan(self) -> ExchangePlan:
|
||||
meta = dict(self.meta)
|
||||
return ExchangePlan(
|
||||
template_kind=self.template_kind,
|
||||
template_id=self.id,
|
||||
version_id=self.current_version,
|
||||
bindings=self.bindings,
|
||||
fields=self.fields,
|
||||
placeholders=self.placeholders,
|
||||
title=self.name,
|
||||
description=self.description,
|
||||
sheet_name=meta.get("sheet_name"),
|
||||
meta={
|
||||
"code": self.code,
|
||||
"status": self.status,
|
||||
"current_version": self.current_version,
|
||||
**meta,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class DataExchangeModule(Protocol):
|
||||
name: str
|
||||
|
||||
def init_app(self, app) -> None:
|
||||
...
|
||||
|
||||
def register_routes(self, app) -> None:
|
||||
...
|
||||
|
||||
def register_permissions(self, app) -> None:
|
||||
...
|
||||
|
||||
def register_menu_seed(self, app) -> None:
|
||||
...
|
||||
|
||||
def register_tasks(self, app) -> None:
|
||||
...
|
||||
@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
import pandas as pd
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.worksheet.worksheet import Worksheet
|
||||
|
||||
from .base import ExchangeField, ExchangePlaceholder, ExchangePlan, ExchangeTemplateSnapshot
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExcelTemplateCodec:
|
||||
"""Render and parse template workbooks."""
|
||||
|
||||
def build_workbook(self, snapshot: ExchangeTemplateSnapshot | ExchangePlan) -> Workbook:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
sheet_name = getattr(snapshot, "sheet_name", None)
|
||||
worksheet.title = (
|
||||
sheet_name
|
||||
or snapshot.meta.get("sheet_name")
|
||||
or (snapshot.bindings[0].default_sheet_name if snapshot.bindings else None)
|
||||
or _safe_sheet_name("Template")
|
||||
)
|
||||
row = self._write_header(worksheet, snapshot)
|
||||
row = self._write_bindings(worksheet, snapshot, row)
|
||||
row = self._write_placeholders(worksheet, snapshot, row)
|
||||
self._write_fields(worksheet, snapshot, row)
|
||||
return workbook
|
||||
|
||||
def _write_header(
|
||||
self, worksheet: Worksheet, snapshot: ExchangeTemplateSnapshot | ExchangePlan
|
||||
) -> int:
|
||||
meta = snapshot.meta if hasattr(snapshot, "meta") else {}
|
||||
title = getattr(snapshot, "title", None)
|
||||
version = getattr(snapshot, "version", None)
|
||||
worksheet["A1"] = title or meta.get("title") or snapshot.template_id or "Template"
|
||||
if version:
|
||||
worksheet["A2"] = f"version: {version}"
|
||||
elif meta.get("version"):
|
||||
worksheet["A2"] = f"version: {meta['version']}"
|
||||
description = getattr(snapshot, "description", None)
|
||||
if meta.get("description") or description:
|
||||
worksheet["A3"] = meta.get("description") or description
|
||||
return 5
|
||||
|
||||
def _write_bindings(
|
||||
self, worksheet: Worksheet, snapshot: ExchangeTemplateSnapshot | ExchangePlan, row: int
|
||||
) -> int:
|
||||
if not snapshot.bindings:
|
||||
return row
|
||||
worksheet.cell(row=row, column=1, value="Bindings")
|
||||
row += 1
|
||||
headers = [
|
||||
"entity",
|
||||
"template_kind",
|
||||
"handler",
|
||||
"description",
|
||||
"default_sheet_name",
|
||||
"default_file_name",
|
||||
"title",
|
||||
]
|
||||
for col, value in enumerate(headers, start=1):
|
||||
worksheet.cell(row=row, column=col, value=value)
|
||||
row += 1
|
||||
for binding in snapshot.bindings:
|
||||
values = [
|
||||
binding.entity,
|
||||
_enum_value(binding.template_kind),
|
||||
binding.handler,
|
||||
binding.description,
|
||||
binding.default_sheet_name,
|
||||
binding.default_file_name,
|
||||
binding.title,
|
||||
]
|
||||
for col, value in enumerate(values, start=1):
|
||||
worksheet.cell(row=row, column=col, value=value)
|
||||
row += 1
|
||||
return row
|
||||
|
||||
def _write_placeholders(
|
||||
self, worksheet: Worksheet, snapshot: ExchangeTemplateSnapshot | ExchangePlan, row: int
|
||||
) -> int:
|
||||
if not snapshot.placeholders:
|
||||
return row
|
||||
worksheet.cell(row=row, column=1, value="Placeholders")
|
||||
row += 1
|
||||
for placeholder in snapshot.placeholders:
|
||||
worksheet.cell(row=row, column=1, value=placeholder.key)
|
||||
worksheet.cell(row=row, column=2, value=placeholder.label)
|
||||
worksheet.cell(row=row, column=3, value=placeholder.description)
|
||||
worksheet.cell(row=row, column=4, value=placeholder.example)
|
||||
worksheet.cell(row=row, column=5, value=placeholder.required)
|
||||
row += 1
|
||||
return row
|
||||
|
||||
def _write_fields(
|
||||
self, worksheet: Worksheet, snapshot: ExchangeTemplateSnapshot | ExchangePlan, row: int
|
||||
) -> None:
|
||||
worksheet.cell(row=row, column=1, value="Fields")
|
||||
row += 1
|
||||
headers = [
|
||||
"key",
|
||||
"label",
|
||||
"placeholder",
|
||||
"required",
|
||||
"example",
|
||||
"format",
|
||||
"source",
|
||||
"target",
|
||||
]
|
||||
for col, value in enumerate(headers, start=1):
|
||||
worksheet.cell(row=row, column=col, value=value)
|
||||
row += 1
|
||||
for field in snapshot.fields:
|
||||
values = [
|
||||
field.key,
|
||||
field.label,
|
||||
field.placeholder,
|
||||
field.required,
|
||||
field.example,
|
||||
field.format,
|
||||
field.source,
|
||||
field.target,
|
||||
]
|
||||
for col, value in enumerate(values, start=1):
|
||||
worksheet.cell(row=row, column=col, value=value)
|
||||
row += 1
|
||||
|
||||
def dump(self, snapshot: ExchangeTemplateSnapshot | ExchangePlan) -> bytes:
|
||||
buffer = BytesIO()
|
||||
self.build_workbook(snapshot).save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
def load(self, content: bytes) -> dict[str, Any]:
|
||||
workbook = load_workbook(BytesIO(content))
|
||||
worksheet = workbook.active
|
||||
payload = {
|
||||
"title": worksheet["A1"].value,
|
||||
"version": worksheet["A2"].value,
|
||||
"description": worksheet["A3"].value,
|
||||
"sheet_name": worksheet.title,
|
||||
}
|
||||
payload["bindings"], payload["placeholders"], payload["fields"] = self._parse_sections(
|
||||
worksheet
|
||||
)
|
||||
return payload
|
||||
|
||||
def _parse_sections(
|
||||
self, worksheet: Worksheet
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
bindings: list[dict[str, Any]] = []
|
||||
placeholders: list[dict[str, Any]] = []
|
||||
fields: list[dict[str, Any]] = []
|
||||
mode: str | None = None
|
||||
headers: list[str] = []
|
||||
|
||||
for row in worksheet.iter_rows(values_only=True):
|
||||
cells = [cell for cell in row]
|
||||
first = cells[0] if cells else None
|
||||
if first == "Bindings":
|
||||
mode = "bindings_headers"
|
||||
headers = []
|
||||
continue
|
||||
if first == "Placeholders":
|
||||
mode = "placeholders"
|
||||
continue
|
||||
if first == "Fields":
|
||||
mode = "fields_headers"
|
||||
headers = []
|
||||
continue
|
||||
if mode == "bindings_headers":
|
||||
headers = [str(cell) if cell is not None else "" for cell in cells]
|
||||
if not headers:
|
||||
continue
|
||||
mode = "bindings"
|
||||
continue
|
||||
if mode == "bindings":
|
||||
if not any(cell is not None for cell in cells):
|
||||
continue
|
||||
item = {headers[idx]: cells[idx] for idx in range(min(len(headers), len(cells)))}
|
||||
if not item.get("entity") and not item.get("template_kind"):
|
||||
continue
|
||||
bindings.append(
|
||||
{
|
||||
"entity": item.get("entity"),
|
||||
"template_kind": item.get("template_kind"),
|
||||
"handler": item.get("handler"),
|
||||
"description": item.get("description"),
|
||||
"default_sheet_name": item.get("default_sheet_name"),
|
||||
"default_file_name": item.get("default_file_name"),
|
||||
"title": item.get("title"),
|
||||
"meta": {},
|
||||
}
|
||||
)
|
||||
continue
|
||||
if mode == "placeholders":
|
||||
if not any(cell is not None for cell in cells):
|
||||
continue
|
||||
placeholders.append(
|
||||
{
|
||||
"key": cells[0],
|
||||
"label": cells[1],
|
||||
"description": cells[2],
|
||||
"example": cells[3],
|
||||
"required": bool(cells[4]) if len(cells) > 4 else False,
|
||||
}
|
||||
)
|
||||
continue
|
||||
if mode == "fields_headers":
|
||||
headers = [str(cell) if cell is not None else "" for cell in cells]
|
||||
if not headers:
|
||||
continue
|
||||
mode = "fields"
|
||||
continue
|
||||
if mode == "fields":
|
||||
if not any(cell is not None for cell in cells):
|
||||
continue
|
||||
item = {headers[idx]: cells[idx] for idx in range(min(len(headers), len(cells)))}
|
||||
if item.get("key") is None and item.get("label") is None:
|
||||
continue
|
||||
fields.append(
|
||||
{
|
||||
"key": item.get("key"),
|
||||
"label": item.get("label"),
|
||||
"placeholder": item.get("placeholder"),
|
||||
"required": bool(item.get("required", False)),
|
||||
"example": item.get("example"),
|
||||
"format": item.get("format"),
|
||||
"source": item.get("source"),
|
||||
"target": item.get("target"),
|
||||
"options": [],
|
||||
"meta": {},
|
||||
}
|
||||
)
|
||||
return bindings, placeholders, fields
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExcelWorkbookCodec:
|
||||
"""Read and write exchange data workbooks."""
|
||||
|
||||
def export_rows(
|
||||
self,
|
||||
headers: list[str],
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
sheet_name: str = "Export",
|
||||
) -> bytes:
|
||||
buffer = BytesIO()
|
||||
dataframe = pd.DataFrame.from_records(rows, columns=headers)
|
||||
with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
|
||||
dataframe.to_excel(
|
||||
writer,
|
||||
index=False,
|
||||
sheet_name=_safe_sheet_name(sheet_name),
|
||||
)
|
||||
return buffer.getvalue()
|
||||
|
||||
def import_rows(self, content: bytes) -> list[dict[str, Any]]:
|
||||
dataframe = self._read_sheet(content)
|
||||
if dataframe.empty and len(dataframe.columns) == 0:
|
||||
return []
|
||||
headers = [self._header_name(value) for value in dataframe.iloc[0].tolist()]
|
||||
return self._frame_to_records(dataframe.iloc[1:], headers)
|
||||
|
||||
def import_rows_with_fields(
|
||||
self,
|
||||
content: bytes,
|
||||
*,
|
||||
fields: list[ExchangeField],
|
||||
) -> list[dict[str, Any]]:
|
||||
dataframe = self._read_sheet(content)
|
||||
if dataframe.empty and len(dataframe.columns) == 0:
|
||||
return []
|
||||
header_map = {field.workbook_header(): field.import_target_key() for field in fields}
|
||||
headers = [self._header_name(value) for value in dataframe.iloc[0].tolist()]
|
||||
return self._frame_to_records(dataframe.iloc[1:], headers, header_map=header_map)
|
||||
|
||||
def export_rows_with_template(
|
||||
self,
|
||||
*,
|
||||
fields: list[ExchangeField],
|
||||
rows: list[dict[str, Any]],
|
||||
sheet_name: str = "Export",
|
||||
) -> bytes:
|
||||
headers = [field.workbook_header() for field in fields]
|
||||
normalized_rows: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
item: dict[str, Any] = {}
|
||||
for field in fields:
|
||||
item[field.workbook_header()] = row.get(field.export_source_key())
|
||||
normalized_rows.append(item)
|
||||
return self.export_rows(headers, normalized_rows, sheet_name=sheet_name)
|
||||
|
||||
def export_rows_with_plan(
|
||||
self,
|
||||
*,
|
||||
plan: ExchangePlan,
|
||||
rows: list[dict[str, Any]],
|
||||
sheet_name: str | None = None,
|
||||
) -> bytes:
|
||||
return self.export_rows_with_template(
|
||||
fields=list(plan.fields),
|
||||
rows=rows,
|
||||
sheet_name=sheet_name or plan.sheet_name or "Export",
|
||||
)
|
||||
|
||||
def _read_sheet(self, content: bytes) -> pd.DataFrame:
|
||||
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
|
||||
sheet_name = workbook.active.title
|
||||
workbook.close()
|
||||
return pd.read_excel(
|
||||
BytesIO(content),
|
||||
sheet_name=sheet_name,
|
||||
header=None,
|
||||
dtype=object,
|
||||
engine="openpyxl",
|
||||
)
|
||||
|
||||
def _frame_to_records(
|
||||
self,
|
||||
dataframe: pd.DataFrame,
|
||||
headers: list[str],
|
||||
*,
|
||||
header_map: dict[str, str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for values in dataframe.itertuples(index=False, name=None):
|
||||
item: dict[str, Any] = {}
|
||||
for index, header in enumerate(headers):
|
||||
if not header:
|
||||
continue
|
||||
key = header_map.get(header, header) if header_map is not None else header
|
||||
value = values[index] if index < len(values) else None
|
||||
item[key] = self._normalize_value(value)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _normalize_value(value: Any) -> Any:
|
||||
return None if pd.isna(value) else value
|
||||
|
||||
@staticmethod
|
||||
def _header_name(value: Any) -> str:
|
||||
normalized = ExcelWorkbookCodec._normalize_value(value)
|
||||
return "" if normalized is None else str(normalized)
|
||||
|
||||
|
||||
def _safe_sheet_name(value: str) -> str:
|
||||
cleaned = "".join(ch for ch in value if ch not in "[]:*?/\\")
|
||||
cleaned = cleaned.strip()
|
||||
if not cleaned:
|
||||
cleaned = "Sheet"
|
||||
return cleaned[:31]
|
||||
|
||||
|
||||
def _enum_value(value: Any) -> Any:
|
||||
return value.value if hasattr(value, "value") else value
|
||||
@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, JSON, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from iti.db import Base, IdMixin, TimestampMixin
|
||||
|
||||
|
||||
class ExchangeTemplateModel(Base, IdMixin, TimestampMixin):
|
||||
__tablename__ = "exchange_templates"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(128), unique=True, index=True, comment="模板编码")
|
||||
name: Mapped[str] = mapped_column(String(255), comment="模板名称")
|
||||
template_kind: Mapped[str] = mapped_column(String(32), index=True, comment="模板类型")
|
||||
entity: Mapped[str] = mapped_column(String(128), index=True, comment="业务实体")
|
||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True, comment="状态")
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True, comment="说明")
|
||||
current_version: Mapped[str | None] = mapped_column(
|
||||
String(64), nullable=True, index=True, comment="当前版本"
|
||||
)
|
||||
meta: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="扩展配置")
|
||||
|
||||
versions: Mapped[list["ExchangeTemplateVersionModel"]] = relationship(
|
||||
back_populates="template",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ExchangeTemplateVersionModel(Base, IdMixin, TimestampMixin):
|
||||
__tablename__ = "exchange_template_versions"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("template_id", "version", name="uq_exchange_template_versions_template_version"),
|
||||
Index("ix_exchange_template_versions_template_id_version", "template_id", "version"),
|
||||
)
|
||||
|
||||
template_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("exchange_templates.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
comment="模板ID",
|
||||
)
|
||||
version: Mapped[str] = mapped_column(String(64), comment="版本号")
|
||||
template_kind: Mapped[str] = mapped_column(String(32), index=True, comment="模板类型")
|
||||
published_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="发布时间",
|
||||
)
|
||||
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="模板文件")
|
||||
checksum: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="校验值")
|
||||
bindings: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, comment="绑定配置")
|
||||
fields: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, comment="字段定义")
|
||||
placeholders: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list, comment="占位符定义")
|
||||
meta: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="扩展配置")
|
||||
|
||||
template: Mapped["ExchangeTemplateModel"] = relationship(back_populates="versions")
|
||||
|
||||
|
||||
class ExchangeTaskModel(Base, IdMixin, TimestampMixin):
|
||||
__tablename__ = "exchange_tasks"
|
||||
__table_args__ = (
|
||||
Index("ix_exchange_tasks_template_id_kind_status", "template_id", "task_kind", "status"),
|
||||
Index("ix_exchange_tasks_version_id", "template_version_id"),
|
||||
)
|
||||
|
||||
template_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("exchange_templates.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="模板ID",
|
||||
)
|
||||
template_version_id: Mapped[str | None] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("exchange_template_versions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
comment="模板版本ID",
|
||||
)
|
||||
task_kind: Mapped[str] = mapped_column(String(32), index=True, comment="任务类型")
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True, comment="状态")
|
||||
requested_by: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True, comment="发起人")
|
||||
storage_key: Mapped[str | None] = mapped_column(String(512), nullable=True, comment="任务文件")
|
||||
checksum: Mapped[str | None] = mapped_column(String(128), nullable=True, comment="校验值")
|
||||
error_count: Mapped[int] = mapped_column(default=0, comment="错误数")
|
||||
success_count: Mapped[int] = mapped_column(default=0, comment="成功数")
|
||||
failed_count: Mapped[int] = mapped_column(default=0, comment="失败数")
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="消息")
|
||||
input_payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="输入参数")
|
||||
result_payload: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="输出结果")
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="开始时间")
|
||||
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="结束时间")
|
||||
meta: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="扩展配置")
|
||||
|
||||
template: Mapped["ExchangeTemplateModel"] = relationship(
|
||||
foreign_keys=[template_id]
|
||||
)
|
||||
version: Mapped["ExchangeTemplateVersionModel"] = relationship(
|
||||
foreign_keys=[template_version_id]
|
||||
)
|
||||
rows: Mapped[list["ExchangeTaskRowModel"]] = relationship(
|
||||
back_populates="task",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class ExchangeTaskRowModel(Base, IdMixin, TimestampMixin):
|
||||
__tablename__ = "exchange_task_rows"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("task_id", "row_index", name="uq_exchange_task_rows_task_row"),
|
||||
Index("ix_exchange_task_rows_task_id_status", "task_id", "status"),
|
||||
)
|
||||
|
||||
task_id: Mapped[str] = mapped_column(
|
||||
String(36),
|
||||
ForeignKey("exchange_tasks.id", ondelete="CASCADE"),
|
||||
index=True,
|
||||
comment="任务ID",
|
||||
)
|
||||
row_index: Mapped[int] = mapped_column(comment="行号")
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending", index=True, comment="状态")
|
||||
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="原始数据")
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="错误信息")
|
||||
result: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, comment="结果数据")
|
||||
|
||||
task: Mapped["ExchangeTaskModel"] = relationship(back_populates="rows")
|
||||
@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from iti.modules import ModulePermission
|
||||
|
||||
from .routes import router
|
||||
|
||||
|
||||
class ExchangeModule:
|
||||
name = "exchange"
|
||||
|
||||
def register_routes(self, app) -> None:
|
||||
app.include_router(router)
|
||||
|
||||
def register_permissions(self, app) -> None:
|
||||
app.state.iti_modules.register_permission(
|
||||
ModulePermission("exchange:template:list", "数据模板列表")
|
||||
)
|
||||
app.state.iti_modules.register_permission(
|
||||
ModulePermission("exchange:template:manage", "数据模板管理")
|
||||
)
|
||||
app.state.iti_modules.register_permission(
|
||||
ModulePermission("exchange:task:create", "导入导出任务创建")
|
||||
)
|
||||
app.state.iti_modules.register_permission(
|
||||
ModulePermission("exchange:task:list", "导入导出任务列表")
|
||||
)
|
||||
|
||||
|
||||
def create_exchange_module() -> ExchangeModule:
|
||||
return ExchangeModule()
|
||||
@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .base import (
|
||||
ExchangeField,
|
||||
ExchangePlaceholder,
|
||||
ExchangePlan,
|
||||
ExchangeTemplateBinding,
|
||||
ExchangeTemplateKind,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeMappingPlanInput:
|
||||
template_kind: ExchangeTemplateKind | str
|
||||
template_id: str | None = None
|
||||
version_id: str | None = None
|
||||
version: str | None = None
|
||||
bindings: list[ExchangeTemplateBinding] | None = None
|
||||
fields: list[ExchangeField] | None = None
|
||||
placeholders: list[ExchangePlaceholder] | None = None
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
sheet_name: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
def to_plan(self) -> ExchangePlan:
|
||||
return ExchangePlan.from_mapping(
|
||||
template_kind=self.template_kind,
|
||||
template_id=self.template_id,
|
||||
version_id=self.version_id,
|
||||
version=self.version,
|
||||
bindings=self.bindings,
|
||||
fields=self.fields,
|
||||
placeholders=self.placeholders,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
sheet_name=self.sheet_name,
|
||||
meta=self.meta,
|
||||
)
|
||||
|
||||
@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .base import ExchangeTemplate, ExchangeTemplateSnapshot
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExchangeRegistry:
|
||||
templates: dict[str, ExchangeTemplate] = field(default_factory=dict)
|
||||
versions: dict[str, ExchangeTemplateSnapshot] = field(default_factory=dict)
|
||||
sources: dict[str, object] = field(default_factory=dict)
|
||||
|
||||
def register_template(self, template: ExchangeTemplate) -> ExchangeTemplate:
|
||||
if not template.id:
|
||||
raise ValueError("template id is required")
|
||||
if not template.code:
|
||||
raise ValueError("template code is required")
|
||||
if template.id in self.templates:
|
||||
raise ValueError(f"template already registered: {template.id}")
|
||||
self.templates[template.id] = template
|
||||
return template
|
||||
|
||||
def register_version(
|
||||
self, snapshot: ExchangeTemplateSnapshot
|
||||
) -> ExchangeTemplateSnapshot:
|
||||
if not snapshot.id:
|
||||
raise ValueError("snapshot id is required")
|
||||
if not snapshot.template_id:
|
||||
raise ValueError("snapshot template id is required")
|
||||
key = self._version_key(snapshot.template_id, snapshot.version)
|
||||
if key in self.versions:
|
||||
raise ValueError(f"template version already registered: {key}")
|
||||
self.versions[key] = snapshot
|
||||
return snapshot
|
||||
|
||||
def get_template(self, template_id: str) -> ExchangeTemplate | None:
|
||||
return self.templates.get(template_id)
|
||||
|
||||
def get_version(
|
||||
self, template_id: str, version: str
|
||||
) -> ExchangeTemplateSnapshot | None:
|
||||
return self.versions.get(self._version_key(template_id, version))
|
||||
|
||||
def latest_version(
|
||||
self, template_id: str
|
||||
) -> ExchangeTemplateSnapshot | None:
|
||||
template = self.templates.get(template_id)
|
||||
if template is None or not template.current_version:
|
||||
return None
|
||||
return self.get_version(template_id, template.current_version)
|
||||
|
||||
def list_templates(self) -> list[ExchangeTemplate]:
|
||||
return sorted(self.templates.values(), key=lambda item: (item.entity, item.code))
|
||||
|
||||
def list_versions(self, template_id: str | None = None) -> list[ExchangeTemplateSnapshot]:
|
||||
snapshots = list(self.versions.values())
|
||||
if template_id is not None:
|
||||
snapshots = [item for item in snapshots if item.template_id == template_id]
|
||||
return sorted(snapshots, key=lambda item: (item.template_id, item.version))
|
||||
|
||||
def register_source(self, name: str, source: object) -> object:
|
||||
if not name:
|
||||
raise ValueError("source name is required")
|
||||
if name in self.sources:
|
||||
raise ValueError(f"exchange source already registered: {name}")
|
||||
self.sources[name] = source
|
||||
return source
|
||||
|
||||
def get_source(self, name: str) -> object | None:
|
||||
return self.sources.get(name)
|
||||
|
||||
@staticmethod
|
||||
def _version_key(template_id: str, version: str) -> str:
|
||||
return f"{template_id}:{version}"
|
||||
|
||||
|
||||
def get_exchange_registry(app) -> ExchangeRegistry:
|
||||
registry = getattr(app.state, "iti_exchange", None)
|
||||
if registry is None:
|
||||
registry = ExchangeRegistry()
|
||||
app.state.iti_exchange = registry
|
||||
return registry
|
||||
|
||||
|
||||
def register_exchange_source(app, name: str, source: object) -> object:
|
||||
return get_exchange_registry(app).register_source(name, source)
|
||||
|
||||
|
||||
def get_exchange_source_by_name(app, name: str) -> object | None:
|
||||
return get_exchange_registry(app).get_source(name)
|
||||
@ -0,0 +1,457 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Request, UploadFile
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from iti.db import get_db
|
||||
from iti.exceptions import BizError
|
||||
from iti.responses import ok, raw_response
|
||||
|
||||
from .base import ExchangeField, ExchangePlaceholder, ExchangeTemplateBinding, ExchangePlan
|
||||
from .schemas import (
|
||||
ExchangeFieldSchema,
|
||||
ExchangePlanResolveRequest,
|
||||
ExchangePlanTemplateFileRequest,
|
||||
ExchangePlaceholderSchema,
|
||||
ExchangeTemplateBindingSchema,
|
||||
ExchangeTemplateSourceKind,
|
||||
ExchangeTaskCreateRequest,
|
||||
ExchangeTaskResponse,
|
||||
ExchangeTemplateCreateRequest,
|
||||
ExchangeTemplateResponse,
|
||||
ExchangeTemplateVersionCreateRequest,
|
||||
ExchangeTemplateVersionResponse,
|
||||
ExchangeTemplateUpdateRequest,
|
||||
)
|
||||
from .service import ExchangeService
|
||||
from .excel import ExcelTemplateCodec
|
||||
from .sources import get_exchange_source
|
||||
|
||||
|
||||
router = APIRouter(prefix="/exchange", tags=["exchange"])
|
||||
|
||||
|
||||
def _template_payload(item):
|
||||
return {
|
||||
"id": item.id,
|
||||
"code": item.code,
|
||||
"name": item.name,
|
||||
"template_kind": item.template_kind,
|
||||
"entity": item.entity,
|
||||
"status": item.status,
|
||||
"description": item.description,
|
||||
"current_version": item.current_version,
|
||||
"meta": item.meta,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _version_payload(item):
|
||||
return {
|
||||
"id": item.id,
|
||||
"template_id": item.template_id,
|
||||
"version": item.version,
|
||||
"template_kind": item.template_kind,
|
||||
"published_at": item.published_at,
|
||||
"file_key": item.file_key,
|
||||
"checksum": item.checksum,
|
||||
"bindings": item.bindings,
|
||||
"fields": item.fields,
|
||||
"placeholders": item.placeholders,
|
||||
"meta": item.meta,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _task_payload(item):
|
||||
return {
|
||||
"id": item.id,
|
||||
"template_id": item.template_id,
|
||||
"template_version_id": item.template_version_id,
|
||||
"task_kind": item.task_kind,
|
||||
"status": item.status,
|
||||
"requested_by": item.requested_by,
|
||||
"storage_key": item.storage_key,
|
||||
"success_count": item.success_count,
|
||||
"failed_count": item.failed_count,
|
||||
"error_count": item.error_count,
|
||||
"message": item.message,
|
||||
"input_payload": item.input_payload,
|
||||
"result_payload": item.result_payload,
|
||||
"started_at": item.started_at,
|
||||
"finished_at": item.finished_at,
|
||||
"meta": item.meta,
|
||||
"created_at": item.created_at,
|
||||
"updated_at": item.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _plan_payload(plan: ExchangePlan):
|
||||
return {
|
||||
"template_kind": plan.template_kind,
|
||||
"template_id": plan.template_id,
|
||||
"version_id": plan.version_id,
|
||||
"version": plan.version,
|
||||
"bindings": _plan_schema_items(plan.bindings),
|
||||
"fields": _plan_schema_items(plan.fields),
|
||||
"placeholders": _plan_schema_items(plan.placeholders),
|
||||
"title": plan.title,
|
||||
"description": plan.description,
|
||||
"sheet_name": plan.sheet_name,
|
||||
"meta": plan.meta,
|
||||
}
|
||||
|
||||
|
||||
def _plan_schema_items(items):
|
||||
return [asdict(item) for item in items]
|
||||
|
||||
|
||||
def _binding_from_payload(item):
|
||||
return ExchangeTemplateBinding(
|
||||
entity=item.get("entity"),
|
||||
template_kind=item.get("template_kind"),
|
||||
handler=item.get("handler"),
|
||||
description=item.get("description"),
|
||||
default_sheet_name=item.get("default_sheet_name"),
|
||||
default_file_name=item.get("default_file_name"),
|
||||
title=item.get("title"),
|
||||
meta=item.get("meta") or {},
|
||||
)
|
||||
|
||||
|
||||
def _field_from_payload(item):
|
||||
return ExchangeField(
|
||||
key=item.get("key"),
|
||||
label=item.get("label"),
|
||||
placeholder=item.get("placeholder"),
|
||||
required=bool(item.get("required", False)),
|
||||
example=item.get("example"),
|
||||
width=item.get("width"),
|
||||
format=item.get("format"),
|
||||
source=item.get("source"),
|
||||
target=item.get("target"),
|
||||
options=tuple(tuple(option) for option in item.get("options") or []),
|
||||
meta=item.get("meta") or {},
|
||||
)
|
||||
|
||||
|
||||
def _placeholder_from_payload(item):
|
||||
return ExchangePlaceholder(
|
||||
key=item.get("key"),
|
||||
label=item.get("label"),
|
||||
description=item.get("description"),
|
||||
required=bool(item.get("required", False)),
|
||||
example=item.get("example"),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_source(payload, request: Request, db: Session):
|
||||
source_kind = payload.source_kind
|
||||
source_name = getattr(payload, "source_name", None)
|
||||
if source_name:
|
||||
return get_exchange_source(
|
||||
request.app,
|
||||
source_name=source_name,
|
||||
db=db,
|
||||
service_name=getattr(payload, "source_service", "exchange"),
|
||||
)
|
||||
if source_kind == ExchangeTemplateSourceKind.LOCAL:
|
||||
return get_exchange_source(request.app, source_kind=source_kind, db=db)
|
||||
if source_kind == ExchangeTemplateSourceKind.REMOTE:
|
||||
return get_exchange_source(
|
||||
request.app,
|
||||
source_kind=source_kind,
|
||||
service_name=payload.source_service or "exchange",
|
||||
)
|
||||
if source_kind == ExchangeTemplateSourceKind.MAPPING or source_kind is None:
|
||||
return get_exchange_source(request.app, source_kind=ExchangeTemplateSourceKind.MAPPING)
|
||||
return get_exchange_source(request.app, source_kind=source_kind, db=db)
|
||||
|
||||
|
||||
@router.get("/templates")
|
||||
def list_templates(request: Request, db: Session = Depends(get_db)):
|
||||
service = ExchangeService(request.app, db)
|
||||
return ok(
|
||||
[
|
||||
ExchangeTemplateResponse.model_validate(_template_payload(item)).model_dump(mode="json")
|
||||
for item in service.list_templates()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}")
|
||||
def get_template(template_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
service = ExchangeService(request.app, db)
|
||||
template = service.get_template_or_404(template_id)
|
||||
return ok(ExchangeTemplateResponse.model_validate(_template_payload(template)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/templates")
|
||||
def create_template(
|
||||
payload: ExchangeTemplateCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
template = service.create_template(
|
||||
code=payload.code,
|
||||
name=payload.name,
|
||||
template_kind=payload.template_kind,
|
||||
entity=payload.entity,
|
||||
description=payload.description,
|
||||
meta=payload.meta,
|
||||
)
|
||||
return ok(ExchangeTemplateResponse.model_validate(_template_payload(template)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
def update_template(
|
||||
template_id: str,
|
||||
payload: ExchangeTemplateUpdateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
template = service.update_template(
|
||||
template_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
status=payload.status,
|
||||
current_version=payload.current_version,
|
||||
meta=payload.meta,
|
||||
)
|
||||
return ok(ExchangeTemplateResponse.model_validate(_template_payload(template)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}/versions")
|
||||
def list_versions(
|
||||
template_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
return ok(
|
||||
[
|
||||
ExchangeTemplateVersionResponse.model_validate(_version_payload(item)).model_dump(mode="json")
|
||||
for item in service.list_versions(template_id)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/templates/{template_id}/versions/{version_id}")
|
||||
def get_version(
|
||||
template_id: str,
|
||||
version_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
version = service.get_version_or_404(version_id)
|
||||
if version.template_id != template_id:
|
||||
raise BizError("模板版本不存在", code=404)
|
||||
return ok(ExchangeTemplateVersionResponse.model_validate(_version_payload(version)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
def list_tasks(request: Request, db: Session = Depends(get_db)):
|
||||
service = ExchangeService(request.app, db)
|
||||
return ok(
|
||||
[
|
||||
ExchangeTaskResponse.model_validate(_task_payload(item)).model_dump(mode="json")
|
||||
for item in service.list_tasks()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}")
|
||||
def get_task(task_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
service = ExchangeService(request.app, db)
|
||||
task = service.get_task_or_404(task_id)
|
||||
return ok(ExchangeTaskResponse.model_validate(_task_payload(task)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/versions")
|
||||
def publish_version(
|
||||
template_id: str,
|
||||
payload: ExchangeTemplateVersionCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
version = service.publish_version(
|
||||
template_id=template_id,
|
||||
version=payload.version,
|
||||
bindings=[
|
||||
ExchangeTemplateBinding(**item.model_dump()) for item in payload.bindings
|
||||
],
|
||||
fields=[ExchangeField(**item.model_dump()) for item in payload.fields],
|
||||
placeholders=[
|
||||
ExchangePlaceholder(**item.model_dump()) for item in payload.placeholders
|
||||
],
|
||||
meta=payload.meta,
|
||||
make_current=payload.make_current,
|
||||
)
|
||||
return ok(ExchangeTemplateVersionResponse.model_validate(_version_payload(version)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/template-versions/{version_id}/download")
|
||||
@raw_response
|
||||
def download_template_version(
|
||||
version_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
content = service.build_template_file(version_id)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="template.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/plans/resolve")
|
||||
def resolve_plan(
|
||||
payload: ExchangePlanResolveRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
source = _resolve_source(payload, request, db)
|
||||
plan = service.resolve_plan(
|
||||
template_kind=payload.template_kind,
|
||||
template_id=payload.template_id,
|
||||
version_id=payload.version_id,
|
||||
version=payload.version,
|
||||
bindings=[ExchangeTemplateBinding(**item.model_dump()) for item in payload.bindings],
|
||||
fields=[ExchangeField(**item.model_dump()) for item in payload.fields],
|
||||
placeholders=[ExchangePlaceholder(**item.model_dump()) for item in payload.placeholders],
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
sheet_name=payload.sheet_name,
|
||||
meta=payload.meta,
|
||||
source=source,
|
||||
)
|
||||
return ok(_plan_payload(plan))
|
||||
|
||||
|
||||
@router.post("/plans/template-file")
|
||||
@raw_response
|
||||
def build_plan_template_file(
|
||||
payload: ExchangePlanTemplateFileRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
source = _resolve_source(payload, request, db)
|
||||
plan = service.resolve_plan(
|
||||
template_kind=payload.template_kind,
|
||||
template_id=payload.template_id,
|
||||
version_id=payload.version_id,
|
||||
version=payload.version,
|
||||
bindings=[ExchangeTemplateBinding(**item.model_dump()) for item in payload.bindings],
|
||||
fields=[ExchangeField(**item.model_dump()) for item in payload.fields],
|
||||
placeholders=[ExchangePlaceholder(**item.model_dump()) for item in payload.placeholders],
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
sheet_name=payload.sheet_name,
|
||||
meta=payload.meta,
|
||||
source=source,
|
||||
)
|
||||
content = source.load_template_file(plan) if source is not None else None
|
||||
if content is None:
|
||||
content = service.build_plan_template_file(plan)
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": 'attachment; filename="template.xlsx"'},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/versions/upload")
|
||||
def upload_template_version(
|
||||
template_id: str,
|
||||
version: str,
|
||||
request: Request,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
content = file.file.read()
|
||||
parsed = ExcelTemplateCodec().load(content)
|
||||
snapshot = service.publish_version(
|
||||
template_id=template_id,
|
||||
version=version,
|
||||
bindings=[_binding_from_payload(item) for item in parsed.get("bindings", [])],
|
||||
fields=[_field_from_payload(item) for item in parsed.get("fields", [])],
|
||||
placeholders=[
|
||||
_placeholder_from_payload(item) for item in parsed.get("placeholders", [])
|
||||
],
|
||||
meta={
|
||||
"title": parsed.get("title"),
|
||||
"description": parsed.get("description"),
|
||||
"sheet_name": parsed.get("sheet_name"),
|
||||
"source_file": file.filename,
|
||||
},
|
||||
file_content=content,
|
||||
file_name=file.filename,
|
||||
)
|
||||
return ok(ExchangeTemplateVersionResponse.model_validate(_version_payload(snapshot)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/tasks")
|
||||
def create_task(
|
||||
payload: ExchangeTaskCreateRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
service = ExchangeService(request.app, db)
|
||||
source = _resolve_source(payload, request, db)
|
||||
plan = service.resolve_plan(
|
||||
template_kind=payload.task_kind,
|
||||
template_id=payload.template_id,
|
||||
version_id=payload.version_id,
|
||||
version=payload.version,
|
||||
bindings=[ExchangeTemplateBinding(**item.model_dump()) for item in payload.bindings],
|
||||
fields=[ExchangeField(**item.model_dump()) for item in payload.fields],
|
||||
placeholders=[ExchangePlaceholder(**item.model_dump()) for item in payload.placeholders],
|
||||
title=payload.title,
|
||||
description=payload.description,
|
||||
sheet_name=payload.sheet_name,
|
||||
meta=payload.meta,
|
||||
source=source,
|
||||
)
|
||||
task = service.create_task(
|
||||
template_id=plan.template_id,
|
||||
version_id=plan.version_id,
|
||||
version=plan.version,
|
||||
task_kind=payload.task_kind,
|
||||
storage_key=payload.storage_key,
|
||||
input_payload=payload.input_payload,
|
||||
meta=payload.meta,
|
||||
)
|
||||
return ok(ExchangeTaskResponse.model_validate(_task_payload(task)).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/tasks/{task_id}/rows")
|
||||
def list_task_rows(task_id: str, request: Request, db: Session = Depends(get_db)):
|
||||
service = ExchangeService(request.app, db)
|
||||
return ok(
|
||||
[
|
||||
{
|
||||
"id": item.id,
|
||||
"task_id": item.task_id,
|
||||
"row_index": item.row_index,
|
||||
"status": item.status,
|
||||
"data": item.data,
|
||||
"message": item.message,
|
||||
"result": item.result,
|
||||
}
|
||||
for item in service.list_task_rows(task_id)
|
||||
]
|
||||
)
|
||||
@ -0,0 +1,152 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import AliasChoices, Field
|
||||
|
||||
from iti.schemas import ItiModel
|
||||
|
||||
from .base import ExchangeTemplateKind, ExchangeTemplateSourceKind, ExchangeTaskKind
|
||||
|
||||
|
||||
class ExchangePlaceholderSchema(ItiModel):
|
||||
key: str
|
||||
label: str
|
||||
description: str | None = None
|
||||
required: bool = False
|
||||
example: str | None = None
|
||||
|
||||
|
||||
class ExchangeFieldSchema(ItiModel):
|
||||
key: str
|
||||
label: str
|
||||
placeholder: str | None = None
|
||||
required: bool = False
|
||||
example: str | None = None
|
||||
width: int | None = None
|
||||
format: str | None = None
|
||||
source: str | None = None
|
||||
target: str | None = None
|
||||
options: list[tuple[str, str]] = Field(default_factory=list)
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExchangeTemplateBindingSchema(ItiModel):
|
||||
entity: str
|
||||
template_kind: ExchangeTemplateKind
|
||||
handler: str | None = None
|
||||
description: str | None = None
|
||||
default_sheet_name: str | None = None
|
||||
default_file_name: str | None = None
|
||||
title: str | None = None
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExchangeTemplateCreateRequest(ItiModel):
|
||||
code: str
|
||||
name: str
|
||||
template_kind: ExchangeTemplateKind
|
||||
entity: str
|
||||
description: str | None = None
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExchangeTemplateUpdateRequest(ItiModel):
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
status: str | None = None
|
||||
current_version: str | None = None
|
||||
meta: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ExchangeTemplateVersionCreateRequest(ItiModel):
|
||||
version: str
|
||||
bindings: list[ExchangeTemplateBindingSchema] = Field(default_factory=list)
|
||||
fields: list[ExchangeFieldSchema] = Field(default_factory=list)
|
||||
placeholders: list[ExchangePlaceholderSchema] = Field(default_factory=list)
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
make_current: bool = True
|
||||
|
||||
|
||||
class ExchangePlanRequest(ItiModel):
|
||||
template_id: str | None = None
|
||||
version_id: str | None = None
|
||||
version: str | None = None
|
||||
source_kind: ExchangeTemplateSourceKind | None = None
|
||||
source_name: str | None = None
|
||||
source_service: str | None = None
|
||||
bindings: list[ExchangeTemplateBindingSchema] = Field(default_factory=list)
|
||||
fields: list[ExchangeFieldSchema] = Field(default_factory=list)
|
||||
placeholders: list[ExchangePlaceholderSchema] = Field(default_factory=list)
|
||||
title: str | None = None
|
||||
description: str | None = None
|
||||
sheet_name: str | None = None
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExchangePlanResolveRequest(ExchangePlanRequest):
|
||||
template_kind: ExchangeTemplateKind = Field(
|
||||
validation_alias=AliasChoices("templateKind", "taskKind")
|
||||
)
|
||||
|
||||
|
||||
class ExchangePlanTemplateFileRequest(ExchangePlanResolveRequest):
|
||||
pass
|
||||
|
||||
|
||||
class ExchangeTaskCreateRequest(ExchangePlanRequest):
|
||||
task_kind: ExchangeTaskKind
|
||||
storage_key: str | None = None
|
||||
input_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ExchangeTemplateResponse(ItiModel):
|
||||
id: str
|
||||
code: str
|
||||
name: str
|
||||
template_kind: str
|
||||
entity: str
|
||||
status: str
|
||||
description: str | None = None
|
||||
current_version: str | None = None
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExchangeTemplateVersionResponse(ItiModel):
|
||||
id: str
|
||||
template_id: str
|
||||
version: str
|
||||
template_kind: str
|
||||
published_at: datetime | None = None
|
||||
file_key: str | None = None
|
||||
checksum: str | None = None
|
||||
bindings: list[dict[str, Any]] = Field(default_factory=list)
|
||||
fields: list[dict[str, Any]] = Field(default_factory=list)
|
||||
placeholders: list[dict[str, Any]] = Field(default_factory=list)
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExchangeTaskResponse(ItiModel):
|
||||
id: str
|
||||
template_id: str | None = None
|
||||
template_version_id: str | None = None
|
||||
task_kind: str
|
||||
status: str
|
||||
requested_by: str | None = None
|
||||
storage_key: str | None = None
|
||||
success_count: int
|
||||
failed_count: int
|
||||
error_count: int
|
||||
message: str | None = None
|
||||
input_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
result_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
started_at: datetime | None = None
|
||||
finished_at: datetime | None = None
|
||||
meta: dict[str, Any] = Field(default_factory=dict)
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -0,0 +1,513 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import asdict
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from iti.exceptions import BizError
|
||||
|
||||
from .base import (
|
||||
ExchangeField,
|
||||
ExchangePlaceholder,
|
||||
ExchangeTemplateBinding,
|
||||
ExchangeTemplateKind,
|
||||
ExchangePlan,
|
||||
ExchangeTemplateSnapshot,
|
||||
ExchangeTaskKind,
|
||||
)
|
||||
from .excel import ExcelTemplateCodec, ExcelWorkbookCodec
|
||||
from .models import (
|
||||
ExchangeTaskModel,
|
||||
ExchangeTaskRowModel,
|
||||
ExchangeTemplateModel,
|
||||
ExchangeTemplateVersionModel,
|
||||
)
|
||||
from .tasks import get_exchange_storage
|
||||
|
||||
|
||||
class ExchangeService:
|
||||
def __init__(self, app, db: Session) -> None:
|
||||
self.app = app
|
||||
self.db = db
|
||||
|
||||
def create_template(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
name: str,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
entity: str,
|
||||
description: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ExchangeTemplateModel:
|
||||
template = ExchangeTemplateModel(
|
||||
code=code,
|
||||
name=name,
|
||||
template_kind=_enum_value(template_kind),
|
||||
entity=entity,
|
||||
description=description,
|
||||
meta=meta or {},
|
||||
)
|
||||
self.db.add(template)
|
||||
self.db.commit()
|
||||
self.db.refresh(template)
|
||||
return template
|
||||
|
||||
def update_template(
|
||||
self,
|
||||
template_id: str,
|
||||
*,
|
||||
name: str | None = None,
|
||||
description: str | None = None,
|
||||
status: str | None = None,
|
||||
current_version: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ExchangeTemplateModel:
|
||||
template = self.get_template_or_404(template_id)
|
||||
if name is not None:
|
||||
template.name = name
|
||||
if description is not None:
|
||||
template.description = description
|
||||
if status is not None:
|
||||
template.status = status
|
||||
if current_version is not None:
|
||||
template.current_version = current_version
|
||||
if meta is not None:
|
||||
template.meta = meta
|
||||
self.db.commit()
|
||||
self.db.refresh(template)
|
||||
return template
|
||||
|
||||
def publish_version(
|
||||
self,
|
||||
*,
|
||||
template_id: str,
|
||||
version: str,
|
||||
bindings: list[ExchangeTemplateBinding] | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
placeholders: list[ExchangePlaceholder] | None = None,
|
||||
file_content: bytes | None = None,
|
||||
file_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
make_current: bool = True,
|
||||
) -> ExchangeTemplateVersionModel:
|
||||
template = self.get_template_or_404(template_id)
|
||||
file_key = None
|
||||
checksum = None
|
||||
if file_content is not None:
|
||||
file_key = self.save_template_file(
|
||||
template=template,
|
||||
version=version,
|
||||
content=file_content,
|
||||
file_name=file_name,
|
||||
)
|
||||
checksum = hashlib.sha256(file_content).hexdigest()
|
||||
|
||||
snapshot = ExchangeTemplateVersionModel(
|
||||
template_id=template.id,
|
||||
version=version,
|
||||
template_kind=template.template_kind,
|
||||
published_at=datetime.now(),
|
||||
file_key=file_key,
|
||||
checksum=checksum,
|
||||
bindings=[_jsonable(asdict(item)) for item in bindings or []],
|
||||
fields=[_jsonable(asdict(item)) for item in fields or []],
|
||||
placeholders=[_jsonable(asdict(item)) for item in placeholders or []],
|
||||
meta=meta or {},
|
||||
)
|
||||
self.db.add(snapshot)
|
||||
if make_current:
|
||||
template.current_version = version
|
||||
template.status = "published"
|
||||
self.db.commit()
|
||||
self.db.refresh(snapshot)
|
||||
return snapshot
|
||||
|
||||
def build_template_file(self, version_id: str) -> bytes:
|
||||
version = self.get_version_or_404(version_id)
|
||||
if version.file_key:
|
||||
storage = get_exchange_storage(self.app)
|
||||
with storage.download(version.file_key) as file_stream:
|
||||
return file_stream.read()
|
||||
snapshot = self.snapshot_from_model(version)
|
||||
return ExcelTemplateCodec().dump(snapshot)
|
||||
|
||||
def build_plan_template_file(self, plan: ExchangePlan) -> bytes:
|
||||
if plan.version_id:
|
||||
version = self.get_snapshot_by_version_id(plan.version_id)
|
||||
if version is not None and version.file_key:
|
||||
storage = get_exchange_storage(self.app)
|
||||
with storage.download(version.file_key) as file_stream:
|
||||
return file_stream.read()
|
||||
return ExcelTemplateCodec().dump(plan)
|
||||
|
||||
def export_rows(
|
||||
self,
|
||||
rows: list[dict[str, Any]],
|
||||
*,
|
||||
plan: ExchangePlan | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
sheet_name: str | None = None,
|
||||
) -> bytes:
|
||||
workbook_codec = ExcelWorkbookCodec()
|
||||
if plan is not None:
|
||||
return workbook_codec.export_rows_with_plan(
|
||||
plan=plan,
|
||||
rows=rows,
|
||||
sheet_name=sheet_name,
|
||||
)
|
||||
if fields is not None:
|
||||
return workbook_codec.export_rows_with_template(
|
||||
fields=fields,
|
||||
rows=rows,
|
||||
sheet_name=sheet_name or "Export",
|
||||
)
|
||||
if not rows:
|
||||
return workbook_codec.export_rows([], [], sheet_name=sheet_name or "Export")
|
||||
headers = list(rows[0].keys())
|
||||
return workbook_codec.export_rows(headers, rows, sheet_name=sheet_name or "Export")
|
||||
|
||||
def import_rows(
|
||||
self,
|
||||
content: bytes,
|
||||
*,
|
||||
plan: ExchangePlan | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
workbook_codec = ExcelWorkbookCodec()
|
||||
if plan is not None and plan.fields:
|
||||
return workbook_codec.import_rows_with_fields(content, fields=list(plan.fields))
|
||||
if fields is not None:
|
||||
return workbook_codec.import_rows_with_fields(content, fields=fields)
|
||||
return workbook_codec.import_rows(content)
|
||||
|
||||
def save_template_file(
|
||||
self,
|
||||
*,
|
||||
template: ExchangeTemplateModel,
|
||||
version: str,
|
||||
content: bytes,
|
||||
file_name: str | None = None,
|
||||
) -> str:
|
||||
suffix = _safe_suffix(file_name or "template.xlsx")
|
||||
key = f"exchange/templates/{template.code}/{version}/{hashlib.sha256(content).hexdigest()}.{suffix}"
|
||||
storage = get_exchange_storage(self.app)
|
||||
storage.upload(BytesIO(content), key, _excel_mime_type())
|
||||
return key
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
task_kind: ExchangeTaskKind | str,
|
||||
requested_by: str | None = None,
|
||||
storage_key: str | None = None,
|
||||
input_payload: dict[str, Any] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ExchangeTaskModel:
|
||||
template = self.get_template_or_404(template_id) if template_id else None
|
||||
version_model = self.get_version_or_404(version_id) if version_id else None
|
||||
if template is not None and version_model is not None and version_model.template_id != template.id:
|
||||
raise BizError("模板版本不属于该模板", code=400)
|
||||
if template is None and version_model is not None:
|
||||
template = self.get_template_or_404(version_model.template_id)
|
||||
if template is not None and version_model is None:
|
||||
if version:
|
||||
version_model = self.get_snapshot(template_id=template.id, version=version)
|
||||
elif template.current_version:
|
||||
version_model = self.get_snapshot(
|
||||
template_id=template.id,
|
||||
version=template.current_version,
|
||||
)
|
||||
task = ExchangeTaskModel(
|
||||
template_id=template.id if template is not None else None,
|
||||
template_version_id=version_model.id if version_model is not None else None,
|
||||
task_kind=_enum_value(task_kind),
|
||||
status="pending",
|
||||
requested_by=requested_by,
|
||||
storage_key=storage_key,
|
||||
input_payload=input_payload or {},
|
||||
meta=meta or {},
|
||||
)
|
||||
self.db.add(task)
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
return task
|
||||
|
||||
def get_snapshot(self, *, template_id: str, version: str) -> ExchangeTemplateSnapshot | None:
|
||||
version_model = self.db.scalar(
|
||||
select(ExchangeTemplateVersionModel)
|
||||
.where(ExchangeTemplateVersionModel.template_id == template_id)
|
||||
.where(ExchangeTemplateVersionModel.version == version)
|
||||
)
|
||||
if version_model is None:
|
||||
return None
|
||||
return self.snapshot_from_model(version_model)
|
||||
|
||||
def get_snapshot_by_version_id(self, version_id: str) -> ExchangeTemplateSnapshot | None:
|
||||
version_model = self.db.get(ExchangeTemplateVersionModel, version_id)
|
||||
if version_model is None:
|
||||
return None
|
||||
return self.snapshot_from_model(version_model)
|
||||
|
||||
def get_current_snapshot(self, template_id: str) -> ExchangeTemplateSnapshot | None:
|
||||
template = self.db.get(ExchangeTemplateModel, template_id)
|
||||
if template is None or not template.current_version:
|
||||
return None
|
||||
return self.get_snapshot(template_id=template_id, version=template.current_version)
|
||||
|
||||
def resolve_plan(
|
||||
self,
|
||||
*,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
bindings: list[ExchangeTemplateBinding] | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
placeholders: list[ExchangePlaceholder] | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
sheet_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source: Any | None = None,
|
||||
) -> ExchangePlan:
|
||||
if source is not None:
|
||||
return source.resolve_plan(
|
||||
template_kind=template_kind,
|
||||
template_id=template_id,
|
||||
version_id=version_id,
|
||||
version=version,
|
||||
bindings=bindings,
|
||||
fields=fields,
|
||||
placeholders=placeholders,
|
||||
title=title,
|
||||
description=description,
|
||||
sheet_name=sheet_name,
|
||||
meta=meta,
|
||||
)
|
||||
if version_id:
|
||||
snapshot = self.get_snapshot_by_version_id(version_id)
|
||||
if snapshot is not None:
|
||||
return snapshot.to_plan()
|
||||
if template_id and version:
|
||||
snapshot = self.get_snapshot(template_id=template_id, version=version)
|
||||
if snapshot is not None:
|
||||
return snapshot.to_plan()
|
||||
if template_id:
|
||||
current = self.get_current_snapshot(template_id)
|
||||
if current is not None:
|
||||
return current.to_plan()
|
||||
return ExchangePlan.from_mapping(
|
||||
template_kind=template_kind,
|
||||
template_id=template_id,
|
||||
version_id=version_id,
|
||||
version=version,
|
||||
bindings=bindings,
|
||||
fields=fields,
|
||||
placeholders=placeholders,
|
||||
title=title,
|
||||
description=description,
|
||||
sheet_name=sheet_name,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
def mark_task_running(self, task_id: str) -> ExchangeTaskModel:
|
||||
task = self.get_task_or_404(task_id)
|
||||
task.status = "running"
|
||||
task.started_at = datetime.now()
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
return task
|
||||
|
||||
def mark_task_finished(
|
||||
self,
|
||||
task_id: str,
|
||||
*,
|
||||
status: str = "success",
|
||||
message: str | None = None,
|
||||
result_payload: dict[str, Any] | None = None,
|
||||
success_count: int | None = None,
|
||||
failed_count: int | None = None,
|
||||
) -> ExchangeTaskModel:
|
||||
task = self.get_task_or_404(task_id)
|
||||
task.status = status
|
||||
task.message = message
|
||||
task.finished_at = datetime.now()
|
||||
if result_payload is not None:
|
||||
task.result_payload = result_payload
|
||||
if success_count is not None:
|
||||
task.success_count = success_count
|
||||
if failed_count is not None:
|
||||
task.failed_count = failed_count
|
||||
task.error_count = failed_count
|
||||
self.db.commit()
|
||||
self.db.refresh(task)
|
||||
return task
|
||||
|
||||
def add_task_row(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
row_index: int,
|
||||
status: str,
|
||||
data: dict[str, Any] | None = None,
|
||||
message: str | None = None,
|
||||
result: dict[str, Any] | None = None,
|
||||
) -> ExchangeTaskRowModel:
|
||||
row = ExchangeTaskRowModel(
|
||||
task_id=task_id,
|
||||
row_index=row_index,
|
||||
status=status,
|
||||
data=data or {},
|
||||
message=message,
|
||||
result=result or {},
|
||||
)
|
||||
self.db.add(row)
|
||||
self.db.commit()
|
||||
self.db.refresh(row)
|
||||
return row
|
||||
|
||||
def get_template_or_404(self, template_id: str) -> ExchangeTemplateModel:
|
||||
template = self.db.get(ExchangeTemplateModel, template_id)
|
||||
if template is None:
|
||||
raise BizError("模板不存在", code=404)
|
||||
return template
|
||||
|
||||
def get_version_or_404(self, version_id: str) -> ExchangeTemplateVersionModel:
|
||||
version = self.db.get(ExchangeTemplateVersionModel, version_id)
|
||||
if version is None:
|
||||
raise BizError("模板版本不存在", code=404)
|
||||
return version
|
||||
|
||||
def get_task_or_404(self, task_id: str) -> ExchangeTaskModel:
|
||||
task = self.db.get(ExchangeTaskModel, task_id)
|
||||
if task is None:
|
||||
raise BizError("导入导出任务不存在", code=404)
|
||||
return task
|
||||
|
||||
def list_templates(self) -> list[ExchangeTemplateModel]:
|
||||
return list(
|
||||
self.db.scalars(
|
||||
select(ExchangeTemplateModel).order_by(
|
||||
ExchangeTemplateModel.entity,
|
||||
ExchangeTemplateModel.code,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def list_versions(self, template_id: str) -> list[ExchangeTemplateVersionModel]:
|
||||
return list(
|
||||
self.db.scalars(
|
||||
select(ExchangeTemplateVersionModel)
|
||||
.where(ExchangeTemplateVersionModel.template_id == template_id)
|
||||
.order_by(ExchangeTemplateVersionModel.version)
|
||||
)
|
||||
)
|
||||
|
||||
def list_tasks(self, template_id: str | None = None) -> list[ExchangeTaskModel]:
|
||||
statement = select(ExchangeTaskModel).order_by(ExchangeTaskModel.created_at.desc())
|
||||
if template_id is not None:
|
||||
statement = statement.where(ExchangeTaskModel.template_id == template_id)
|
||||
return list(self.db.scalars(statement))
|
||||
|
||||
def list_task_rows(self, task_id: str) -> list[ExchangeTaskRowModel]:
|
||||
return list(
|
||||
self.db.scalars(
|
||||
select(ExchangeTaskRowModel)
|
||||
.where(ExchangeTaskRowModel.task_id == task_id)
|
||||
.order_by(ExchangeTaskRowModel.row_index)
|
||||
)
|
||||
)
|
||||
|
||||
def snapshot_from_model(
|
||||
self, version: ExchangeTemplateVersionModel
|
||||
) -> ExchangeTemplateSnapshot:
|
||||
return ExchangeTemplateSnapshot(
|
||||
id=version.id,
|
||||
version=version.version,
|
||||
template_id=version.template_id,
|
||||
template_kind=ExchangeTemplateKind(version.template_kind),
|
||||
bindings=tuple(_binding_from_dict(item) for item in version.bindings),
|
||||
published_at=version.published_at.isoformat() if version.published_at else None,
|
||||
file_key=version.file_key,
|
||||
checksum=version.checksum,
|
||||
fields=tuple(_field_from_dict(item) for item in version.fields),
|
||||
placeholders=tuple(_placeholder_from_dict(item) for item in version.placeholders),
|
||||
meta=version.meta,
|
||||
)
|
||||
|
||||
|
||||
def _field_from_dict(value: dict[str, Any]) -> ExchangeField:
|
||||
options = value.get("options") or ()
|
||||
return ExchangeField(
|
||||
key=value["key"],
|
||||
label=value["label"],
|
||||
placeholder=value.get("placeholder"),
|
||||
required=bool(value.get("required", False)),
|
||||
example=value.get("example"),
|
||||
width=value.get("width"),
|
||||
format=value.get("format"),
|
||||
source=value.get("source"),
|
||||
target=value.get("target"),
|
||||
options=tuple(tuple(item) for item in options),
|
||||
meta=value.get("meta") or {},
|
||||
)
|
||||
|
||||
|
||||
def _placeholder_from_dict(value: dict[str, Any]) -> ExchangePlaceholder:
|
||||
return ExchangePlaceholder(
|
||||
key=value["key"],
|
||||
label=value["label"],
|
||||
description=value.get("description"),
|
||||
required=bool(value.get("required", False)),
|
||||
example=value.get("example"),
|
||||
)
|
||||
|
||||
|
||||
def _binding_from_dict(value: dict[str, Any]) -> ExchangeTemplateBinding:
|
||||
return ExchangeTemplateBinding(
|
||||
entity=value["entity"],
|
||||
template_kind=ExchangeTemplateKind(value["template_kind"]),
|
||||
handler=value.get("handler"),
|
||||
description=value.get("description"),
|
||||
default_sheet_name=value.get("default_sheet_name"),
|
||||
default_file_name=value.get("default_file_name"),
|
||||
title=value.get("title"),
|
||||
meta=value.get("meta") or {},
|
||||
)
|
||||
|
||||
|
||||
def _enum_value(value: Any) -> str:
|
||||
return value.value if hasattr(value, "value") else str(value)
|
||||
|
||||
|
||||
def _safe_suffix(file_name: str) -> str:
|
||||
if "." not in file_name:
|
||||
return "xlsx"
|
||||
suffix = file_name.rsplit(".", 1)[-1].lower()
|
||||
return "".join(ch for ch in suffix if ch.isalnum()) or "xlsx"
|
||||
|
||||
|
||||
def _excel_mime_type() -> str:
|
||||
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, dict):
|
||||
return {key: _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_jsonable(item) for item in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from iti.service_client import ServiceClient, service_client
|
||||
|
||||
from .base import (
|
||||
ExchangeField,
|
||||
ExchangePlaceholder,
|
||||
ExchangePlan,
|
||||
ExchangeTemplateBinding,
|
||||
ExchangeTemplateKind,
|
||||
ExchangeTemplateSource,
|
||||
ExchangeTemplateSourceKind,
|
||||
)
|
||||
from .excel import ExcelTemplateCodec
|
||||
|
||||
|
||||
class ExchangeSource(Protocol):
|
||||
def resolve_plan(
|
||||
self,
|
||||
*,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
bindings: list[ExchangeTemplateBinding] | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
placeholders: list[ExchangePlaceholder] | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
sheet_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source: ExchangeTemplateSource | None = None,
|
||||
) -> ExchangePlan:
|
||||
...
|
||||
|
||||
def load_template_file(self, plan: ExchangePlan) -> bytes | None:
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class MappingExchangeSource:
|
||||
def resolve_plan(
|
||||
self,
|
||||
*,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
bindings: list[ExchangeTemplateBinding] | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
placeholders: list[ExchangePlaceholder] | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
sheet_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source: ExchangeTemplateSource | None = None,
|
||||
) -> ExchangePlan:
|
||||
if source is not None:
|
||||
return source.to_plan()
|
||||
return ExchangePlan.from_mapping(
|
||||
template_kind=template_kind,
|
||||
template_id=template_id,
|
||||
version_id=version_id,
|
||||
version=version,
|
||||
bindings=bindings,
|
||||
fields=fields,
|
||||
placeholders=placeholders,
|
||||
title=title,
|
||||
description=description,
|
||||
sheet_name=sheet_name,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
def load_template_file(self, plan: ExchangePlan) -> bytes | None:
|
||||
return ExcelTemplateCodec().dump(plan)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LocalExchangeSource:
|
||||
app: Any
|
||||
db: Any
|
||||
|
||||
def resolve_plan(
|
||||
self,
|
||||
*,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
bindings: list[ExchangeTemplateBinding] | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
placeholders: list[ExchangePlaceholder] | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
sheet_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source: ExchangeTemplateSource | None = None,
|
||||
) -> ExchangePlan:
|
||||
from .service import ExchangeService
|
||||
|
||||
service = ExchangeService(self.app, self.db)
|
||||
if source is not None:
|
||||
return source.to_plan()
|
||||
if version_id:
|
||||
snapshot = service.get_snapshot_by_version_id(version_id)
|
||||
if snapshot is not None:
|
||||
return snapshot.to_plan()
|
||||
if template_id:
|
||||
snapshot = service.get_current_snapshot(template_id)
|
||||
if snapshot is not None:
|
||||
return snapshot.to_plan()
|
||||
return ExchangePlan.from_mapping(
|
||||
template_kind=template_kind,
|
||||
template_id=template_id,
|
||||
version_id=version_id,
|
||||
version=version,
|
||||
bindings=bindings,
|
||||
fields=fields,
|
||||
placeholders=placeholders,
|
||||
title=title,
|
||||
description=description,
|
||||
sheet_name=sheet_name,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
def load_template_file(self, plan: ExchangePlan) -> bytes | None:
|
||||
if plan.version_id:
|
||||
from .service import ExchangeService
|
||||
|
||||
return ExchangeService(self.app, self.db).build_template_file(plan.version_id)
|
||||
return ExcelTemplateCodec().dump(plan)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RemoteExchangeSource:
|
||||
app: Any
|
||||
service_name: str = "exchange"
|
||||
|
||||
def resolve_plan(
|
||||
self,
|
||||
*,
|
||||
template_kind: ExchangeTemplateKind | str,
|
||||
template_id: str | None = None,
|
||||
version_id: str | None = None,
|
||||
version: str | None = None,
|
||||
bindings: list[ExchangeTemplateBinding] | None = None,
|
||||
fields: list[ExchangeField] | None = None,
|
||||
placeholders: list[ExchangePlaceholder] | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
sheet_name: str | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
source: ExchangeTemplateSource | None = None,
|
||||
) -> ExchangePlan:
|
||||
if source is not None:
|
||||
return source.to_plan()
|
||||
client = service_client(self.app, self.service_name)
|
||||
payload = self._fetch_plan(client, template_id=template_id, version=version, version_id=version_id)
|
||||
if payload is not None:
|
||||
meta = payload.get("meta") or {}
|
||||
return ExchangePlan.from_mapping(
|
||||
template_kind=payload.get("template_kind") or template_kind,
|
||||
template_id=payload.get("template_id") or template_id,
|
||||
version_id=payload.get("id") or version_id,
|
||||
version=payload.get("version") or version,
|
||||
bindings=[_binding_from_mapping(item) for item in payload.get("bindings", [])],
|
||||
fields=[_field_from_mapping(item) for item in payload.get("fields", [])],
|
||||
placeholders=[
|
||||
_placeholder_from_mapping(item) for item in payload.get("placeholders", [])
|
||||
],
|
||||
title=payload.get("title") or meta.get("title") or title,
|
||||
description=payload.get("description") or meta.get("description") or description,
|
||||
sheet_name=payload.get("sheet_name") or meta.get("sheet_name") or sheet_name,
|
||||
meta=meta or payload.get("meta") or {},
|
||||
)
|
||||
return ExchangePlan.from_mapping(
|
||||
template_kind=template_kind,
|
||||
template_id=template_id,
|
||||
version_id=version_id,
|
||||
version=version,
|
||||
bindings=bindings,
|
||||
fields=fields,
|
||||
placeholders=placeholders,
|
||||
title=title,
|
||||
description=description,
|
||||
sheet_name=sheet_name,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
def load_template_file(self, plan: ExchangePlan) -> bytes | None:
|
||||
if not plan.version_id:
|
||||
return ExcelTemplateCodec().dump(plan)
|
||||
client = service_client(self.app, self.service_name)
|
||||
response = client.get(
|
||||
f"/exchange/template-versions/{plan.version_id}/download",
|
||||
expect_json=False,
|
||||
)
|
||||
return response.content
|
||||
|
||||
def _fetch_plan(
|
||||
self,
|
||||
client: ServiceClient,
|
||||
*,
|
||||
template_id: str | None,
|
||||
version: str | None,
|
||||
version_id: str | None,
|
||||
) -> dict[str, Any] | None:
|
||||
if version_id:
|
||||
return client.get(f"/exchange/template-versions/{version_id}")
|
||||
if template_id and version:
|
||||
return client.get(f"/exchange/templates/{template_id}/versions/{version}")
|
||||
if template_id:
|
||||
template = client.get(f"/exchange/templates/{template_id}")
|
||||
current_version = (template or {}).get("current_version")
|
||||
if current_version:
|
||||
return client.get(f"/exchange/templates/{template_id}/versions/{current_version}")
|
||||
return None
|
||||
|
||||
|
||||
def _binding_from_mapping(item: dict[str, Any]) -> ExchangeTemplateBinding:
|
||||
return ExchangeTemplateBinding(
|
||||
entity=item.get("entity"),
|
||||
template_kind=item.get("template_kind") or item.get("templateKind"),
|
||||
handler=item.get("handler"),
|
||||
description=item.get("description"),
|
||||
default_sheet_name=item.get("default_sheet_name") or item.get("defaultSheetName"),
|
||||
default_file_name=item.get("default_file_name") or item.get("defaultFileName"),
|
||||
title=item.get("title"),
|
||||
meta=item.get("meta") or {},
|
||||
)
|
||||
|
||||
|
||||
def _field_from_mapping(item: dict[str, Any]) -> ExchangeField:
|
||||
return ExchangeField(
|
||||
key=item.get("key"),
|
||||
label=item.get("label"),
|
||||
placeholder=item.get("placeholder"),
|
||||
required=bool(item.get("required", False)),
|
||||
example=item.get("example"),
|
||||
width=item.get("width"),
|
||||
format=item.get("format"),
|
||||
source=item.get("source"),
|
||||
target=item.get("target"),
|
||||
options=tuple(tuple(option) for option in item.get("options") or []),
|
||||
meta=item.get("meta") or {},
|
||||
)
|
||||
|
||||
|
||||
def _placeholder_from_mapping(item: dict[str, Any]) -> ExchangePlaceholder:
|
||||
return ExchangePlaceholder(
|
||||
key=item.get("key"),
|
||||
label=item.get("label"),
|
||||
description=item.get("description"),
|
||||
required=bool(item.get("required", False)),
|
||||
example=item.get("example"),
|
||||
)
|
||||
|
||||
|
||||
def get_exchange_source(
|
||||
app: Any,
|
||||
*,
|
||||
source_kind: ExchangeTemplateSourceKind | str = ExchangeTemplateSourceKind.MAPPING,
|
||||
source_name: str | None = None,
|
||||
db: Any | None = None,
|
||||
service_name: str = "exchange",
|
||||
) -> ExchangeSource:
|
||||
if source_name:
|
||||
from .registry import get_exchange_registry
|
||||
|
||||
source = get_exchange_registry(app).get_source(source_name)
|
||||
if source is None:
|
||||
raise ValueError(f"exchange source not registered: {source_name}")
|
||||
return source # type: ignore[return-value]
|
||||
kind = ExchangeTemplateSourceKind(source_kind)
|
||||
if kind == ExchangeTemplateSourceKind.LOCAL:
|
||||
if db is None:
|
||||
raise ValueError("local exchange source requires db")
|
||||
return LocalExchangeSource(app=app, db=db)
|
||||
if kind == ExchangeTemplateSourceKind.REMOTE:
|
||||
return RemoteExchangeSource(app=app, service_name=service_name)
|
||||
if kind == ExchangeTemplateSourceKind.CUSTOM:
|
||||
raise ValueError("custom exchange source requires source_name")
|
||||
return MappingExchangeSource()
|
||||
@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from iti.storage import StorageManager
|
||||
from iti.tasks import task_registry
|
||||
|
||||
from .base import ExchangeTaskKind
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeTaskContext:
|
||||
task_kind: ExchangeTaskKind
|
||||
template_id: str
|
||||
version: str
|
||||
storage_key: str | None = None
|
||||
payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExchangeTaskResult:
|
||||
success_count: int = 0
|
||||
failed_count: int = 0
|
||||
message: str | None = None
|
||||
result_payload: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def register_exchange_task(
|
||||
*,
|
||||
name: str,
|
||||
handler,
|
||||
schedule: str | None = None,
|
||||
description: str | None = None,
|
||||
):
|
||||
return task_registry.register(
|
||||
name=name,
|
||||
handler=handler,
|
||||
schedule=schedule,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def wrap_exchange_task(handler):
|
||||
def runner(context: ExchangeTaskContext):
|
||||
return handler(context)
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
def get_exchange_storage(app):
|
||||
base_config = dict(getattr(app.state.config, "file_storage", {}) or {})
|
||||
exchange_config = dict(getattr(app.state.config, "exchange_storage", {}) or {})
|
||||
base_config.update(exchange_config)
|
||||
default_storage = getattr(app.state.config, "exchange_default_storage", None)
|
||||
if default_storage and "DEFAULT_STORAGE_TYPE" not in exchange_config:
|
||||
base_config["DEFAULT_STORAGE_TYPE"] = default_storage
|
||||
base_dir = getattr(app.state.config, "base_dir", None)
|
||||
return StorageManager.get_storage(config=base_config, base_dir=base_dir)
|
||||
@ -0,0 +1,360 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from iti import create_app
|
||||
from iti.config import BaseConfig
|
||||
from iti.db import Base, reset_db
|
||||
from iti.exchange import (
|
||||
ExchangeField,
|
||||
ExchangePlaceholder,
|
||||
ExchangePlan,
|
||||
ExchangeTemplateBinding,
|
||||
ExchangeTemplateKind,
|
||||
ExchangeTemplateSource,
|
||||
ExchangeTemplateSourceKind,
|
||||
LocalExchangeSource,
|
||||
MappingExchangeSource,
|
||||
RemoteExchangeSource,
|
||||
get_exchange_registry,
|
||||
register_exchange_source,
|
||||
)
|
||||
from iti.exchange.excel import ExcelTemplateCodec, ExcelWorkbookCodec
|
||||
from iti.exchange.service import ExchangeService
|
||||
from iti.exchange.base import ExchangeTemplateSnapshot
|
||||
from iti.service_client import register_service_client
|
||||
|
||||
|
||||
def make_app(*, exchange_enabled: bool = True):
|
||||
config = BaseConfig(
|
||||
database_url="sqlite+pysqlite:///:memory:",
|
||||
testing=True,
|
||||
exchange_enabled=exchange_enabled,
|
||||
)
|
||||
app = create_app(config_mapping=config)
|
||||
Base.metadata.create_all(app.state.db_engine)
|
||||
return app
|
||||
|
||||
|
||||
def test_exchange_module_is_auto_registered():
|
||||
app = make_app()
|
||||
|
||||
assert app.state.iti_modules.get("exchange") is not None
|
||||
assert "exchange:template:list" in app.state.iti_modules.permissions
|
||||
|
||||
|
||||
def test_template_version_workbook_roundtrip():
|
||||
snapshot = ExchangeTemplateSnapshot(
|
||||
id="v1",
|
||||
version="1.0.0",
|
||||
template_id="tpl1",
|
||||
template_kind="import",
|
||||
bindings=(
|
||||
ExchangeTemplateBinding(entity="order", template_kind="import", title="订单"),
|
||||
),
|
||||
fields=(
|
||||
ExchangeField(key="name", label="名称", placeholder="{{name}}", source="name"),
|
||||
),
|
||||
placeholders=(
|
||||
ExchangePlaceholder(key="tenant", label="租户", example="demo"),
|
||||
),
|
||||
meta={"title": "订单模板", "sheet_name": "模板"},
|
||||
)
|
||||
codec = ExcelTemplateCodec()
|
||||
content = codec.dump(snapshot)
|
||||
parsed = codec.load(content)
|
||||
|
||||
assert parsed["title"] == "订单模板"
|
||||
assert parsed["sheet_name"] == "模板"
|
||||
assert parsed["bindings"][0]["entity"] == "order"
|
||||
assert parsed["fields"][0]["key"] == "name"
|
||||
assert parsed["placeholders"][0]["key"] == "tenant"
|
||||
|
||||
|
||||
def test_exchange_service_create_publish_and_task_flow():
|
||||
reset_db()
|
||||
app = make_app()
|
||||
service = ExchangeService(app, app.state.db_sessionmaker())
|
||||
|
||||
template = service.create_template(
|
||||
code="order",
|
||||
name="订单",
|
||||
template_kind="import",
|
||||
entity="order",
|
||||
)
|
||||
assert template.code == "order"
|
||||
|
||||
version = service.publish_version(
|
||||
template_id=template.id,
|
||||
version="1.0.0",
|
||||
bindings=[
|
||||
ExchangeTemplateBinding(entity="order", template_kind="import", title="订单")
|
||||
],
|
||||
fields=[ExchangeField(key="name", label="名称", source="name")],
|
||||
placeholders=[ExchangePlaceholder(key="tenant", label="租户")],
|
||||
)
|
||||
assert version.version == "1.0.0"
|
||||
|
||||
task = service.create_task(
|
||||
template_id=template.id,
|
||||
version_id=version.id,
|
||||
task_kind="import",
|
||||
input_payload={"source": "upload"},
|
||||
)
|
||||
assert task.task_kind == "import"
|
||||
assert task.template_version_id == version.id
|
||||
|
||||
workbook = ExcelWorkbookCodec().export_rows_with_template(
|
||||
fields=[ExchangeField(key="name", label="名称", source="name")],
|
||||
rows=[{"name": "A"}],
|
||||
sheet_name="导出",
|
||||
)
|
||||
rows = ExcelWorkbookCodec().import_rows(workbook)
|
||||
assert rows[0]["名称"] == "A"
|
||||
|
||||
|
||||
def test_mapping_source_supports_template_less_workbook_roundtrip():
|
||||
source = MappingExchangeSource()
|
||||
plan = source.resolve_plan(
|
||||
template_kind="import",
|
||||
fields=[
|
||||
ExchangeField(key="name", label="名称", placeholder="{{name}}", source="name"),
|
||||
ExchangeField(key="age", label="年龄", source="age"),
|
||||
],
|
||||
placeholders=[ExchangePlaceholder(key="tenant", label="租户")],
|
||||
title="映射模板",
|
||||
sheet_name="映射",
|
||||
)
|
||||
|
||||
codec = ExcelTemplateCodec()
|
||||
workbook = codec.dump(plan)
|
||||
parsed = codec.load(workbook)
|
||||
|
||||
assert parsed["title"] == "映射模板"
|
||||
assert parsed["sheet_name"] == "映射"
|
||||
assert parsed["fields"][0]["source"] == "name"
|
||||
rows = ExcelWorkbookCodec().export_rows_with_plan(
|
||||
plan=plan,
|
||||
rows=[{"name": "Alice", "age": 18}],
|
||||
)
|
||||
imported = ExcelWorkbookCodec().import_rows_with_fields(rows, fields=list(plan.fields))
|
||||
assert imported[0]["name"] == "Alice"
|
||||
assert imported[0]["age"] == 18
|
||||
|
||||
|
||||
def test_excel_workbook_codec_roundtrip_uses_pandas_and_preserves_empty_cells():
|
||||
codec = ExcelWorkbookCodec()
|
||||
content = codec.export_rows(
|
||||
headers=["名称", "年龄"],
|
||||
rows=[{"名称": "Alice", "年龄": None}],
|
||||
sheet_name="导出",
|
||||
)
|
||||
|
||||
rows = codec.import_rows(content)
|
||||
|
||||
assert rows == [{"名称": "Alice", "年龄": None}]
|
||||
|
||||
|
||||
def test_excel_workbook_codec_with_fields_maps_headers_to_target_keys():
|
||||
codec = ExcelWorkbookCodec()
|
||||
fields = [
|
||||
ExchangeField(key="name", label="名称", source="name"),
|
||||
ExchangeField(key="age", label="年龄", target="age_import"),
|
||||
]
|
||||
content = codec.export_rows_with_template(
|
||||
fields=fields,
|
||||
rows=[{"name": "Alice", "age": 18}],
|
||||
sheet_name="映射",
|
||||
)
|
||||
|
||||
rows = codec.import_rows_with_fields(content, fields=fields)
|
||||
|
||||
assert rows == [{"name": "Alice", "age_import": 18}]
|
||||
|
||||
|
||||
def test_remote_source_resolves_plan_and_template_bytes():
|
||||
app = create_app(config_mapping=BaseConfig(database_url="sqlite+pysqlite:///:memory:", testing=True))
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.path == "/exchange/template-versions/v1":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"data": {
|
||||
"id": "v1",
|
||||
"template_id": "tpl1",
|
||||
"version": "1.0.0",
|
||||
"template_kind": "import",
|
||||
"bindings": [
|
||||
{"entity": "order", "template_kind": "import", "title": "订单"}
|
||||
],
|
||||
"fields": [
|
||||
{"key": "name", "label": "名称", "source": "name"}
|
||||
],
|
||||
"placeholders": [
|
||||
{"key": "tenant", "label": "租户"}
|
||||
],
|
||||
"meta": {"title": "订单模板", "sheet_name": "模板"},
|
||||
},
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
},
|
||||
)
|
||||
if request.url.path == "/exchange/template-versions/v1/download":
|
||||
content = ExcelTemplateCodec().dump(
|
||||
ExchangePlan.from_mapping(
|
||||
template_kind=ExchangeTemplateKind.IMPORT,
|
||||
template_id="tpl1",
|
||||
version_id="v1",
|
||||
version="1.0.0",
|
||||
fields=[ExchangeField(key="name", label="名称", source="name")],
|
||||
title="订单模板",
|
||||
sheet_name="模板",
|
||||
)
|
||||
)
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=content,
|
||||
headers={
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
},
|
||||
)
|
||||
raise AssertionError(f"unexpected path: {request.url.path}")
|
||||
|
||||
register_service_client(
|
||||
app,
|
||||
"template_center",
|
||||
{"base_url": "https://template-center.local"},
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
source = RemoteExchangeSource(app, service_name="template_center")
|
||||
|
||||
plan = source.resolve_plan(template_kind="import", version_id="v1")
|
||||
assert plan.template_id == "tpl1"
|
||||
assert plan.title == "订单模板"
|
||||
assert plan.fields[0].source == "name"
|
||||
|
||||
content = source.load_template_file(plan)
|
||||
assert content is not None
|
||||
assert len(content) > 0
|
||||
|
||||
|
||||
def test_custom_registered_source_can_drive_plan_and_file():
|
||||
@dataclass
|
||||
class CustomSource:
|
||||
plan: ExchangePlan
|
||||
content: bytes
|
||||
|
||||
def resolve_plan(self, **kwargs):
|
||||
return self.plan
|
||||
|
||||
def load_template_file(self, plan: ExchangePlan) -> bytes | None:
|
||||
return self.content
|
||||
|
||||
app = make_app()
|
||||
custom_plan = ExchangePlan.from_mapping(
|
||||
template_kind="import",
|
||||
template_id="tpl-custom",
|
||||
version_id="v-custom",
|
||||
version="1.0.0",
|
||||
fields=[ExchangeField(key="name", label="名称", source="name")],
|
||||
title="自定义模板",
|
||||
)
|
||||
register_exchange_source(
|
||||
app,
|
||||
"custom-center",
|
||||
CustomSource(plan=custom_plan, content=b"custom-template"),
|
||||
)
|
||||
|
||||
assert get_exchange_registry(app).get_source("custom-center") is not None
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/exchange/plans/resolve",
|
||||
json={
|
||||
"templateKind": "import",
|
||||
"sourceName": "custom-center",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["template_id"] == "tpl-custom"
|
||||
|
||||
file_resp = client.post(
|
||||
"/exchange/plans/template-file",
|
||||
json={
|
||||
"templateKind": "import",
|
||||
"sourceName": "custom-center",
|
||||
},
|
||||
)
|
||||
assert file_resp.status_code == 200
|
||||
assert file_resp.content == b"custom-template"
|
||||
|
||||
|
||||
def test_exchange_routes_are_available():
|
||||
app = make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
created = client.post(
|
||||
"/exchange/templates",
|
||||
json={
|
||||
"code": "order",
|
||||
"name": "订单",
|
||||
"template_kind": "import",
|
||||
"entity": "order",
|
||||
},
|
||||
)
|
||||
assert created.status_code == 200
|
||||
template_id = created.json()["data"]["id"]
|
||||
|
||||
version = client.post(
|
||||
f"/exchange/templates/{template_id}/versions",
|
||||
json={
|
||||
"version": "1.0.0",
|
||||
"bindings": [],
|
||||
"fields": [],
|
||||
"placeholders": [],
|
||||
},
|
||||
)
|
||||
assert version.status_code == 200
|
||||
|
||||
listed = client.get("/exchange/templates")
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["data"][0]["code"] == "order"
|
||||
|
||||
|
||||
def test_exchange_plan_routes_support_mapping_source():
|
||||
app = make_app()
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/exchange/plans/resolve",
|
||||
json={
|
||||
"taskKind": "import",
|
||||
"sourceKind": "mapping",
|
||||
"fields": [
|
||||
{"key": "name", "label": "名称", "source": "name"},
|
||||
],
|
||||
"title": "映射",
|
||||
"sheetName": "映射",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["title"] == "映射"
|
||||
|
||||
file_resp = client.post(
|
||||
"/exchange/plans/template-file",
|
||||
json={
|
||||
"taskKind": "import",
|
||||
"sourceKind": "mapping",
|
||||
"fields": [
|
||||
{"key": "name", "label": "名称", "source": "name"},
|
||||
],
|
||||
"title": "映射",
|
||||
"sheetName": "映射",
|
||||
},
|
||||
)
|
||||
assert file_resp.status_code == 200
|
||||
Loading…
Reference in New Issue