feat: sync saved views and metadata entities to replicas
All checks were successful
Deploy / deploy (push) Successful in 34s
All checks were successful
Deploy / deploy (push) Successful in 34s
- Sync Tags, Correspondents, Document Types, Custom Fields via _ensure_schema_parity (already existed) - Add saved views sync: create/update on replica with filter rule ID translation - _FILTER_RULE_ENTITY_MAP translates entity-referencing rule types (correspondents, document types, tags, custom fields) to replica IDs - Saved views sync is best-effort (non-fatal on failure) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -134,6 +134,77 @@ def _translate_metadata(meta: dict, maps: dict) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# Maps paperless-ngx FilterRuleType integers that reference entity IDs.
|
||||||
|
# Based on paperless-ngx FilterRuleType enum (stable since v1.14).
|
||||||
|
_FILTER_RULE_ENTITY_MAP: dict[int, str] = {
|
||||||
|
3: "correspondents", # CORRESPONDENT
|
||||||
|
4: "document_types", # DOCUMENT_TYPE
|
||||||
|
6: "tags", # HAS_TAG
|
||||||
|
17: "tags", # DOES_NOT_HAVE_TAG
|
||||||
|
21: "correspondents", # DOES_NOT_HAVE_CORRESPONDENT
|
||||||
|
22: "document_types", # DOES_NOT_HAVE_DOCUMENT_TYPE
|
||||||
|
25: "custom_fields", # HAS_CUSTOM_FIELD
|
||||||
|
26: "custom_fields", # DOES_NOT_HAVE_CUSTOM_FIELD
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _sync_saved_views(
|
||||||
|
master: PaperlessClient,
|
||||||
|
replica: PaperlessClient,
|
||||||
|
maps: dict,
|
||||||
|
replica_obj: Replica,
|
||||||
|
run_id: int,
|
||||||
|
session: Session,
|
||||||
|
) -> None:
|
||||||
|
"""Sync saved views from master to replica, translating entity IDs in filter rules."""
|
||||||
|
master_views = await master.get_saved_views()
|
||||||
|
replica_views = {v["name"]: v for v in await replica.get_saved_views()}
|
||||||
|
|
||||||
|
created = updated = 0
|
||||||
|
for view in master_views:
|
||||||
|
translated_rules = []
|
||||||
|
for rule in view.get("filter_rules", []):
|
||||||
|
rule_type = rule.get("rule_type")
|
||||||
|
value = rule.get("value")
|
||||||
|
entity_key = _FILTER_RULE_ENTITY_MAP.get(rule_type)
|
||||||
|
if entity_key and value is not None:
|
||||||
|
try:
|
||||||
|
replica_id = maps[entity_key].get(int(value))
|
||||||
|
if replica_id is None:
|
||||||
|
continue # entity not synced yet — skip rule
|
||||||
|
value = str(replica_id)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # non-integer value, pass through unchanged
|
||||||
|
translated_rules.append({"rule_type": rule_type, "value": value})
|
||||||
|
|
||||||
|
view_data = {
|
||||||
|
"name": view["name"],
|
||||||
|
"show_on_dashboard": view.get("show_on_dashboard", False),
|
||||||
|
"show_in_sidebar": view.get("show_in_sidebar", False),
|
||||||
|
"sort_field": view.get("sort_field", ""),
|
||||||
|
"sort_reverse": view.get("sort_reverse", False),
|
||||||
|
"filter_rules": translated_rules,
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = replica_views.get(view["name"])
|
||||||
|
if existing:
|
||||||
|
await replica.update_saved_view(existing["id"], view_data)
|
||||||
|
updated += 1
|
||||||
|
else:
|
||||||
|
await replica.create_saved_view(view_data)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created or updated:
|
||||||
|
emit_log(
|
||||||
|
"info",
|
||||||
|
f"Saved views synced: {created} created, {updated} updated",
|
||||||
|
replica=replica_obj.name,
|
||||||
|
replica_id=replica_obj.id,
|
||||||
|
run_id=run_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _sha256(data: bytes) -> str:
|
def _sha256(data: bytes) -> str:
|
||||||
return hashlib.sha256(data).hexdigest()
|
return hashlib.sha256(data).hexdigest()
|
||||||
|
|
||||||
@@ -262,6 +333,19 @@ async def _sync_replica(
|
|||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
# Step 5a2: sync saved views (best-effort, non-fatal)
|
||||||
|
try:
|
||||||
|
await _sync_saved_views(master, replica, maps, replica_obj, run_id, session)
|
||||||
|
except Exception as e:
|
||||||
|
emit_log(
|
||||||
|
"warning",
|
||||||
|
f"Saved views sync failed (non-fatal): {e}",
|
||||||
|
replica=replica_obj.name,
|
||||||
|
replica_id=replica_obj.id,
|
||||||
|
run_id=run_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
# Step 5b: resolve pending tasks
|
# Step 5b: resolve pending tasks
|
||||||
_progress.phase = f"resolving tasks — {replica_obj.name}"
|
_progress.phase = f"resolving tasks — {replica_obj.name}"
|
||||||
await _resolve_pending_tasks(
|
await _resolve_pending_tasks(
|
||||||
|
|||||||
@@ -195,6 +195,17 @@ class PaperlessClient:
|
|||||||
)
|
)
|
||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
async def get_saved_views(self) -> list[dict]:
|
||||||
|
return await self._get_all("/api/saved_views/")
|
||||||
|
|
||||||
|
async def create_saved_view(self, view_data: dict) -> dict:
|
||||||
|
r = await self._request("POST", "/api/saved_views/", json=view_data)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
async def update_saved_view(self, view_id: int, view_data: dict) -> dict:
|
||||||
|
r = await self._request("PUT", f"/api/saved_views/{view_id}/", json=view_data)
|
||||||
|
return r.json()
|
||||||
|
|
||||||
async def test_connection(self) -> dict:
|
async def test_connection(self) -> dict:
|
||||||
"""Returns {ok, error, latency_ms, doc_count}."""
|
"""Returns {ok, error, latency_ms, doc_count}."""
|
||||||
t0 = time.monotonic()
|
t0 = time.monotonic()
|
||||||
|
|||||||
Reference in New Issue
Block a user