|
|
@@ -0,0 +1,440 @@
|
|
|
+from __future__ import annotations
|
|
|
+
|
|
|
+import hashlib
|
|
|
+import json
|
|
|
+from dataclasses import dataclass, replace
|
|
|
+from datetime import datetime
|
|
|
+from typing import Any, Callable
|
|
|
+from uuid import UUID
|
|
|
+
|
|
|
+from app.core.common.identifiers import new_governance_uid
|
|
|
+from app.core.common.timezone_utils import now_china
|
|
|
+from app.core.data_research.errors import (
|
|
|
+ DeviceAssetInvalid,
|
|
|
+ DeviceAssetNotFound,
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+ASSET_TYPES = frozenset(
|
|
|
+ {
|
|
|
+ "device",
|
|
|
+ "component",
|
|
|
+ "measurement_point",
|
|
|
+ "alarm",
|
|
|
+ "maintenance_record",
|
|
|
+ }
|
|
|
+)
|
|
|
+ASSET_STATUSES = frozenset({"active", "retired"})
|
|
|
+MAX_IMPORT_RECORDS = 500
|
|
|
+MAX_ATTRIBUTES_BYTES = 64_000
|
|
|
+SECRET_KEYS = frozenset(
|
|
|
+ {
|
|
|
+ "password",
|
|
|
+ "passwd",
|
|
|
+ "credential",
|
|
|
+ "credentials",
|
|
|
+ "encrypted_payload",
|
|
|
+ "api_key",
|
|
|
+ "token",
|
|
|
+ "authorization",
|
|
|
+ "connection_string",
|
|
|
+ "connection_url",
|
|
|
+ "conn_str",
|
|
|
+ }
|
|
|
+)
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceAssetRecord:
|
|
|
+ uid: str
|
|
|
+ asset_type: str
|
|
|
+ name: str
|
|
|
+ status: str
|
|
|
+ current_version: int
|
|
|
+ content_hash: str
|
|
|
+ location: str | None
|
|
|
+ organization: str | None
|
|
|
+ responsible_person: str | None
|
|
|
+ attributes: dict[str, Any]
|
|
|
+ created_by: str | None
|
|
|
+ updated_by: str | None
|
|
|
+ created_at: datetime | None = None
|
|
|
+ updated_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceAssetMappingRecord:
|
|
|
+ uid: str
|
|
|
+ asset_uid: str
|
|
|
+ source_uid: str
|
|
|
+ source_entity: str
|
|
|
+ asset_type: str
|
|
|
+ source_code: str
|
|
|
+ source_updated_at: datetime | None
|
|
|
+ first_seen_at: datetime | None = None
|
|
|
+ last_seen_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceAssetVersionRecord:
|
|
|
+ uid: str
|
|
|
+ asset_uid: str
|
|
|
+ version: int
|
|
|
+ content_hash: str
|
|
|
+ snapshot: dict[str, Any]
|
|
|
+ source_mapping_uid: str
|
|
|
+ actor_uid: str | None
|
|
|
+ created_at: datetime | None = None
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceAssetDetail:
|
|
|
+ asset: DeviceAssetRecord
|
|
|
+ mappings: tuple[DeviceAssetMappingRecord, ...]
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceAssetImportItem:
|
|
|
+ action: str
|
|
|
+ asset: DeviceAssetRecord
|
|
|
+ mapping: DeviceAssetMappingRecord
|
|
|
+
|
|
|
+
|
|
|
+@dataclass(frozen=True)
|
|
|
+class DeviceAssetImportResult:
|
|
|
+ items: tuple[DeviceAssetImportItem, ...]
|
|
|
+ created_count: int
|
|
|
+ updated_count: int
|
|
|
+ unchanged_count: int
|
|
|
+
|
|
|
+
|
|
|
+def _required_text(payload: dict[str, Any], name: str, *, maximum=300) -> str:
|
|
|
+ value = str(payload.get(name) or "").strip()
|
|
|
+ if not value:
|
|
|
+ raise DeviceAssetInvalid(f"{name} is required")
|
|
|
+ if len(value) > maximum:
|
|
|
+ raise DeviceAssetInvalid(f"{name} exceeds {maximum} characters")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _optional_text(payload: dict[str, Any], name: str, *, maximum=300):
|
|
|
+ value = str(payload.get(name) or "").strip()
|
|
|
+ if not value:
|
|
|
+ return None
|
|
|
+ if len(value) > maximum:
|
|
|
+ raise DeviceAssetInvalid(f"{name} exceeds {maximum} characters")
|
|
|
+ return value
|
|
|
+
|
|
|
+
|
|
|
+def _source_time(value: Any) -> datetime | None:
|
|
|
+ if value in (None, ""):
|
|
|
+ return None
|
|
|
+ try:
|
|
|
+ return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
|
|
|
+ except (TypeError, ValueError) as exc:
|
|
|
+ raise DeviceAssetInvalid("source_updated_at must be ISO-8601") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _source_uid(payload: dict[str, Any]) -> str:
|
|
|
+ value = _required_text(payload, "source_uid", maximum=100)
|
|
|
+ try:
|
|
|
+ return str(UUID(value))
|
|
|
+ except (AttributeError, TypeError, ValueError) as exc:
|
|
|
+ raise DeviceAssetInvalid("source_uid must be a UUID") from exc
|
|
|
+
|
|
|
+
|
|
|
+def _search_filters(filters: Any) -> dict[str, str]:
|
|
|
+ if not isinstance(filters, dict):
|
|
|
+ raise DeviceAssetInvalid("filters must be an object")
|
|
|
+ normalized = {}
|
|
|
+ keyword = str(filters.get("keyword") or "").strip()
|
|
|
+ if len(keyword) > 300:
|
|
|
+ raise DeviceAssetInvalid("keyword exceeds 300 characters")
|
|
|
+ if keyword:
|
|
|
+ normalized["keyword"] = keyword
|
|
|
+ asset_type = str(filters.get("asset_type") or "").strip()
|
|
|
+ if asset_type and asset_type not in ASSET_TYPES:
|
|
|
+ raise DeviceAssetInvalid("asset_type is not supported")
|
|
|
+ if asset_type:
|
|
|
+ normalized["asset_type"] = asset_type
|
|
|
+ status = str(filters.get("status") or "").strip()
|
|
|
+ if status and status not in ASSET_STATUSES:
|
|
|
+ raise DeviceAssetInvalid("status is not supported")
|
|
|
+ if status:
|
|
|
+ normalized["status"] = status
|
|
|
+ if filters.get("source_uid"):
|
|
|
+ normalized["source_uid"] = _source_uid(filters)
|
|
|
+ return normalized
|
|
|
+
|
|
|
+
|
|
|
+def _reject_secrets(value: Any) -> None:
|
|
|
+ if isinstance(value, dict):
|
|
|
+ for key, item in value.items():
|
|
|
+ normalized = str(key).strip().lower()
|
|
|
+ if normalized in SECRET_KEYS:
|
|
|
+ raise DeviceAssetInvalid(
|
|
|
+ "secret attributes are not allowed in device assets"
|
|
|
+ )
|
|
|
+ _reject_secrets(item)
|
|
|
+ elif isinstance(value, (list, tuple)):
|
|
|
+ for item in value:
|
|
|
+ _reject_secrets(item)
|
|
|
+
|
|
|
+
|
|
|
+def _normalize_record(raw: Any) -> tuple[dict[str, Any], datetime | None]:
|
|
|
+ if not isinstance(raw, dict):
|
|
|
+ raise DeviceAssetInvalid("each device asset record must be an object")
|
|
|
+ if "uid" in raw or "asset_uid" in raw:
|
|
|
+ raise DeviceAssetInvalid("uid is assigned by the platform")
|
|
|
+ asset_type = _required_text(raw, "asset_type", maximum=40)
|
|
|
+ if asset_type not in ASSET_TYPES:
|
|
|
+ raise DeviceAssetInvalid("asset_type is not supported")
|
|
|
+ status = str(raw.get("status") or "active").strip()
|
|
|
+ if status not in ASSET_STATUSES:
|
|
|
+ raise DeviceAssetInvalid("status is not supported")
|
|
|
+ attributes = raw.get("attributes") or {}
|
|
|
+ if not isinstance(attributes, dict):
|
|
|
+ raise DeviceAssetInvalid("attributes must be an object")
|
|
|
+ _reject_secrets(attributes)
|
|
|
+ serialized = json.dumps(
|
|
|
+ attributes,
|
|
|
+ ensure_ascii=False,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ )
|
|
|
+ if len(serialized.encode("utf-8")) > MAX_ATTRIBUTES_BYTES:
|
|
|
+ raise DeviceAssetInvalid("attributes exceed the catalog boundary")
|
|
|
+ normalized_attributes = json.loads(serialized)
|
|
|
+ snapshot = {
|
|
|
+ "asset_type": asset_type,
|
|
|
+ "source_code": _required_text(raw, "source_code"),
|
|
|
+ "name": _required_text(raw, "name"),
|
|
|
+ "status": status,
|
|
|
+ "location": _optional_text(raw, "location"),
|
|
|
+ "organization": _optional_text(raw, "organization"),
|
|
|
+ "responsible_person": _optional_text(raw, "responsible_person"),
|
|
|
+ "attributes": normalized_attributes,
|
|
|
+ }
|
|
|
+ return snapshot, _source_time(raw.get("source_updated_at"))
|
|
|
+
|
|
|
+
|
|
|
+def _content_hash(snapshot: dict[str, Any]) -> str:
|
|
|
+ canonical = json.dumps(
|
|
|
+ snapshot,
|
|
|
+ ensure_ascii=False,
|
|
|
+ sort_keys=True,
|
|
|
+ separators=(",", ":"),
|
|
|
+ )
|
|
|
+ return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
|
+
|
|
|
+
|
|
|
+class DeviceAssetService:
|
|
|
+ def __init__(
|
|
|
+ self,
|
|
|
+ repository,
|
|
|
+ *,
|
|
|
+ uid_factory: Callable[[], str] = new_governance_uid,
|
|
|
+ now_factory: Callable[[], datetime] = now_china,
|
|
|
+ commit: Callable[[], Any] = lambda: None,
|
|
|
+ rollback: Callable[[], Any] = lambda: None,
|
|
|
+ ):
|
|
|
+ self.repository = repository
|
|
|
+ self.uid_factory = uid_factory
|
|
|
+ self.now_factory = now_factory
|
|
|
+ self.commit = commit
|
|
|
+ self.rollback = rollback
|
|
|
+
|
|
|
+ def import_records(self, payload, actor_uid) -> DeviceAssetImportResult:
|
|
|
+ if not isinstance(payload, dict):
|
|
|
+ raise DeviceAssetInvalid("payload must be an object")
|
|
|
+ source_uid = _source_uid(payload)
|
|
|
+ source_entity = _required_text(
|
|
|
+ payload,
|
|
|
+ "source_entity",
|
|
|
+ maximum=300,
|
|
|
+ )
|
|
|
+ if not self.repository.source_is_active(source_uid):
|
|
|
+ raise DeviceAssetInvalid("source_uid must reference an active source")
|
|
|
+ raw_records = payload.get("records")
|
|
|
+ if not isinstance(raw_records, list) or not raw_records:
|
|
|
+ raise DeviceAssetInvalid("records must be a non-empty array")
|
|
|
+ if len(raw_records) > MAX_IMPORT_RECORDS:
|
|
|
+ raise DeviceAssetInvalid("records cannot exceed 500 items")
|
|
|
+
|
|
|
+ normalized_records = []
|
|
|
+ identities = set()
|
|
|
+ for raw in raw_records:
|
|
|
+ snapshot, source_updated_at = _normalize_record(raw)
|
|
|
+ identity = (
|
|
|
+ source_uid,
|
|
|
+ source_entity,
|
|
|
+ snapshot["asset_type"],
|
|
|
+ snapshot["source_code"],
|
|
|
+ )
|
|
|
+ if identity in identities:
|
|
|
+ raise DeviceAssetInvalid(
|
|
|
+ "duplicate source identity in import request"
|
|
|
+ )
|
|
|
+ identities.add(identity)
|
|
|
+ normalized_records.append((snapshot, source_updated_at))
|
|
|
+
|
|
|
+ items = []
|
|
|
+ try:
|
|
|
+ for snapshot, source_updated_at in normalized_records:
|
|
|
+ items.append(
|
|
|
+ self._import_one(
|
|
|
+ source_uid,
|
|
|
+ source_entity,
|
|
|
+ snapshot,
|
|
|
+ source_updated_at,
|
|
|
+ actor_uid,
|
|
|
+ )
|
|
|
+ )
|
|
|
+ self.commit()
|
|
|
+ except Exception:
|
|
|
+ self.rollback()
|
|
|
+ raise
|
|
|
+ actions = [item.action for item in items]
|
|
|
+ return DeviceAssetImportResult(
|
|
|
+ items=tuple(items),
|
|
|
+ created_count=actions.count("created"),
|
|
|
+ updated_count=actions.count("updated"),
|
|
|
+ unchanged_count=actions.count("unchanged"),
|
|
|
+ )
|
|
|
+
|
|
|
+ def _import_one(
|
|
|
+ self,
|
|
|
+ source_uid,
|
|
|
+ source_entity,
|
|
|
+ snapshot,
|
|
|
+ source_updated_at,
|
|
|
+ actor_uid,
|
|
|
+ ):
|
|
|
+ now = self.now_factory()
|
|
|
+ digest = _content_hash(snapshot)
|
|
|
+ mapping = self.repository.find_mapping(
|
|
|
+ source_uid,
|
|
|
+ source_entity,
|
|
|
+ snapshot["asset_type"],
|
|
|
+ snapshot["source_code"],
|
|
|
+ for_update=True,
|
|
|
+ )
|
|
|
+ if mapping is None:
|
|
|
+ asset = DeviceAssetRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ asset_type=snapshot["asset_type"],
|
|
|
+ name=snapshot["name"],
|
|
|
+ status=snapshot["status"],
|
|
|
+ current_version=1,
|
|
|
+ content_hash=digest,
|
|
|
+ location=snapshot["location"],
|
|
|
+ organization=snapshot["organization"],
|
|
|
+ responsible_person=snapshot["responsible_person"],
|
|
|
+ attributes=dict(snapshot["attributes"]),
|
|
|
+ created_by=actor_uid,
|
|
|
+ updated_by=actor_uid,
|
|
|
+ created_at=now,
|
|
|
+ updated_at=now,
|
|
|
+ )
|
|
|
+ mapping = DeviceAssetMappingRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ asset_uid=asset.uid,
|
|
|
+ source_uid=source_uid,
|
|
|
+ source_entity=source_entity,
|
|
|
+ asset_type=asset.asset_type,
|
|
|
+ source_code=snapshot["source_code"],
|
|
|
+ source_updated_at=source_updated_at,
|
|
|
+ first_seen_at=now,
|
|
|
+ last_seen_at=now,
|
|
|
+ )
|
|
|
+ version = DeviceAssetVersionRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ asset_uid=asset.uid,
|
|
|
+ version=1,
|
|
|
+ content_hash=digest,
|
|
|
+ snapshot=dict(snapshot),
|
|
|
+ source_mapping_uid=mapping.uid,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ asset, mapping = self.repository.create_asset(
|
|
|
+ asset,
|
|
|
+ mapping,
|
|
|
+ version,
|
|
|
+ )
|
|
|
+ return DeviceAssetImportItem("created", asset, mapping)
|
|
|
+
|
|
|
+ asset = self.repository.get(mapping.asset_uid)
|
|
|
+ if asset is None:
|
|
|
+ raise DeviceAssetInvalid("source mapping references a missing asset")
|
|
|
+ if asset.content_hash == digest:
|
|
|
+ mapping = self.repository.touch_mapping(
|
|
|
+ mapping,
|
|
|
+ source_updated_at=source_updated_at,
|
|
|
+ last_seen_at=now,
|
|
|
+ )
|
|
|
+ return DeviceAssetImportItem("unchanged", asset, mapping)
|
|
|
+
|
|
|
+ asset = replace(
|
|
|
+ asset,
|
|
|
+ name=snapshot["name"],
|
|
|
+ status=snapshot["status"],
|
|
|
+ current_version=asset.current_version + 1,
|
|
|
+ content_hash=digest,
|
|
|
+ location=snapshot["location"],
|
|
|
+ organization=snapshot["organization"],
|
|
|
+ responsible_person=snapshot["responsible_person"],
|
|
|
+ attributes=dict(snapshot["attributes"]),
|
|
|
+ updated_by=actor_uid,
|
|
|
+ updated_at=now,
|
|
|
+ )
|
|
|
+ mapping = replace(
|
|
|
+ mapping,
|
|
|
+ source_updated_at=source_updated_at,
|
|
|
+ last_seen_at=now,
|
|
|
+ )
|
|
|
+ version = DeviceAssetVersionRecord(
|
|
|
+ uid=self.uid_factory(),
|
|
|
+ asset_uid=asset.uid,
|
|
|
+ version=asset.current_version,
|
|
|
+ content_hash=digest,
|
|
|
+ snapshot=dict(snapshot),
|
|
|
+ source_mapping_uid=mapping.uid,
|
|
|
+ actor_uid=actor_uid,
|
|
|
+ created_at=now,
|
|
|
+ )
|
|
|
+ asset, mapping = self.repository.update_asset(
|
|
|
+ asset,
|
|
|
+ mapping,
|
|
|
+ version,
|
|
|
+ )
|
|
|
+ return DeviceAssetImportItem("updated", asset, mapping)
|
|
|
+
|
|
|
+ def search(self, filters, *, page=1, page_size=20):
|
|
|
+ assets, total = self.repository.search(
|
|
|
+ _search_filters(filters or {}),
|
|
|
+ page=int(page),
|
|
|
+ page_size=int(page_size),
|
|
|
+ )
|
|
|
+ return [
|
|
|
+ DeviceAssetDetail(
|
|
|
+ asset=asset,
|
|
|
+ mappings=tuple(self.repository.list_mappings(asset.uid)),
|
|
|
+ )
|
|
|
+ for asset in assets
|
|
|
+ ], total
|
|
|
+
|
|
|
+ def get(self, asset_uid) -> DeviceAssetDetail:
|
|
|
+ asset = self.repository.get(str(asset_uid))
|
|
|
+ if asset is None:
|
|
|
+ raise DeviceAssetNotFound(
|
|
|
+ f"device asset {asset_uid} was not found"
|
|
|
+ )
|
|
|
+ return DeviceAssetDetail(
|
|
|
+ asset=asset,
|
|
|
+ mappings=tuple(self.repository.list_mappings(asset.uid)),
|
|
|
+ )
|
|
|
+
|
|
|
+ def versions(self, asset_uid):
|
|
|
+ self.get(asset_uid)
|
|
|
+ return self.repository.list_versions(str(asset_uid))
|