feat(cases): link provider-owned evidence references
Module Package Release / publish-packages (push) Successful in 11s

This commit is contained in:
2026-08-21 16:10:48 +02:00
parent eab2f4b3b2
commit f236034e88
11 changed files with 951 additions and 5 deletions
@@ -0,0 +1,239 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import datetime
import hashlib
import json
from urllib.parse import parse_qs, quote, urlsplit
from sqlalchemy.orm import Session
from govoplan_core.core.institutional import EvidenceReference
from govoplan_cases.backend.domain import CaseRecord
from govoplan_cases.backend.service import CaseStoreError, get_case, update_case
_SOURCE_CONTRACT = "cases.quick-access-reference.v1"
_SUPPORTED_REFERENCES = {
("files", "file-version"): "document",
("mail", "message"): "event",
("campaigns", "campaign"): "event",
}
def link_case_evidence(
session: Session,
principal: object,
*,
case_id: str,
expected_revision: int,
reference: Mapping[str, object],
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
)
evidence = _validated_evidence(reference, tenant_id=base.reference.tenant_id)
if any(item.evidence_id == evidence.evidence_id for item in base.evidence_refs):
raise CaseStoreError("The owner reference is already linked to this Case.")
return update_case(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
changes={"evidence_refs": (*base.evidence_refs, evidence)},
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
)
def unlink_case_evidence(
session: Session,
principal: object,
*,
case_id: str,
evidence_id: str,
expected_revision: int,
recorded_at: datetime,
change_reason: str,
idempotency_key: str,
) -> CaseRecord:
base = _base_revision(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
)
evidence = next(
(item for item in base.evidence_refs if item.evidence_id == evidence_id),
None,
)
if evidence is None or linked_source(evidence) is None:
raise CaseStoreError("The linked owner reference was not found.")
return update_case(
session,
principal,
case_id=case_id,
expected_revision=expected_revision,
changes={
"evidence_refs": tuple(
item for item in base.evidence_refs if item.evidence_id != evidence_id
)
},
recorded_at=recorded_at,
change_reason=change_reason,
idempotency_key=idempotency_key,
)
def linked_source(evidence: EvidenceReference) -> Mapping[str, str] | None:
if evidence.source_ref is None:
return None
try:
value = json.loads(evidence.source_ref)
except (TypeError, ValueError):
return None
if not isinstance(value, Mapping) or value.get("contract") != _SOURCE_CONTRACT:
return None
result = {
key: str(value.get(key) or "")
for key in ("owner_module", "kind", "object_id", "path")
}
if (
not all(result.values())
or (result["owner_module"], result["kind"]) not in _SUPPORTED_REFERENCES
):
return None
return result
def _base_revision(
session: Session,
principal: object,
*,
case_id: str,
expected_revision: int,
) -> CaseRecord:
base = get_case(
session,
principal,
case_id=case_id,
revision=expected_revision,
)
if base is None:
raise LookupError("Case revision not found.")
return base
def _validated_evidence(
value: Mapping[str, object],
*,
tenant_id: str,
) -> EvidenceReference:
owner_module = _bounded(value.get("owner_module"), "Owner module", 120)
reference_kind = _bounded(value.get("kind"), "Owner reference kind", 120)
object_id = _bounded(value.get("object_id"), "Owner object id", 1_000)
reference_tenant = _bounded(value.get("tenant_id"), "Owner tenant id", 255)
version = _bounded(value.get("version"), "Owner version", 255)
path = _bounded(value.get("path"), "Owner path", 2_000)
evidence_kind = _SUPPORTED_REFERENCES.get((owner_module, reference_kind))
if evidence_kind is None:
raise CaseStoreError("The owner reference kind is not supported by Cases.")
if reference_tenant != tenant_id:
raise CaseStoreError("Owner references cannot cross Case tenants.")
_validate_owner_path(
owner_module=owner_module,
reference_kind=reference_kind,
object_id=object_id,
version=version,
path=path,
)
canonical = {
"contract": _SOURCE_CONTRACT,
"owner_module": owner_module,
"kind": reference_kind,
"object_id": object_id,
"path": path,
}
encoded = json.dumps(
canonical,
ensure_ascii=True,
sort_keys=True,
separators=(",", ":"),
)
digest = hashlib.sha256(
f"{tenant_id}\x1f{encoded}\x1f{version}".encode()
).hexdigest()
return EvidenceReference(
kind=evidence_kind, # type: ignore[arg-type]
owner_module=owner_module,
evidence_id=f"linked-{digest[:40]}",
tenant_id=tenant_id,
version=version,
source_ref=encoded,
derived_from=(_SOURCE_CONTRACT,),
)
def _validate_owner_path(
*,
owner_module: str,
reference_kind: str,
object_id: str,
version: str,
path: str,
) -> None:
parsed = urlsplit(path)
if parsed.scheme or parsed.netloc or not path.startswith("/") or path.startswith("//"):
raise CaseStoreError("Owner paths must be local platform routes.")
if (owner_module, reference_kind) == ("files", "file-version"):
query = parse_qs(parsed.query, keep_blank_values=True)
if (
parsed.path != "/files"
or query.get("versionId") != [object_id]
or version != object_id
or not query.get("fileId", [""])[0]
):
raise CaseStoreError("Files links require an exact authorized version path.")
return
if (owner_module, reference_kind) == ("mail", "message"):
query = parse_qs(parsed.query, keep_blank_values=True)
profile = query.get("profile", [""])[0]
folder = query.get("folder", [""])[0]
message = query.get("message", [""])[0]
expected_id = f"{profile}:{folder}:{message}"
if (
parsed.path != "/mail"
or not profile
or not folder
or not message
or object_id != expected_id
or version != message
):
raise CaseStoreError("Mail links require an exact mailbox message path.")
return
expected_path = f"/campaigns/{quote(object_id, safe='')}"
if path != expected_path:
raise CaseStoreError("Campaign links require an exact Campaign path.")
def _bounded(value: object, label: str, maximum: int) -> str:
result = str(value or "").strip()
if not result:
raise CaseStoreError(f"{label} is required.")
if len(result) > maximum or any(ord(character) < 32 for character in result):
raise CaseStoreError(f"{label} is invalid.")
return result
__all__ = [
"link_case_evidence",
"linked_source",
"unlink_case_evidence",
]