You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
361 lines
11 KiB
Python
361 lines
11 KiB
Python
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
|