From 6dd4d691633257d6eb96f273924eb8e484007891 Mon Sep 17 00:00:00 2001 From: Mikhail Date: Sun, 17 May 2026 22:10:06 +0300 Subject: [PATCH] Extract import sync preview service --- .../src/api_server/import_sync_models.py | 25 ++++++ .../src/api_server/import_sync_service.py | 73 +++++++++++++++ services/api-server/src/api_server/main.py | 90 +------------------ 3 files changed, 100 insertions(+), 88 deletions(-) create mode 100644 services/api-server/src/api_server/import_sync_models.py create mode 100644 services/api-server/src/api_server/import_sync_service.py diff --git a/services/api-server/src/api_server/import_sync_models.py b/services/api-server/src/api_server/import_sync_models.py new file mode 100644 index 0000000..33767cd --- /dev/null +++ b/services/api-server/src/api_server/import_sync_models.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class ImportSyncDiffItem(BaseModel): + qualified_name: str + name: str + object_kind: str + group_name: str | None = None + change_kind: str + before_hash: str | None = None + after_hash: str | None = None + + +class ImportSyncPreview(BaseModel): + mode: str = "SYNC_PREVIEW" + applied: bool = False + status: str = "preview_only" + message: str + added_count: int = 0 + removed_count: int = 0 + changed_count: int = 0 + unchanged_count: int = 0 + items: list[ImportSyncDiffItem] = Field(default_factory=list) diff --git a/services/api-server/src/api_server/import_sync_service.py b/services/api-server/src/api_server/import_sync_service.py new file mode 100644 index 0000000..0dc7f84 --- /dev/null +++ b/services/api-server/src/api_server/import_sync_service.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import json + +from api_server.import_sync_models import ImportSyncDiffItem, ImportSyncPreview +from one_c_normalizer import NormalizedProject +from sir import stable_hash + + +def build_import_sync_preview( + current: NormalizedProject | None, + incoming: NormalizedProject | None, +) -> ImportSyncPreview: + current_index = normalized_object_hash_index(current) + incoming_index = normalized_object_hash_index(incoming) + items: list[ImportSyncDiffItem] = [] + + for qualified_name in sorted(set(current_index) | set(incoming_index)): + before = current_index.get(qualified_name) + after = incoming_index.get(qualified_name) + if before is None and after is not None: + change_kind = "ADD" + elif before is not None and after is None: + change_kind = "REMOVE" + elif before is not None and after is not None and before["hash"] != after["hash"]: + change_kind = "UPDATE" + else: + change_kind = "UNCHANGED" + + source = after or before or {} + items.append( + ImportSyncDiffItem( + qualified_name=qualified_name, + name=str(source.get("name", qualified_name)), + object_kind=str(source.get("object_kind", "UNKNOWN")), + group_name=source.get("group_name"), + change_kind=change_kind, + before_hash=before["hash"] if before is not None else None, + after_hash=after["hash"] if after is not None else None, + ) + ) + + added_count = sum(1 for item in items if item.change_kind == "ADD") + removed_count = sum(1 for item in items if item.change_kind == "REMOVE") + changed_count = sum(1 for item in items if item.change_kind == "UPDATE") + unchanged_count = sum(1 for item in items if item.change_kind == "UNCHANGED") + return ImportSyncPreview( + message=( + "Synchronization is not applied yet. This preview shows what would change; " + "use FULL_REPLACE to replace current project data." + ), + added_count=added_count, + removed_count=removed_count, + changed_count=changed_count, + unchanged_count=unchanged_count, + items=[item for item in items if item.change_kind != "UNCHANGED"], + ) + + +def normalized_object_hash_index(normalized: NormalizedProject | None) -> dict[str, dict]: + if normalized is None: + return {} + result: dict[str, dict] = {} + for group in normalized.configuration.groups: + for item in group.objects: + payload = item.model_dump(mode="json") + result[item.qualified_name] = { + "name": item.name, + "object_kind": item.object_kind, + "group_name": group.name, + "hash": stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True)), + } + return result diff --git a/services/api-server/src/api_server/main.py b/services/api-server/src/api_server/main.py index 0408c87..817a243 100644 --- a/services/api-server/src/api_server/main.py +++ b/services/api-server/src/api_server/main.py @@ -96,6 +96,8 @@ from api_server.html5_setup_controller import ( ) from api_server.import_quality_models import ImportQualityResponse from api_server.import_quality_service import import_quality_response as _build_import_quality_response +from api_server.import_sync_models import ImportSyncPreview +from api_server.import_sync_service import build_import_sync_preview as _build_import_sync_preview from api_server.metadata_tree_controller import ( metadata_tree as _metadata_tree, metadata_tree_children as _metadata_tree_children, @@ -923,28 +925,6 @@ class ImportRequest(BaseModel): mode: ImportMode = ImportMode.FULL_REPLACE -class ImportSyncDiffItem(BaseModel): - qualified_name: str - name: str - object_kind: str - group_name: str | None = None - change_kind: str - before_hash: str | None = None - after_hash: str | None = None - - -class ImportSyncPreview(BaseModel): - mode: ImportMode = ImportMode.SYNC_PREVIEW - applied: bool = False - status: str = "preview_only" - message: str - added_count: int = 0 - removed_count: int = 0 - changed_count: int = 0 - unchanged_count: int = 0 - items: list[ImportSyncDiffItem] = Field(default_factory=list) - - class ImportSummary(BaseModel): source: ImportSourceKind mode: ImportMode = ImportMode.FULL_REPLACE @@ -7249,72 +7229,6 @@ def _import_project_sync_preview(project_id: str, request: ImportRequest) -> Imp return summary -def _build_import_sync_preview( - current: NormalizedProject | None, - incoming: NormalizedProject | None, -) -> ImportSyncPreview: - current_index = _normalized_object_hash_index(current) - incoming_index = _normalized_object_hash_index(incoming) - items: list[ImportSyncDiffItem] = [] - - for qualified_name in sorted(set(current_index) | set(incoming_index)): - before = current_index.get(qualified_name) - after = incoming_index.get(qualified_name) - if before is None and after is not None: - change_kind = "ADD" - elif before is not None and after is None: - change_kind = "REMOVE" - elif before is not None and after is not None and before["hash"] != after["hash"]: - change_kind = "UPDATE" - else: - change_kind = "UNCHANGED" - - source = after or before or {} - items.append( - ImportSyncDiffItem( - qualified_name=qualified_name, - name=str(source.get("name", qualified_name)), - object_kind=str(source.get("object_kind", "UNKNOWN")), - group_name=source.get("group_name"), - change_kind=change_kind, - before_hash=before["hash"] if before is not None else None, - after_hash=after["hash"] if after is not None else None, - ) - ) - - added_count = sum(1 for item in items if item.change_kind == "ADD") - removed_count = sum(1 for item in items if item.change_kind == "REMOVE") - changed_count = sum(1 for item in items if item.change_kind == "UPDATE") - unchanged_count = sum(1 for item in items if item.change_kind == "UNCHANGED") - return ImportSyncPreview( - message=( - "Synchronization is not applied yet. This preview shows what would change; " - "use FULL_REPLACE to replace current project data." - ), - added_count=added_count, - removed_count=removed_count, - changed_count=changed_count, - unchanged_count=unchanged_count, - items=[item for item in items if item.change_kind != "UNCHANGED"], - ) - - -def _normalized_object_hash_index(normalized: NormalizedProject | None) -> dict[str, dict]: - if normalized is None: - return {} - result: dict[str, dict] = {} - for group in normalized.configuration.groups: - for item in group.objects: - payload = item.model_dump(mode="json") - result[item.qualified_name] = { - "name": item.name, - "object_kind": item.object_kind, - "group_name": group.name, - "hash": stable_hash(json.dumps(payload, ensure_ascii=False, sort_keys=True)), - } - return result - - def _append_import_history(project_id: str, summary: ImportSummary) -> None: state = _project_setup.setdefault(project_id, {}) history = list(state.get("import_history", []))