feat: govern attachment exceptions and ownership transfers
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
@@ -157,7 +159,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
||||
"send_without_attachments_behavior": "block",
|
||||
"global": [],
|
||||
"zip": {"enabled": False, "archives": []},
|
||||
"missing_behavior": "ask",
|
||||
"missing_behavior": "warn",
|
||||
"ambiguous_behavior": "ask",
|
||||
},
|
||||
"entries": {
|
||||
@@ -175,7 +177,7 @@ def minimal_campaign_json(*, external_id: str, name: str, description: str | Non
|
||||
},
|
||||
},
|
||||
"validation_policy": {
|
||||
"missing_required_attachment": "ask",
|
||||
"missing_required_attachment": "block",
|
||||
"missing_optional_attachment": "warn",
|
||||
"ambiguous_attachment_match": "ask",
|
||||
"unsent_attachment_files": "warn",
|
||||
@@ -832,6 +834,7 @@ def update_campaign_review_state(
|
||||
version_id: str,
|
||||
inspection_complete: bool,
|
||||
reviewed_message_keys: list[str],
|
||||
issue_decisions: list[dict[str, Any]] | None = None,
|
||||
user_id: str | None,
|
||||
commit: bool = True,
|
||||
) -> CampaignVersion:
|
||||
@@ -854,13 +857,22 @@ def update_campaign_review_state(
|
||||
raise LockedCampaignVersionError("Delivery has started; message review state can no longer be changed.")
|
||||
build_token = _campaign_review_build_token(version)
|
||||
normalized_reviewed = list(dict.fromkeys(str(value) for value in reviewed_message_keys if str(value).strip()))
|
||||
normalized_decisions: list[dict[str, Any]] = []
|
||||
if inspection_complete:
|
||||
normalized_reviewed = _complete_campaign_review_keys(session, version, normalized_reviewed)
|
||||
normalized_reviewed, normalized_decisions = _complete_campaign_review(
|
||||
session,
|
||||
version,
|
||||
normalized_reviewed,
|
||||
issue_decisions or [],
|
||||
user_id=user_id,
|
||||
build_token=build_token,
|
||||
)
|
||||
_write_campaign_review_state(
|
||||
version,
|
||||
build_token=build_token,
|
||||
inspection_complete=inspection_complete,
|
||||
reviewed_message_keys=normalized_reviewed,
|
||||
issue_decisions=normalized_decisions,
|
||||
user_id=user_id,
|
||||
)
|
||||
session.add(version)
|
||||
@@ -885,11 +897,15 @@ def _campaign_review_build_token(version: CampaignVersion) -> str:
|
||||
return build_token
|
||||
|
||||
|
||||
def _complete_campaign_review_keys(
|
||||
def _complete_campaign_review(
|
||||
session: Session,
|
||||
version: CampaignVersion,
|
||||
reviewed_message_keys: list[str],
|
||||
) -> list[str]:
|
||||
issue_decisions: list[dict[str, Any]],
|
||||
*,
|
||||
user_id: str | None,
|
||||
build_token: str,
|
||||
) -> tuple[list[str], list[dict[str, Any]]]:
|
||||
jobs = (
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_version_id == version.id)
|
||||
@@ -904,7 +920,123 @@ def _complete_campaign_review_keys(
|
||||
raise CampaignPersistenceError(
|
||||
"Messages requiring an explicit decision must be opened before review can be completed: " + ", ".join(missing)
|
||||
)
|
||||
return list(dict.fromkeys([*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]))
|
||||
decisions = _normalize_review_issue_decisions(
|
||||
jobs,
|
||||
issue_decisions,
|
||||
user_id=user_id,
|
||||
build_token=build_token,
|
||||
)
|
||||
return (
|
||||
list(
|
||||
dict.fromkeys(
|
||||
[*reviewed_message_keys, *_bulk_acceptable_review_keys(jobs)]
|
||||
)
|
||||
),
|
||||
decisions,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_review_issue_decisions(
|
||||
jobs: list[CampaignJob],
|
||||
requested: list[dict[str, Any]],
|
||||
*,
|
||||
user_id: str | None,
|
||||
build_token: str,
|
||||
decided_at: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
jobs_by_id = {job.id: job for job in jobs}
|
||||
requested_by_job: dict[str, dict[str, Any]] = {}
|
||||
for item in requested:
|
||||
job_id = str(item.get("job_id") or "").strip()
|
||||
if not job_id or job_id not in jobs_by_id:
|
||||
raise CampaignPersistenceError(
|
||||
"A review decision references a message outside the current build."
|
||||
)
|
||||
if jobs_by_id[job_id].validation_status != "needs_review":
|
||||
raise CampaignPersistenceError(
|
||||
"Review decisions are accepted only for messages requiring review."
|
||||
)
|
||||
if job_id in requested_by_job:
|
||||
raise CampaignPersistenceError(
|
||||
"Only one review decision may be recorded per built message."
|
||||
)
|
||||
if str(item.get("decision") or "accept") != "accept":
|
||||
raise CampaignPersistenceError(
|
||||
"Unsupported campaign review decision."
|
||||
)
|
||||
requested_by_job[job_id] = item
|
||||
|
||||
timestamp = (decided_at or datetime.now(UTC)).isoformat()
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for job in jobs:
|
||||
reviewable_issues = [
|
||||
issue
|
||||
for issue in (job.issues_snapshot or [])
|
||||
if isinstance(issue, dict)
|
||||
and str(issue.get("behavior") or "").lower() == "ask"
|
||||
]
|
||||
attachment_overrides = [
|
||||
issue
|
||||
for issue in reviewable_issues
|
||||
if str(issue.get("source") or "").startswith("attachments")
|
||||
]
|
||||
decision = requested_by_job.get(job.id)
|
||||
if attachment_overrides and decision is None:
|
||||
raise CampaignPersistenceError(
|
||||
"Attachment exceptions require an explicit reason before review can be completed "
|
||||
f"for message {job.entry_id or job.entry_index}."
|
||||
)
|
||||
if decision is None:
|
||||
continue
|
||||
reason = str(decision.get("reason") or "").strip()
|
||||
if attachment_overrides and not reason:
|
||||
raise CampaignPersistenceError(
|
||||
"Attachment exception decisions require a reason."
|
||||
)
|
||||
evidence_issues = reviewable_issues or [
|
||||
issue
|
||||
for issue in (job.issues_snapshot or [])
|
||||
if isinstance(issue, dict)
|
||||
]
|
||||
issue_payload = [
|
||||
{
|
||||
"code": str(issue.get("code") or ""),
|
||||
"behavior": str(issue.get("behavior") or ""),
|
||||
"source": str(issue.get("source") or ""),
|
||||
"details": issue.get("details")
|
||||
if isinstance(issue.get("details"), dict)
|
||||
else {},
|
||||
}
|
||||
for issue in evidence_issues
|
||||
]
|
||||
fingerprint = hashlib.sha256(
|
||||
json.dumps(
|
||||
issue_payload,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
normalized.append(
|
||||
{
|
||||
"job_id": job.id,
|
||||
"review_key": str(job.entry_id or job.entry_index),
|
||||
"decision": "accept",
|
||||
"reason": reason or None,
|
||||
"actor_user_id": user_id,
|
||||
"decided_at": timestamp,
|
||||
"build_token": build_token,
|
||||
"message_sha256": job.eml_sha256,
|
||||
"issue_fingerprint": fingerprint,
|
||||
"issue_codes": sorted(
|
||||
{
|
||||
str(issue.get("code") or "")
|
||||
for issue in evidence_issues
|
||||
if issue.get("code")
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def _required_review_keys(jobs: list[CampaignJob]) -> set[str]:
|
||||
@@ -929,6 +1061,7 @@ def _write_campaign_review_state(
|
||||
build_token: str,
|
||||
inspection_complete: bool,
|
||||
reviewed_message_keys: list[str],
|
||||
issue_decisions: list[dict[str, Any]],
|
||||
user_id: str | None,
|
||||
) -> None:
|
||||
editor_state = copy.deepcopy(version.editor_state or {})
|
||||
@@ -936,6 +1069,7 @@ def _write_campaign_review_state(
|
||||
"build_token": build_token,
|
||||
"inspection_complete": bool(inspection_complete),
|
||||
"reviewed_message_keys": reviewed_message_keys,
|
||||
"issue_decisions": issue_decisions,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"updated_by_user_id": user_id,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user