"""Детерминированный content_hash для сущностей с полем content_hash (sync pull/push).""" from __future__ import annotations import hashlib import json from typing import Any # Не входят в хеш: версия/метаданные синка и аудит (иначе хеш «прыгает» без смены данных). _EXCLUDED_FROM_HASH = frozenset( { "version", "content_hash", "sync_timestamp", "sync_status", "created_at", "updated_at", "created_by", "updated_by", "client_id", "server_synced", "is_deleted", "deleted_at", "deleted_by", "deleted_reason", "restored_at", "restored_by", "restored_reason", "delete_restore_count", } ) def stable_payload_for_hash(data: dict[str, Any]) -> dict[str, Any]: return {k: v for k, v in data.items() if k not in _EXCLUDED_FROM_HASH} def compute_content_hash_hex(payload: dict[str, Any]) -> str: raw = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode("utf-8") return hashlib.sha256(raw).hexdigest() def compute_content_hash_for_object(obj: Any) -> str: from app.services.sync_record_data import get_object_data data = get_object_data(obj) if not isinstance(data, dict): return "" stable = stable_payload_for_hash(data) return compute_content_hash_hex(stable) def refresh_content_hash_if_applicable(obj: Any) -> None: if not hasattr(obj, "content_hash"): return new_hash = compute_content_hash_for_object(obj) if new_hash and getattr(obj, "content_hash", None) != new_hash: obj.content_hash = new_hash def refresh_content_hashes_for_session(session) -> None: """Вызывать из before_flush: новые и изменённые строки с content_hash.""" seen: set[int] = set() for obj in list(session.new) + list(session.dirty): oid = id(obj) if oid in seen: continue seen.add(oid) if not hasattr(obj, "content_hash"): continue refresh_content_hash_if_applicable(obj)