Compare commits

...
3 Commits
Author SHA1 Message Date
zemion 3934e7fedb feat(campaigns): add portable campaign transfers
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 04:01:39 +02:00
zemion 1bd24f9b5b feat: orchestrate accountable Campaign work
Module Package Release / publish-packages (push) Successful in 13s
2026-08-22 02:14:35 +02:00
zemion 4f52f010ee fix(campaigns): declare work view surface
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 00:56:37 +02:00
28 changed files with 3919 additions and 40 deletions
+12
View File
@@ -73,6 +73,18 @@ has a remaining occurrence, including while it is paused; once the schedule
finishes, already accepted Mail commands retain their own encrypted payload and
evidence under Mail policy.
Campaign versions can also be exported as versioned portable JSON packages and
imported as independently owned drafts. The privacy-safe export default is
metadata plus template/configuration. Recipients, attachment rules, aggregate
review state, and recipient-level delivery history are separate scopes with
their existing fine-grained permissions. Packages include source provenance,
scope/item/redaction manifests, and a SHA-256 integrity digest. They never
contain attachment bytes, transport secrets, credential references,
password-field values, local storage locators, shares, or ownership grants.
Import previews schema and checksum compatibility plus every created/skipped
domain. It clears deployment-bound Mail references and never replays locks,
approvals, review decisions, jobs, attempts, or sent state.
Public campaign, version, job, and report responses expose business data and
delivery evidence, but never process-local paths, storage-backend keys, or
worker claim tokens. Operational troubleshooting uses the dedicated job
+55
View File
@@ -138,6 +138,35 @@ Notifications. The thread displays only human discussion; approvals, workflow
state, delivery events, and durable system evidence remain on their owning
surfaces and in Tenant audit.
### Assign accountable campaign work
Open **Work** to assign one bounded purpose to an account, group, or
organization function that already has Campaign access. Assignment records
responsibility only: it never creates a share, transfers ownership, or grants a
permission. Assignees may accept, complete, or reject their work; rejection is
distinct from administrative cancellation. Managers may reassign or cancel
open work, and every transition retains the expected revision, actor snapshot,
typed target, and append-only event history.
Workflow may create or reference a Campaign and open the same assignment through
the optional `campaigns.workOrchestration` capability. Those assignments pin the
Campaign version and store the Workflow instance, step, correlation, and
idempotency provenance. Campaign emits `campaign.work.changed` for assignment,
acceptance, start, reassignment, completion, rejection, and cancellation.
Workflow uses the assignment ID and event revision, rechecks current Campaign
access, and then resumes the matching durable external hand-off without browser
polling. A missing Tasks or Notifications capability only removes the optional
projection or notification. A missing Campaign provider, revoked Campaign
access, or stale event revision keeps the Workflow blocked and inspectable.
Campaign also contributes the opt-in **Accountable Campaign work hand-off**
Workflow template. It is deliberately not activated on installation. A
configurator must copy or activate it and supply either `campaign_id` or
`create_campaign`; unused optional input keys must be present with `null`
values. The template prepares the assignment idempotently, opens the exact
Campaign work URL, and waits for completion, rejection, cancellation, or the
configured timeout. Opening the link never completes the Workflow.
### Prepare a campaign
1. Create a campaign and confirm its owner or owning group.
@@ -303,6 +332,32 @@ default.
## Data and evidence model
### Portable Campaign transfer
Campaign offers two reuse paths with different boundaries. **Copy campaign**
creates another campaign inside the same installation and can reuse selected
local shares, policies, and Mail profile references. **Export package** creates
a versioned JSON hand-off whose selected scopes can cross an installation
boundary; **Import package** always creates a separately owned draft.
The export dialog starts with only metadata and template/configuration. Add
recipients, attachment rules, review state, or delivery history only when the
handoff requires them and the destination and retention are approved. Recipient
and delivery scopes remain protected by recipient/report export permissions.
Transport secrets, credential references, password-field values, local storage
locators, and attachment bytes are always removed. The manifest records scope
counts and redactions, while the envelope carries source Campaign/version
provenance and a SHA-256 digest.
Import verifies format, scope, checksum, schema, and destination identity before
showing the plan. Editing the destination identity or selected scopes makes the
preview stale and requires a new check. The apply step clears source Mail
references, creates one editable draft, and stores a bounded source/package and
created/skipped receipt. Historical validation/build summaries, review state,
approvals, delivery jobs, attempts, and sent outcomes are never replayed. File
content is never embedded, so reconnect managed files and local Mail profiles,
then validate, build, review, and approve normally.
### Versions and snapshots
Editable campaign JSON is versioned. Build creates recipient jobs and an
+2 -2
View File
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-campaign"
version = "0.1.21"
version = "0.1.24"
description = "GovOPlaN campaigns module with backend and WebUI integration."
readme = "README.md"
requires-python = ">=3.12"
license = { file = "LICENSE" }
authors = [{ name = "GovOPlaN" }]
dependencies = [
"govoplan-core>=0.1.18",
"govoplan-core>=0.1.28",
"jsonschema>=4,<5",
"pydantic>=2,<3",
"SQLAlchemy>=2,<3",
@@ -0,0 +1,730 @@
from __future__ import annotations
import copy
import hashlib
import json
from collections import Counter
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
from uuid import uuid4
from govoplan_campaign.backend.campaign.loader import validate_against_schema
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
from govoplan_campaign.backend.response_security import (
public_campaign_configuration,
public_campaign_payload,
)
PORTABLE_CAMPAIGN_FORMAT = "govoplan.campaign-portable"
PORTABLE_CAMPAIGN_FORMAT_VERSION = "1.0"
PORTABLE_CAMPAIGN_SCOPE_ORDER = (
"metadata",
"template_config",
"recipients",
"attachments",
"review_state",
"delivery_history",
)
DEFAULT_PORTABLE_CAMPAIGN_SCOPES = ("metadata", "template_config")
OPERATIONAL_EVIDENCE_SCOPES = frozenset(("review_state", "delivery_history"))
_CONFIG_STRUCTURAL_KEYS = frozenset(
("version", "campaign", "recipients", "entries", "attachments")
)
_SENSITIVE_SETTING_FRAGMENTS = (
"api_key",
"credential",
"password",
"private_key",
"secret",
"token",
)
class CampaignTransferError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class CampaignImportInspection:
preview: dict[str, Any]
configuration: dict[str, Any] | None
portable_settings: dict[str, Any]
def canonical_sha256(value: object) -> str:
encoded = json.dumps(
value,
sort_keys=True,
separators=(",", ":"),
ensure_ascii=False,
allow_nan=False,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def normalize_transfer_scopes(scopes: Iterable[str]) -> tuple[str, ...]:
selected = set(scopes)
invalid = sorted(selected.difference(PORTABLE_CAMPAIGN_SCOPE_ORDER))
if invalid:
raise CampaignTransferError(
f"Unsupported campaign transfer scope(s): {', '.join(invalid)}"
)
if not selected:
raise CampaignTransferError("Select at least one campaign transfer scope.")
return tuple(scope for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER if scope in selected)
def build_campaign_portable_package(
*,
campaign: Campaign,
version: CampaignVersion,
scopes: Iterable[str],
jobs: Iterable[CampaignJob] = (),
issues: Iterable[CampaignIssue] = (),
module_version: str,
) -> dict[str, Any]:
selected = normalize_transfer_scopes(scopes)
configuration = public_campaign_configuration(version.raw_json)
if not isinstance(configuration, dict):
raise CampaignTransferError("The campaign configuration is not portable JSON.")
configuration, password_redactions = _redact_password_field_values(configuration)
payload: dict[str, Any] = {}
item_counts: dict[str, int] = {}
redactions: Counter[str] = Counter(password_redactions)
if "metadata" in selected:
payload["metadata"] = {
"external_id": campaign.external_id,
"name": campaign.name,
"description": campaign.description,
"source_status": campaign.status,
}
item_counts["metadata"] = 1
if "template_config" in selected:
settings, setting_redactions = _redact_sensitive_settings(
campaign.settings or {}
)
mail_policy, mail_policy_redactions = _redact_sensitive_settings(
campaign.mail_profile_policy or {}
)
template_configuration = {
key: copy.deepcopy(value)
for key, value in configuration.items()
if key not in _CONFIG_STRUCTURAL_KEYS
}
server = template_configuration.get("server")
if isinstance(server, dict):
for key in ("smtp_credential_id", "imap_credential_id"):
if server.pop(key, None) is not None:
redactions["deployment_credential_reference"] += 1
payload["template_config"] = {
"schema_version": version.schema_version,
"configuration": template_configuration,
"campaign_settings": settings,
"mail_profile_policy": mail_policy,
}
redactions.update(setting_redactions)
redactions.update(mail_policy_redactions)
item_counts["template_config"] = len(template_configuration)
if "recipients" in selected:
entries = copy.deepcopy(configuration.get("entries") or {})
_remove_entry_attachments(entries)
payload["recipients"] = {
"recipients": copy.deepcopy(configuration.get("recipients") or {}),
"entries": entries,
}
item_counts["recipients"] = _recipient_entry_count(entries)
if "attachments" in selected:
entry_attachments = _entry_attachment_projection(
configuration.get("entries")
)
payload["attachments"] = {
"configuration": copy.deepcopy(configuration.get("attachments") or {}),
"entry_attachments": entry_attachments,
"content_included": False,
}
item_counts["attachments"] = _attachment_rule_count(
payload["attachments"]
)
issue_rows = tuple(issues)
if "review_state" in selected:
review_state = _review_state_projection(version, issue_rows)
payload["review_state"] = review_state
item_counts["review_state"] = int(review_state["decision_count"])
job_rows = tuple(jobs)
if "delivery_history" in selected:
payload["delivery_history"] = {
"jobs": [_delivery_job_projection(job) for job in job_rows],
"counts": _delivery_counts(job_rows),
}
item_counts["delivery_history"] = len(job_rows)
exported_at = datetime.now(UTC)
package: dict[str, Any] = {
"format": PORTABLE_CAMPAIGN_FORMAT,
"format_version": PORTABLE_CAMPAIGN_FORMAT_VERSION,
"package_id": str(uuid4()),
"exported_at": exported_at.isoformat(),
"source": {
"module": "campaigns",
"module_version": module_version,
"tenant_ref_sha256": hashlib.sha256(
campaign.tenant_id.encode("utf-8")
).hexdigest(),
"campaign_id": campaign.id,
"campaign_external_id": campaign.external_id,
"campaign_name": campaign.name,
"version_id": version.id,
"version_number": version.version_number,
"campaign_schema_version": version.schema_version,
},
"scopes": list(selected),
"manifest": {
"item_counts": item_counts,
"redactions": dict(sorted(redactions.items())),
"privacy_default_scopes": list(DEFAULT_PORTABLE_CAMPAIGN_SCOPES),
"attachments_are_references_only": True,
"operational_evidence_is_not_replayed": True,
"secrets_included": False,
},
"payload": payload,
}
package["integrity"] = {
"algorithm": "sha256",
"package_sha256": canonical_sha256(package),
}
return package
def inspect_campaign_portable_package(
package: Mapping[str, Any],
*,
selected_scopes: Iterable[str] | None,
external_id: str,
name: str,
) -> CampaignImportInspection:
errors: list[str] = []
warnings: list[str] = []
package_dict = copy.deepcopy(dict(package))
package_id = _optional_text(package_dict.get("package_id"))
format_version = _optional_text(package_dict.get("format_version"))
source = package_dict.get("source")
source_dict = copy.deepcopy(source) if isinstance(source, dict) else {}
integrity = package_dict.get("integrity")
expected_hash = (
_optional_text(integrity.get("package_sha256"))
if isinstance(integrity, dict)
else None
)
hash_input = copy.deepcopy(package_dict)
hash_input.pop("integrity", None)
actual_hash = canonical_sha256(hash_input)
if package_dict.get("format") != PORTABLE_CAMPAIGN_FORMAT:
errors.append("The file is not a GovOPlaN portable Campaign package.")
if format_version != PORTABLE_CAMPAIGN_FORMAT_VERSION:
errors.append(
"The Campaign package format version is not supported by this installation."
)
if not package_id:
errors.append("The Campaign package has no package identifier.")
if not expected_hash or expected_hash != actual_hash:
errors.append("The Campaign package integrity checksum does not match its content.")
if not isinstance(integrity, dict) or integrity.get("algorithm") != "sha256":
errors.append("The Campaign package does not use the supported SHA-256 integrity algorithm.")
if not source_dict:
errors.append("The Campaign package has no source provenance.")
elif source_dict.get("campaign_schema_version") != "1.0":
errors.append("The Campaign configuration schema version is not supported by this installation.")
available: tuple[str, ...] = ()
try:
raw_scopes = package_dict.get("scopes")
if not isinstance(raw_scopes, list):
raise CampaignTransferError("The Campaign package has no valid scope list.")
available = normalize_transfer_scopes(str(item) for item in raw_scopes)
except CampaignTransferError as exc:
errors.append(str(exc))
try:
selected = normalize_transfer_scopes(
available if selected_scopes is None else selected_scopes
)
except CampaignTransferError as exc:
errors.append(str(exc))
selected = ()
unavailable = sorted(set(selected).difference(available))
if unavailable:
errors.append(
f"Selected scope(s) are absent from the package: {', '.join(unavailable)}"
)
payload = package_dict.get("payload")
payload_dict = payload if isinstance(payload, dict) else {}
if not isinstance(payload, dict):
errors.append("The Campaign package has no valid payload object.")
if not isinstance(package_dict.get("manifest"), dict):
errors.append("The Campaign package has no valid manifest.")
for scope in available:
if scope not in payload_dict:
errors.append(f"The Campaign package payload is missing scope '{scope}'.")
elif not isinstance(payload_dict[scope], dict):
errors.append(f"The Campaign package scope '{scope}' is not a valid object.")
template_scope = payload_dict.get("template_config")
if (
"template_config" in available
and isinstance(template_scope, dict)
and template_scope.get("schema_version") != "1.0"
):
errors.append("The portable template/configuration schema version is not supported.")
configuration: dict[str, Any] | None = None
portable_settings: dict[str, Any] = {}
will_create: list[dict[str, Any]] = []
will_skip: list[dict[str, Any]] = []
if not errors:
configuration, portable_settings, created, skipped, materialize_warnings = (
_materialize_import(
payload_dict,
available=available,
selected=selected,
external_id=external_id,
name=name,
)
)
will_create.extend(created)
will_skip.extend(skipped)
warnings.extend(materialize_warnings)
try:
validate_against_schema(configuration)
except Exception as exc:
errors.append(f"The imported Campaign configuration is incompatible: {exc}")
configuration = None
manifest = package_dict.get("manifest")
if isinstance(manifest, dict) and manifest.get("redactions"):
warnings.append(
"The source export redacted sensitive or deployment-bound values; review the package manifest and reconfigure them locally."
)
preview = {
"compatible": not errors,
"package_id": package_id,
"package_sha256": actual_hash,
"format_version": format_version,
"source": source_dict,
"available_scopes": list(available),
"selected_scopes": list(selected),
"destination": {
"external_id": external_id,
"name": name,
"status": "draft",
},
"will_create": will_create,
"will_skip": will_skip,
"warnings": list(dict.fromkeys(warnings)),
"errors": list(dict.fromkeys(errors)),
}
return CampaignImportInspection(
preview=preview,
configuration=configuration,
portable_settings=portable_settings,
)
def _materialize_import(
payload: Mapping[str, Any],
*,
available: tuple[str, ...],
selected: tuple[str, ...],
external_id: str,
name: str,
) -> tuple[
dict[str, Any],
dict[str, Any],
list[dict[str, Any]],
list[dict[str, Any]],
list[str],
]:
selected_set = set(selected)
configuration = minimal_campaign_json(external_id=external_id, name=name)
portable_settings: dict[str, Any] = {}
created: list[dict[str, Any]] = [
_plan_item("metadata", "campaign_draft", "A new Campaign draft and editable version will be created.", 1)
]
skipped: list[dict[str, Any]] = []
warnings: list[str] = []
metadata = payload.get("metadata")
if "metadata" in selected_set and isinstance(metadata, dict):
description = metadata.get("description")
if isinstance(description, str):
configuration["campaign"]["description"] = description
template_payload = payload.get("template_config")
if "template_config" in selected_set and isinstance(template_payload, dict):
source_configuration = template_payload.get("configuration")
if isinstance(source_configuration, dict):
for key, value in source_configuration.items():
if key in _CONFIG_STRUCTURAL_KEYS:
continue
configuration[key] = copy.deepcopy(value)
source_server = configuration.get("server")
if isinstance(source_server, dict) and source_server:
configuration["server"] = {}
skipped.append(
_plan_item(
"template_config",
"deployment_bound_mail_profile",
"Mail profile and server references are not applied across installations; select local Mail resources after import.",
len(source_server),
)
)
settings = template_payload.get("campaign_settings")
if isinstance(settings, dict):
portable_settings = copy.deepcopy(settings)
created.append(
_plan_item(
"template_config",
"editable_configuration",
"Portable fields, template, delivery settings, and validation policy will be applied to the draft.",
len(source_configuration),
)
)
recipients_payload = payload.get("recipients")
if "recipients" in selected_set and isinstance(recipients_payload, dict):
recipients = recipients_payload.get("recipients")
entries = recipients_payload.get("entries")
if isinstance(recipients, dict):
configuration["recipients"] = copy.deepcopy(recipients)
if isinstance(entries, dict):
configuration["entries"] = copy.deepcopy(entries)
created.append(
_plan_item(
"recipients",
"recipient_rows",
"Campaign-local recipient rows and source provenance will be copied into the draft.",
_recipient_entry_count(entries),
)
)
attachments_payload = payload.get("attachments")
if "attachments" in selected_set and isinstance(attachments_payload, dict):
attachment_configuration = attachments_payload.get("configuration")
if isinstance(attachment_configuration, dict):
configuration["attachments"] = copy.deepcopy(attachment_configuration)
per_entry = attachments_payload.get("entry_attachments")
applied_entry_rules = 0
if "recipients" in selected_set and isinstance(per_entry, list):
inline = configuration.get("entries", {}).get("inline", [])
if isinstance(inline, list):
for item in per_entry:
if not isinstance(item, dict):
continue
index = item.get("entry_index")
rules = item.get("attachments")
if (
isinstance(index, int)
and 0 <= index < len(inline)
and isinstance(inline[index], dict)
and isinstance(rules, list)
):
inline[index]["attachments"] = copy.deepcopy(rules)
applied_entry_rules += len(rules)
elif isinstance(per_entry, list) and per_entry:
skipped.append(
_plan_item(
"attachments",
"recipient_scope_required",
"Per-recipient attachment rules are skipped unless recipient rows are also imported.",
sum(
len(item.get("attachments") or [])
for item in per_entry
if isinstance(item, dict)
),
)
)
created.append(
_plan_item(
"attachments",
"attachment_references",
"Portable attachment rules will be applied; file content is never embedded in the package.",
_attachment_rule_count(attachments_payload) - max(0, _entry_rule_count(per_entry) - applied_entry_rules),
)
)
warnings.append(
"Attachment rules contain references only. Reconnect or upload the required files and validate the draft before use."
)
for scope in PORTABLE_CAMPAIGN_SCOPE_ORDER:
if scope not in OPERATIONAL_EVIDENCE_SCOPES:
continue
if scope in selected_set:
item_count = _manifest_scope_count(payload.get(scope))
skipped.append(
_plan_item(
scope,
"operational_evidence_not_replayed",
"Historical review or delivery evidence remains in the source package and import receipt but is never replayed as live Campaign state.",
item_count,
)
)
for scope in available:
if scope not in selected_set:
skipped.append(
_plan_item(
scope,
"scope_not_selected",
"This available package scope was not selected for import.",
_manifest_scope_count(payload.get(scope)),
)
)
campaign_metadata = configuration.get("campaign")
if not isinstance(campaign_metadata, dict):
raise CampaignTransferError("The imported Campaign metadata is invalid.")
campaign_metadata.update({"id": external_id, "name": name, "mode": "draft"})
return configuration, portable_settings, created, skipped, warnings
def _review_state_projection(
version: CampaignVersion, issues: tuple[CampaignIssue, ...]
) -> dict[str, Any]:
editor_state = version.editor_state if isinstance(version.editor_state, dict) else {}
review = editor_state.get("review_send")
review = review if isinstance(review, dict) else {}
decisions = [
item
for item in (review.get("issue_decisions") or [])
if isinstance(item, dict)
]
decision_evidence = [
{
"decision": item.get("decision"),
"issue_codes": sorted(str(code) for code in item.get("issue_codes") or []),
"issue_fingerprint": item.get("issue_fingerprint"),
"message_sha256": item.get("message_sha256"),
"reason_recorded": bool(str(item.get("reason") or "").strip()),
}
for item in decisions
]
issue_counts = Counter(str(issue.severity) for issue in issues)
return {
"workflow_state": version.workflow_state,
"inspection_complete": bool(review.get("inspection_complete")),
"reviewed_message_count": len(review.get("reviewed_message_keys") or []),
"decision_count": len(decisions),
"decision_evidence_sha256": canonical_sha256(decision_evidence),
"issue_counts": dict(sorted(issue_counts.items())),
"validation_summary": public_campaign_payload(version.validation_summary or {}),
"build_summary": public_campaign_payload(version.build_summary or {}),
}
def _delivery_job_projection(job: CampaignJob) -> dict[str, Any]:
return {
"job_id": job.id,
"entry_index": job.entry_index,
"entry_id": job.entry_id,
"recipient_email": job.recipient_email,
"message_id_header": job.message_id_header,
"message_sha256": job.eml_sha256,
"build_status": job.build_status,
"validation_status": job.validation_status,
"queue_status": job.queue_status,
"send_status": job.send_status,
"postbox_status": job.postbox_status,
"print_status": job.print_status,
"imap_status": job.imap_status,
"attempt_count": job.attempt_count,
"sent_at": _isoformat(job.sent_at),
"outcome_unknown_at": _isoformat(job.outcome_unknown_at),
"delivery_provenance": public_campaign_payload(job.delivery_provenance or {}),
}
def _delivery_counts(jobs: tuple[CampaignJob, ...]) -> dict[str, dict[str, int]]:
return {
field: dict(
sorted(Counter(str(getattr(job, field) or "unknown") for job in jobs).items())
)
for field in ("validation_status", "queue_status", "send_status")
}
def _redact_sensitive_settings(
value: Mapping[str, Any],
) -> tuple[dict[str, Any], Counter[str]]:
redactions: Counter[str] = Counter()
def visit(item: Any) -> Any:
if isinstance(item, dict):
result: dict[str, Any] = {}
for raw_key, child in item.items():
key = str(raw_key)
normalized = key.lower().replace("-", "_")
if any(fragment in normalized for fragment in _SENSITIVE_SETTING_FRAGMENTS):
redactions["sensitive_setting"] += 1
continue
result[key] = visit(child)
return result
if isinstance(item, list):
return [visit(child) for child in item]
return copy.deepcopy(item)
return visit(dict(value)), redactions
def _redact_password_field_values(
configuration: dict[str, Any],
) -> tuple[dict[str, Any], Counter[str]]:
result = copy.deepcopy(configuration)
password_fields = {
str(field.get("name"))
for field in result.get("fields") or []
if isinstance(field, dict)
and field.get("type") == "password"
and field.get("name")
}
redactions: Counter[str] = Counter()
if not password_fields:
return result, redactions
global_values = result.get("global_values")
if isinstance(global_values, dict):
for key in password_fields:
if global_values.pop(key, None) is not None:
redactions["password_field_value"] += 1
entries = result.get("entries")
if isinstance(entries, dict):
for entry in entries.get("inline") or []:
if not isinstance(entry, dict):
continue
fields = entry.get("fields")
if not isinstance(fields, dict):
continue
for key in password_fields:
if fields.pop(key, None) is not None:
redactions["password_field_value"] += 1
return result, redactions
def _remove_entry_attachments(entries: Any) -> None:
if not isinstance(entries, dict):
return
for entry in entries.get("inline") or []:
if isinstance(entry, dict):
entry["attachments"] = []
defaults = entries.get("defaults")
if isinstance(defaults, dict):
defaults["attachments"] = []
def _entry_attachment_projection(entries: Any) -> list[dict[str, Any]]:
if not isinstance(entries, dict):
return []
result: list[dict[str, Any]] = []
for index, entry in enumerate(entries.get("inline") or []):
if not isinstance(entry, dict):
continue
rules = entry.get("attachments")
if isinstance(rules, list) and rules:
result.append(
{
"entry_index": index,
"attachments": copy.deepcopy(rules),
}
)
return result
def _recipient_entry_count(entries: Any) -> int:
if not isinstance(entries, dict):
return 0
inline = entries.get("inline")
return len(inline) if isinstance(inline, list) else 0
def _attachment_rule_count(value: Any) -> int:
if not isinstance(value, dict):
return 0
configuration = value.get("configuration")
global_rules = (
configuration.get("global") if isinstance(configuration, dict) else []
)
return (len(global_rules) if isinstance(global_rules, list) else 0) + _entry_rule_count(
value.get("entry_attachments")
)
def _entry_rule_count(value: Any) -> int:
if not isinstance(value, list):
return 0
return sum(
len(item.get("attachments") or [])
for item in value
if isinstance(item, dict)
)
def _manifest_scope_count(value: Any) -> int:
if not isinstance(value, dict):
return 0
if isinstance(value.get("jobs"), list):
return len(value["jobs"])
if "decision_count" in value:
return int(value.get("decision_count") or 0)
if "entries" in value:
return _recipient_entry_count(value.get("entries"))
return 1
def _plan_item(
scope: str, code: str, summary: str, item_count: int | None
) -> dict[str, Any]:
return {
"scope": scope,
"code": code,
"summary": summary,
"item_count": item_count,
}
def _optional_text(value: object) -> str | None:
text = str(value or "").strip()
return text or None
def _isoformat(value: datetime | None) -> str | None:
return value.isoformat() if value is not None else None
__all__ = [
"CampaignImportInspection",
"CampaignTransferError",
"DEFAULT_PORTABLE_CAMPAIGN_SCOPES",
"OPERATIONAL_EVIDENCE_SCOPES",
"PORTABLE_CAMPAIGN_FORMAT",
"PORTABLE_CAMPAIGN_FORMAT_VERSION",
"PORTABLE_CAMPAIGN_SCOPE_ORDER",
"build_campaign_portable_package",
"canonical_sha256",
"inspect_campaign_portable_package",
"normalize_transfer_scopes",
]
@@ -226,6 +226,11 @@ class CampaignCollaborationEntry(Base, TimestampMixin):
class CampaignWorkAssignment(Base, TimestampMixin):
__tablename__ = "campaign_work_assignments"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"orchestration_idempotency_key",
name="uq_campaign_work_assignment_orchestration_key",
),
Index(
"ix_campaign_work_assignments_campaign_status",
"tenant_id",
@@ -275,6 +280,21 @@ class CampaignWorkAssignment(Base, TimestampMixin):
)
task_mirror_error: Mapped[str | None] = mapped_column(String(500), nullable=True)
task_mirrored_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
orchestration_idempotency_key: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
orchestration_request_sha256: Mapped[str | None] = mapped_column(
String(64), nullable=True
)
orchestration_correlation_id: Mapped[str | None] = mapped_column(
String(128), nullable=True, index=True
)
workflow_instance_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
workflow_step_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
+42 -4
View File
@@ -13,6 +13,8 @@ _CAMPAIGN_USER_SCOPES = (
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:copy",
"campaigns:campaign:export",
"campaigns:campaign:import",
"campaigns:campaign:archive",
"campaigns:campaign:delete",
"campaigns:campaign:share",
@@ -204,6 +206,39 @@ CAMPAIGN_USER_DOCUMENTATION = (
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.transfer-campaign-package",
title="Export and import a portable Campaign package",
summary="Move selected Campaign configuration into a separately owned draft with an integrity check, compatibility preview, and explicit privacy scopes.",
body="Portable Campaign packages are versioned JSON envelopes. Export defaults to metadata plus template/configuration and excludes recipients, attachments, review state, and delivery history until they are explicitly selected. Recipient and delivery scopes require their existing fine-grained export permissions. Transport secrets, credential references, password-field values, local storage paths, and attachment bytes are not exported. Import verifies the SHA-256 package integrity, previews every scope that will be created or skipped, and always creates a new editable draft. Deployment-bound Mail references must be selected locally. Review, approval, and delivery evidence remains historical package provenance and is never replayed as live state.",
order=33,
audience=("campaign_manager", "campaign_configurator", "campaign_migration_operator"),
required_scopes=("campaigns:campaign:read", "campaigns:campaign:export"),
route="/campaigns/{campaign_id}",
screen="Campaign overview and Campaign list",
help_contexts=("campaign.overview", "campaigns.action.export-package", "campaigns.action.import-package"),
prerequisites=(
"You may export the source Campaign; importing additionally requires Campaign create and portable-import authority.",
"Recipient and delivery scopes have an approved purpose and destination and the corresponding recipient/report export permissions.",
),
steps=(
"Open the source Campaign overview, select Export package, and keep the privacy-safe metadata plus template/configuration default unless more data is necessary.",
"Select any additional recipient, attachment, review, or delivery scopes explicitly and download the integrity-protected JSON package to an approved location.",
"On the destination Campaign list select Import package, choose the file, and review compatibility, redactions, destination identity, created scopes, and skipped evidence.",
"Change the destination identity or selected scopes as needed, refresh the preview, and create the draft only when the preview is current and compatible.",
"Open the draft, reconnect local Mail and file resources, validate recipients and attachments, and complete ordinary review before any delivery.",
),
outcome="A separately owned Campaign draft containing only the selected portable configuration, with source/package provenance and no replayed operational state.",
verification="The destination is a new draft with a distinct ID; its settings retain the package ID, SHA-256, source and created/skipped receipt, while Audit records the matching export/import hashes without storing package content.",
related_topic_ids=("campaigns.workflow.copy-campaign", "campaigns.workflow.prepare-validate-and-build", "campaigns.workflow.export-delivery-report"),
translations={
"de": {
"title": "Portables Campaign-Paket exportieren und importieren",
"summary": "Ausgewaehlte Campaign-Konfiguration mit Integritaetspruefung, Kompatibilitaetsvorschau und expliziten Datenschutzumfaengen in einen eigenstaendigen Entwurf uebernehmen.",
"body": "Portable Campaign-Pakete sind versionierte JSON-Umschlaege. Der Export umfasst standardmaessig nur Metadaten sowie Vorlage und Konfiguration. Empfaenger, Anlagen, Pruefstatus und Zustellhistorie werden erst nach expliziter Auswahl aufgenommen und bleiben getrennt berechtigt. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, lokale Speicherpfade und Dateiinhalte werden nicht exportiert. Der Import prueft die SHA-256-Integritaet, zeigt alle erzeugten und uebersprungenen Umfaenge und erstellt immer einen neuen bearbeitbaren Entwurf. Mail-Verweise muessen lokal neu gewaehlt werden; historische Pruef-, Freigabe- und Zustellnachweise werden nie als aktiver Zustand wiedergegeben.",
}
},
),
_workflow_topic(
topic_id="campaigns.workflow.collaborate-on-campaign",
title="Discuss campaign work without changing its evidence",
@@ -252,7 +287,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
topic_id="campaigns.workflow.assign-accountable-work",
title="Assign accountable Campaign work without granting access",
summary="Record bounded work for an account, group, or organization function while keeping authorization and Campaign ownership separate.",
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can start and complete their own work; managers can reassign or cancel it. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
body="Campaign work assignments record responsibility, not authority. Every reader and actor must still pass the parent Campaign access check, and a new account, group, or organization-function target is accepted only when it already resolves to active principals with Campaign access. Each assignment retains its purpose, optional due date, assigner, typed assignee reference, human-readable snapshot, current resolution state, stable Campaign or child reference, optimistic revision, and append-only transition history. Assignees with the separate completion permission can accept, complete, or reject their own work; rejection is distinct from manager cancellation. Managers can also reassign or cancel it. Workflow-opened work additionally retains the correlation, idempotency, Workflow instance and step, exact Campaign version, and emits a common revision-bearing lifecycle event for assignment, acceptance, start, reassignment, completion, rejection, or cancellation. Workflow rechecks Campaign access before it resumes; the assignment itself never grants access. Reconciliation records vacancy, deactivation, or restored resolution without deleting history or transferring ownership. Notifications and Tasks mirroring are optional and cannot make the Campaign transaction fail.",
order=34,
audience=("campaign_manager", "campaign_reviewer", "campaign_sender"),
required_scopes=("campaigns:campaign:read", "campaigns:assignment:read"),
@@ -263,6 +298,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"campaign.work.create",
"campaign.work.action.start",
"campaign.work.action.complete",
"campaign.work.action.reject",
"campaign.work.action.reassign",
"campaign.work.action.cancel",
"campaign.work.history",
@@ -276,11 +312,11 @@ CAMPAIGN_USER_DOCUMENTATION = (
"Open Work in the selected Campaign workspace and choose Add assignment.",
"Enter a bounded purpose, optional due date, typed target, and optional stable Campaign evidence reference.",
"Resolve any authorization-neutral rejection by granting access through the separate Campaign sharing workflow or choosing another assignee; creating the assignment itself never grants access.",
"Start and complete your own assignment, or use manager actions to reassign or cancel open work.",
"Accept and complete your own assignment, reject it explicitly when it cannot be taken on, or use manager actions to reassign or cancel open work.",
"Reload and reconcile assignments after account, group, organization-function, or incumbency changes; inspect the retained history before acting on unavailable work.",
),
outcome="A durable accountability record whose lifecycle is independent from Campaign ownership, authorization, and delivery state.",
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
verification="Reload Work, inspect the typed target, resolution provenance, revision and history, and confirm Campaign shares and ownership did not change. For Workflow-opened work, follow the focused assignment link and verify the exact terminal event and revision resume only the pinned Workflow step. When Tasks is installed, confirm the optional mirror links back to this Campaign assignment.",
related_modules=("access", "organizations", "idm", "tasks", "notifications", "audit", "policy"),
limitations=(
"Organizations and IDM are optional; organization-function assignment is unavailable until both directory and incumbency capabilities are active.",
@@ -291,7 +327,7 @@ CAMPAIGN_USER_DOCUMENTATION = (
"de": {
"title": "Verantwortliche Kampagnenarbeit zuweisen, ohne Zugriff zu vergeben",
"summary": "Begrenzte Arbeit für Konto, Gruppe oder Organisationsfunktion erfassen und Berechtigung sowie Kampagneneigentum getrennt halten.",
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
"body": "Kampagnenzuweisungen dokumentieren Verantwortung, nicht Berechtigung. Lesende und Handelnde müssen weiterhin den Zugriff auf die übergeordnete Kampagne nachweisen. Neue Ziele werden nur angenommen, wenn Konto, Gruppe oder alle aktuellen Funktionsinhabenden bereits Kampagnenzugriff besitzen. Zweck, optionale Fälligkeit, zuweisende Person, typisierte Referenz, lesbarer Schnappschuss, aktueller Auflösungszustand, Revision und unveränderliche Übergangshistorie bleiben erhalten. Zugewiesene Personen können Arbeit annehmen, abschließen oder ausdrücklich ablehnen; Ablehnung bleibt von einer administrativen Stornierung getrennt. Durch Workflow eröffnete Arbeit bewahrt Korrelation, Idempotenz, Workflow-Instanz und -Schritt sowie die genaue Kampagnenversion und erzeugt revisionsgebundene Lebenszyklusereignisse. Workflow prüft den Kampagnenzugriff vor der Fortsetzung erneut. Deaktivierung oder Vakanz wird beim Abgleich als nicht verfügbar dokumentiert. Optionale Benachrichtigungen und Tasks-Spiegelungen dürfen die Kampagnentransaktion nicht blockieren.",
}
},
),
@@ -980,6 +1016,8 @@ def _actor_capabilities(principal: object, *, mail_available: bool) -> tuple[str
_append_if(capabilities, principal, ("campaigns:campaign:create",), "Create new campaigns.")
_append_if(capabilities, principal, ("campaigns:campaign:update",), "Edit eligible working campaign versions.")
_append_if(capabilities, principal, ("campaigns:campaign:copy",), "Create an editable successor from an eligible existing version.")
_append_if(capabilities, principal, ("campaigns:campaign:export",), "Export privacy-scoped portable Campaign packages.")
_append_if(capabilities, principal, ("campaigns:campaign:import", "campaigns:campaign:create"), "Preview and import compatible portable Campaign packages as new drafts.", require_all=True)
_append_if(capabilities, principal, ("campaigns:recipient:read",), "Inspect recipients and recipient-specific campaign data.")
_append_if(capabilities, principal, ("campaigns:recipient:write",), "Add and edit recipient rows.")
_append_if(capabilities, principal, ("campaigns:recipient:import",), "Import recipient snapshots.")
+109 -3
View File
@@ -14,6 +14,7 @@ from govoplan_core.core.campaigns import (
CAPABILITY_CAMPAIGNS_POLICY_CONTEXT,
CAPABILITY_CAMPAIGNS_RETENTION,
CAPABILITY_CAMPAIGNS_SCHEDULES,
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
)
from govoplan_core.core.calendar import CAPABILITY_CALENDAR_INVITATIONS
from govoplan_core.core.module_guards import (
@@ -72,6 +73,9 @@ from govoplan_campaign.backend.documentation import (
)
from govoplan_campaign.backend.dsar_provider import CAMPAIGN_DSAR_CAPABILITY
from govoplan_campaign.backend.search_source import create_campaign_search_source
from govoplan_campaign.backend.workflow_definitions import (
campaign_workflow_definitions,
)
register_campaign_change_tracking()
@@ -133,13 +137,13 @@ PERMISSIONS = (
_permission(
"campaigns:assignment:manage",
"Manage campaign work assignments",
"Create, reassign, cancel, and reconcile authorization-neutral campaign work assignments.",
"Create, reassign, cancel, and reconcile authorization-neutral campaign work assignments, including Workflow-opened hand-offs.",
"Campaign work",
),
_permission(
"campaigns:assignment:complete",
"Complete assigned campaign work",
"Start or complete campaign work assigned to the current account, group, or organization function.",
"Accept, complete, or reject campaign work assigned to the current account, group, or organization function.",
"Campaign work",
),
_permission(
@@ -160,6 +164,18 @@ PERMISSIONS = (
"Create campaigns or working versions from existing campaigns.",
"Campaigns",
),
_permission(
"campaigns:campaign:export",
"Export portable campaigns",
"Create integrity-protected portable Campaign packages with explicitly selected data scopes.",
"Campaigns",
),
_permission(
"campaigns:campaign:import",
"Import portable campaigns",
"Preview and create new Campaign drafts from compatible portable packages.",
"Campaigns",
),
_permission(
"campaigns:campaign:schedule",
"Schedule campaigns",
@@ -338,6 +354,8 @@ ROLE_TEMPLATES = (
"campaigns:campaign:create",
"campaigns:campaign:update",
"campaigns:campaign:copy",
"campaigns:campaign:export",
"campaigns:campaign:import",
"campaigns:campaign:schedule",
"campaigns:campaign:validate",
"campaigns:campaign:build",
@@ -439,7 +457,8 @@ def _campaigns_router(context: ModuleContext):
manifest = ModuleManifest(
id="campaigns",
name="Campaigns",
version="0.1.21",
version="0.1.24",
workflow_definitions=campaign_workflow_definitions(module_version="0.1.24"),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
@@ -475,6 +494,10 @@ manifest = ModuleManifest(
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.policy_context", version="0.1.6"),
ModuleInterfaceProvider(name="campaigns.retention", version="0.1.6"),
ModuleInterfaceProvider(
name="campaigns.work_orchestration",
version="1.0.0",
),
ModuleInterfaceProvider(
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
version="1.0.0",
@@ -691,12 +714,20 @@ manifest = ModuleManifest(
"campaigns.route.operator-redirect",
OPERATOR_QUEUE_SURFACE_ID,
REPORTS_SURFACE_ID,
"campaigns.page.work",
"campaigns.page.activity",
),
order=40,
),
),
view_surfaces=(
ViewSurface(
id="campaigns.page.work",
module_id="campaigns",
kind="page",
label="Campaign work",
order=44,
),
ViewSurface(
id="campaigns.page.activity",
module_id="campaigns",
@@ -832,6 +863,67 @@ manifest = ModuleManifest(
"help_contexts": ["campaigns.quick_access.campaigns"],
},
),
DocumentationTopic(
id="campaigns.admin.portable-transfer-governance",
title="Govern portable Campaign export and import",
summary="Separate configuration portability from recipient and delivery-data export, and verify every import as a new draft.",
body=(
"Portable Campaign export and import use separate campaign-level permissions. The built-in Campaign manager can move configuration, but recipient rows additionally require recipient read/export on export and recipient write/import on import. Review-state export requires report read; recipient-level delivery history requires report export plus recipient read/export. The UI and API default export to metadata plus template/configuration only. Every package records its format, source Campaign/version, selected scopes, item counts, redaction counts, and SHA-256 integrity digest. Campaign removes transport secrets, credential-envelope references, password-field values, infrastructure paths, and attachment bytes. Import fails closed on format, checksum, schema, scope, or destination-ID conflicts; its preview identifies every created and skipped domain. It always creates a separately owned draft, clears deployment-bound Mail references, and never recreates shares, locks, approvals, review decisions, delivery jobs, attempts, or sent state. The destination retains a bounded import receipt and matching Audit evidence. Operators must govern downloaded package storage and deletion outside GovOPlaN according to the selected data scopes."
),
layer="configured",
documentation_types=("admin",),
audience=("module_admin", "security_reviewer", "privacy_officer", "campaign_manager"),
order=40,
conditions=(
DocumentationCondition(
required_modules=("campaigns",),
any_scopes=(
"campaigns:campaign:export",
"campaigns:campaign:import",
"access:roles:manage",
),
),
),
links=(
DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),
DocumentationLink(
label="Campaign handbook",
href="govoplan-campaign/docs/CAMPAIGN_HANDBOOK.md",
kind="repository",
),
),
related_modules=("access", "audit", "files", "mail"),
translations={
"de": {
"title": "Portablen Campaign-Export und -Import steuern",
"summary": "Konfigurationsportabilitaet vom Export von Empfaenger- und Zustelldaten trennen und jeden Import als neuen Entwurf pruefen.",
"body": (
"Portabler Campaign-Export und -Import verwenden getrennte Campaign-Berechtigungen. Empfaengerzeilen erfordern beim Export zusaetzlich Empfaenger-Lese- und Exportrecht sowie beim Import Empfaenger-Schreib- und Importrecht. Pruefstatus erfordert Berichtsleserecht; Zustellhistorie erfordert Berichtsexport sowie Empfaenger-Lese- und Exportrecht. Standardmaessig werden nur Metadaten sowie Vorlage und Konfiguration exportiert. Jedes Paket enthaelt Format, Quelle, ausgewaehlte Umfaenge, Zaehler, Redaktionen und SHA-256-Integritaet. Transportgeheimnisse, Zugangsdatenverweise, Passwortfeldwerte, Infrastrukturpfade und Dateiinhalte werden entfernt. Der Import schlaegt bei Format-, Pruefsummen-, Schema-, Umfangs- oder Kennungskonflikten geschlossen fehl und erstellt immer einen eigenstaendigen Entwurf. Freigaben, Sperren, Genehmigungen, Pruefentscheidungen, Zustellauftraege und Sendezustaende werden nie wiedergegeben."
),
}
},
metadata={
"kind": "configuration",
"route": "/campaigns",
"screen": "Campaign portable transfer",
"help_contexts": [
"campaigns.action.export-package",
"campaigns.action.import-package",
],
"permission_scopes": [
"campaigns:campaign:export",
"campaigns:campaign:import",
"campaigns:recipient:read",
"campaigns:recipient:write",
"campaigns:recipient:import",
"campaigns:recipient:export",
"campaigns:report:read",
"campaigns:report:export",
],
"privacy_default_scopes": ["metadata", "template_config"],
"verification": "Export the default scopes as a Campaign manager, verify a recipient scope is denied without recipient-export, tamper with the JSON and verify preview rejects it, then import a valid package and confirm a new draft plus matching Audit hashes without jobs or approval state.",
},
),
DocumentationTopic(
id="campaigns.admin.collaboration-governance",
title="Govern Campaign collaboration permissions and retention",
@@ -1598,6 +1690,10 @@ manifest = ModuleManifest(
"govoplan_campaign.backend.capabilities",
fromlist=["retention_capability"],
).retention_capability(context),
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: lambda context: __import__(
"govoplan_campaign.backend.work_orchestration",
fromlist=["SqlCampaignWorkOrchestrationProvider"],
).SqlCampaignWorkOrchestrationProvider(registry=context.registry),
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
"govoplan_campaign.backend.reports.provider",
fromlist=["CampaignAggregateReportProvider"],
@@ -1605,6 +1701,16 @@ manifest = ModuleManifest(
CAMPAIGN_DSAR_CAPABILITY: _dsar_provider,
},
capability_documentation={
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION: CapabilityDocumentation(
label="Campaign work orchestration",
summary=(
"Creates or references Campaign work idempotently and exposes "
"revision-bearing lifecycle events without granting access."
),
contract_version="1.0",
documentation_types=("admin", "user"),
audience=("campaign_manager", "workflow_designer", "module_admin"),
),
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
label="Campaign aggregate report provider",
summary=(
@@ -0,0 +1,103 @@
"""add durable Campaign work orchestration provenance
revision = "f3c7a9d2e6b1"
down_revision = "d8e9f0a1b2c3"
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "f3c7a9d2e6b1"
down_revision = "d8e9f0a1b2c3"
branch_labels = None
depends_on = None
_COLUMN_SPECS = (
("orchestration_idempotency_key", sa.String(length=255)),
("orchestration_request_sha256", sa.String(length=64)),
("orchestration_correlation_id", sa.String(length=128)),
("workflow_instance_id", sa.String(length=36)),
("workflow_step_id", sa.String(length=36)),
)
def upgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_work_assignments"):
return
existing = {
item["name"]
for item in inspector.get_columns("campaign_work_assignments")
}
with op.batch_alter_table("campaign_work_assignments") as batch:
for name, column_type in _COLUMN_SPECS:
if name not in existing:
batch.add_column(sa.Column(name, column_type, nullable=True))
inspector = sa.inspect(op.get_bind())
indexes = {
item["name"]
for item in inspector.get_indexes("campaign_work_assignments")
}
for name, columns in (
(
"ix_campaign_work_assignments_orchestration_idempotency_key",
["orchestration_idempotency_key"],
),
(
"ix_campaign_work_assignments_orchestration_correlation_id",
["orchestration_correlation_id"],
),
(
"ix_campaign_work_assignments_workflow_instance_id",
["workflow_instance_id"],
),
(
"ix_campaign_work_assignments_workflow_step_id",
["workflow_step_id"],
),
(
"uq_campaign_work_assignment_orchestration_key",
["tenant_id", "orchestration_idempotency_key"],
),
):
if name not in indexes:
op.create_index(
name,
"campaign_work_assignments",
columns,
unique=name.startswith("uq_"),
)
def downgrade() -> None:
inspector = sa.inspect(op.get_bind())
if not inspector.has_table("campaign_work_assignments"):
return
indexes = {
item["name"]
for item in inspector.get_indexes("campaign_work_assignments")
}
for name in (
"uq_campaign_work_assignment_orchestration_key",
"ix_campaign_work_assignments_workflow_step_id",
"ix_campaign_work_assignments_workflow_instance_id",
"ix_campaign_work_assignments_orchestration_correlation_id",
"ix_campaign_work_assignments_orchestration_idempotency_key",
):
if name in indexes:
op.drop_index(name, table_name="campaign_work_assignments")
existing = {
item["name"]
for item in sa.inspect(op.get_bind()).get_columns(
"campaign_work_assignments"
)
}
with op.batch_alter_table("campaign_work_assignments") as batch:
for name, _column_type in reversed(_COLUMN_SPECS):
if name in existing:
batch.drop_column(name)
+2
View File
@@ -12,12 +12,14 @@ from govoplan_campaign.backend.routes.operations import router as operations_rou
from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.schedules import router as schedules_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.transfers import router as transfers_router
from govoplan_campaign.backend.routes.versions import router as versions_router
router = APIRouter()
for workflow_router in (
operations_router,
transfers_router,
campaigns_router,
assignments_router,
collaboration_router,
@@ -43,6 +43,13 @@ from govoplan_core.core.idm import (
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
IdmFunctionAssignmentDirectory,
)
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.notifications import (
NotificationDispatchRequest,
notification_dispatch_provider,
@@ -127,7 +134,10 @@ def list_campaign_work_assignments(
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:read")
statuses = tuple(dict.fromkeys(item.strip() for item in assignment_status if item.strip()))
if any(item not in {"open", "in_progress", "completed", "cancelled"} for item in statuses):
if any(
item not in {"open", "in_progress", "completed", "rejected", "cancelled"}
for item in statuses
):
raise HTTPException(status_code=422, detail="Unsupported assignment status filter.")
query = session.query(CampaignWorkAssignment).filter(
CampaignWorkAssignment.tenant_id == principal.tenant_id,
@@ -288,12 +298,20 @@ def transition_campaign_work_assignment(
if payload.action == "cancel":
raise HTTPException(status_code=403, detail="Only an assignment manager may cancel work.")
_require_revision(assignment, payload.expected_revision)
target_status = {"start": "in_progress", "complete": "completed", "cancel": "cancelled"}[payload.action]
target_status = {
"accept": "in_progress",
"start": "in_progress",
"complete": "completed",
"reject": "rejected",
"cancel": "cancelled",
}[payload.action]
if assignment.status == target_status:
return _assignment_response(assignment)
allowed = {
"accept": {"open"},
"start": {"open"},
"complete": {"open", "in_progress"},
"reject": {"open", "in_progress"},
"cancel": {"open", "in_progress"},
}
if assignment.status not in allowed[payload.action]:
@@ -306,7 +324,13 @@ def transition_campaign_work_assignment(
session,
assignment=assignment,
principal=principal,
event_kind={"start": "started", "complete": "completed", "cancel": "cancelled"}[payload.action],
event_kind={
"accept": "accepted",
"start": "started",
"complete": "completed",
"reject": "rejected",
"cancel": "cancelled",
}[payload.action],
details={"reason": payload.reason},
)
_notify_assignment(session, campaign=campaign, assignment=assignment, event_kind=payload.action)
@@ -697,6 +721,51 @@ def _record_event(
)
session.add(event)
session.flush()
emit_platform_event(
session,
PlatformEvent(
type="campaign.work.changed",
module_id="campaigns",
event_id=event.id,
occurred_at=event.created_at,
correlation_id=assignment.orchestration_correlation_id,
causation_id=assignment.workflow_step_id,
actor=EventActorRef(
type="account",
id=principal.account_id,
label=_actor_label(principal),
),
tenant=EventTenantRef(id=assignment.tenant_id),
subject=EventObjectRef(
type="campaign_work_assignment",
id=assignment.id,
),
resource=EventObjectRef(
type="campaign",
id=assignment.campaign_id,
),
classification="internal",
payload={
"campaign_id": assignment.campaign_id,
"campaign_version_id": assignment.campaign_version_id,
"assignment_id": assignment.id,
"assignment_revision": assignment.resource_revision,
"assignment_ref": (
"campaign-work-assignment:"
f"{assignment.id}:r{assignment.resource_revision}"
),
"outcome": event_kind,
"status": assignment.status,
"assignee_type": assignment.assignee_type,
"assignee_id": assignment.assignee_id,
"action_url": (
f"/campaigns/{assignment.campaign_id}/work"
f"?assignment={assignment.id}"
),
},
),
registry=get_registry(),
)
return event
@@ -0,0 +1,378 @@
from __future__ import annotations
import copy
from importlib import metadata
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.transfers import (
CampaignImportInspection,
CampaignTransferError,
build_campaign_portable_package,
inspect_campaign_portable_package,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.campaigns import (
create_campaign_version_from_json,
)
from govoplan_campaign.backend.route_support import (
_campaign_response_context,
_get_campaign_for_principal,
_require_permission,
_write_current_version_snapshot_if_available,
)
from govoplan_campaign.backend.schemas import (
CampaignExportRequest,
CampaignImportApplyRequest,
CampaignImportApplyResponse,
CampaignImportPreviewRequest,
CampaignImportPreviewResponse,
CampaignPortablePackageResponse,
CampaignResponse,
CampaignVersionResponse,
)
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.db.session import get_session
router = APIRouter(tags=["campaigns"])
@router.post(
"/campaigns/{campaign_id}/versions/{version_id}/exports",
response_model=CampaignPortablePackageResponse,
)
def export_campaign_package(
campaign_id: str,
version_id: str,
payload: CampaignExportRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:campaign:export")
),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
version = (
session.query(CampaignVersion)
.filter(
CampaignVersion.id == version_id,
CampaignVersion.campaign_id == campaign.id,
)
.one_or_none()
)
if version is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Campaign version not found",
)
scopes = set(payload.scopes)
if "recipients" in scopes:
_require_permission(principal, "campaigns:recipient:read")
_require_permission(principal, "campaigns:recipient:export")
if "review_state" in scopes:
_require_permission(principal, "campaigns:report:read")
if "delivery_history" in scopes:
_require_permission(principal, "campaigns:report:export")
_require_permission(principal, "campaigns:recipient:read")
_require_permission(principal, "campaigns:recipient:export")
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc(), CampaignJob.id.asc())
.all()
if "delivery_history" in scopes
else ()
)
issues = (
session.query(CampaignIssue)
.filter(CampaignIssue.campaign_version_id == version.id)
.order_by(CampaignIssue.id.asc())
.all()
if "review_state" in scopes
else ()
)
try:
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=payload.scopes,
jobs=jobs,
issues=issues,
module_version=_module_version(),
)
audit_from_principal(
session,
principal,
action="campaign.portable_export_created",
object_type="campaign_version",
object_id=version.id,
details={
"campaign_id": campaign.id,
"package_id": package["package_id"],
"package_sha256": package["integrity"]["package_sha256"],
"format_version": package["format_version"],
"scopes": package["scopes"],
"item_counts": package["manifest"]["item_counts"],
"redactions": package["manifest"]["redactions"],
},
commit=True,
)
except CampaignTransferError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
except Exception:
session.rollback()
raise
return package
@router.post(
"/campaign-transfers/imports/preview",
response_model=CampaignImportPreviewResponse,
)
def preview_campaign_import(
payload: CampaignImportPreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:campaign:import")
),
):
_require_permission(principal, "campaigns:campaign:create")
inspection = _inspect_import_request(
session,
principal,
package=payload.package,
selected_scopes=payload.selected_scopes,
external_id=payload.external_id,
name=payload.name,
)
return inspection.preview
@router.post(
"/campaign-transfers/imports",
response_model=CampaignImportApplyResponse,
status_code=status.HTTP_201_CREATED,
)
def import_campaign_package(
payload: CampaignImportApplyRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_scope("campaigns:campaign:import")
),
):
_require_permission(principal, "campaigns:campaign:create")
inspection = _inspect_import_request(
session,
principal,
package=payload.package,
selected_scopes=payload.selected_scopes,
external_id=payload.external_id,
name=payload.name,
)
_require_import_scope_permissions(principal, inspection)
preview = inspection.preview
if payload.expected_package_sha256 != preview.get("package_sha256"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="The Campaign package changed after preview. Preview it again before importing.",
)
if not preview["compatible"] or inspection.configuration is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail={
"message": "The Campaign package is not compatible.",
"errors": preview["errors"],
},
)
destination = preview["destination"]
package_id = str(preview["package_id"])
package_sha256 = str(preview["package_sha256"])
receipt = {
"package_id": package_id,
"package_sha256": package_sha256,
"format_version": preview["format_version"],
"source": copy.deepcopy(preview["source"]),
"selected_scopes": list(preview["selected_scopes"]),
"created": copy.deepcopy(preview["will_create"]),
"skipped": copy.deepcopy(preview["will_skip"]),
}
try:
campaign, version = create_campaign_version_from_json(
session,
tenant_id=principal.tenant_id,
user_id=principal.user.id,
raw_json=inspection.configuration,
source_filename=f"{package_id}.govoplan-campaign.json",
source_base_path=None,
commit=False,
)
campaign.settings = {
**inspection.portable_settings,
"portable_import": receipt,
}
session.add(campaign)
audit_from_principal(
session,
principal,
action="campaign.portable_import_applied",
object_type="campaign",
object_id=campaign.id,
details={
"version_id": version.id,
"external_id": destination["external_id"],
"package_id": package_id,
"package_sha256": package_sha256,
"format_version": preview["format_version"],
"selected_scopes": preview["selected_scopes"],
"created_codes": [item["code"] for item in preview["will_create"]],
"skipped_codes": [item["code"] for item in preview["will_skip"]],
},
commit=True,
)
session.refresh(campaign)
session.refresh(version)
_write_current_version_snapshot_if_available(version)
except Exception:
session.rollback()
raise
return CampaignImportApplyResponse(
campaign=CampaignResponse.model_validate(campaign),
version=CampaignVersionResponse.model_validate(
version,
context=_campaign_response_context(principal),
),
receipt=receipt,
)
def _inspect_import_request(
session: Session,
principal: ApiPrincipal,
*,
package: dict[str, Any],
selected_scopes: list[str] | None,
external_id: str | None,
name: str | None,
) -> CampaignImportInspection:
source = package.get("source")
source = source if isinstance(source, dict) else {}
metadata_payload = package.get("payload")
metadata_payload = metadata_payload if isinstance(metadata_payload, dict) else {}
metadata_scope = metadata_payload.get("metadata")
metadata_scope = metadata_scope if isinstance(metadata_scope, dict) else {}
source_external_id = str(
metadata_scope.get("external_id")
or source.get("campaign_external_id")
or "campaign"
)
destination_external_id = _portable_import_external_id(
session,
tenant_id=principal.tenant_id,
source_external_id=source_external_id,
requested=external_id,
)
destination_name = str(
name
or metadata_scope.get("name")
or source.get("campaign_name")
or "Imported campaign"
).strip()
if not destination_name:
destination_name = "Imported campaign"
inspection = inspect_campaign_portable_package(
package,
selected_scopes=selected_scopes,
external_id=destination_external_id,
name=destination_name,
)
if _campaign_external_id_exists(
session, principal.tenant_id, destination_external_id
):
inspection.preview["compatible"] = False
inspection.preview["errors"].append(
"The destination Campaign ID already exists in this tenant."
)
return CampaignImportInspection(
preview=inspection.preview,
configuration=None,
portable_settings=inspection.portable_settings,
)
return inspection
def _portable_import_external_id(
session: Session,
*,
tenant_id: str,
source_external_id: str,
requested: str | None,
) -> str:
if requested is not None:
candidate = requested.strip()
if not candidate:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign ID cannot be empty.",
)
return candidate
stem = f"{source_external_id[:238]}-import"
for suffix in ("", *(f"-{number}" for number in range(2, 10_000))):
candidate = f"{stem[:255 - len(suffix)]}{suffix}"
if not _campaign_external_id_exists(session, tenant_id, candidate):
return candidate
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No available Campaign import identifier could be generated.",
)
def _campaign_external_id_exists(
session: Session, tenant_id: str, external_id: str
) -> bool:
return (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == tenant_id,
Campaign.external_id == external_id,
)
.first()
is not None
)
def _require_import_scope_permissions(
principal: ApiPrincipal, inspection: CampaignImportInspection
) -> None:
selected = set(inspection.preview.get("selected_scopes") or [])
if "recipients" in selected:
_require_permission(principal, "campaigns:recipient:import")
_require_permission(principal, "campaigns:recipient:write")
def _module_version() -> str:
try:
return metadata.version("govoplan-campaign")
except metadata.PackageNotFoundError:
return "development"
__all__ = [
"export_campaign_package",
"import_campaign_package",
"preview_campaign_import",
"router",
]
+98 -2
View File
@@ -134,7 +134,13 @@ class CampaignCollaborationListResponse(BaseModel):
CampaignWorkAssigneeType = Literal["account", "group", "organization_function"]
CampaignWorkAssignmentStatus = Literal["open", "in_progress", "completed", "cancelled"]
CampaignWorkAssignmentStatus = Literal[
"open",
"in_progress",
"completed",
"rejected",
"cancelled",
]
CampaignWorkAssigneeResolutionState = Literal[
"resolved",
"unavailable",
@@ -195,7 +201,7 @@ class CampaignWorkAssignmentTransitionRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
action: Literal["start", "complete", "cancel"]
action: Literal["accept", "start", "complete", "reject", "cancel"]
reason: str | None = Field(default=None, max_length=500)
@field_validator("reason")
@@ -589,6 +595,96 @@ class CampaignCreateResponse(BaseModel):
version: CampaignVersionResponse
CampaignTransferScope = Literal[
"metadata",
"template_config",
"recipients",
"attachments",
"review_state",
"delivery_history",
]
class CampaignExportRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
scopes: list[CampaignTransferScope] = Field(
default_factory=lambda: ["metadata", "template_config"],
min_length=1,
max_length=6,
)
@field_validator("scopes")
@classmethod
def normalize_scopes(
cls, value: list[CampaignTransferScope]
) -> list[CampaignTransferScope]:
return list(dict.fromkeys(value))
class CampaignPortablePackageResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
format: Literal["govoplan.campaign-portable"]
format_version: str
package_id: str
exported_at: str
source: dict[str, Any]
scopes: list[CampaignTransferScope]
manifest: dict[str, Any]
payload: dict[str, Any]
integrity: dict[str, str]
class CampaignImportPreviewRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
package: dict[str, Any]
selected_scopes: list[CampaignTransferScope] | None = Field(
default=None,
max_length=6,
)
external_id: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
@field_validator("selected_scopes")
@classmethod
def normalize_selected_scopes(
cls, value: list[CampaignTransferScope] | None
) -> list[CampaignTransferScope] | None:
return list(dict.fromkeys(value)) if value is not None else None
class CampaignImportApplyRequest(CampaignImportPreviewRequest):
expected_package_sha256: str = Field(min_length=64, max_length=64)
class CampaignTransferPlanItem(BaseModel):
scope: CampaignTransferScope
code: str
summary: str
item_count: int | None = None
class CampaignImportPreviewResponse(BaseModel):
compatible: bool
package_id: str | None = None
package_sha256: str | None = None
format_version: str | None = None
source: dict[str, Any] = Field(default_factory=dict)
available_scopes: list[CampaignTransferScope] = Field(default_factory=list)
selected_scopes: list[CampaignTransferScope] = Field(default_factory=list)
destination: dict[str, Any] = Field(default_factory=dict)
will_create: list[CampaignTransferPlanItem] = Field(default_factory=list)
will_skip: list[CampaignTransferPlanItem] = Field(default_factory=list)
warnings: list[str] = Field(default_factory=list)
errors: list[str] = Field(default_factory=list)
class CampaignImportApplyResponse(CampaignCreateResponse):
receipt: dict[str, Any]
class CampaignListResponse(BaseModel):
campaigns: list[CampaignResponse]
@@ -0,0 +1,778 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import replace
from datetime import datetime
import hashlib
import json
from fastapi import HTTPException
from sqlalchemy.orm import Session
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignVersion,
CampaignWorkAssignment,
)
from govoplan_campaign.backend.persistence.versions import create_minimal_campaign
from govoplan_campaign.backend.route_support import _get_campaign_for_principal
from govoplan_campaign.backend.routes.assignments import (
_actor_label,
_mirror_assignment_to_tasks,
_notify_assignment,
_record_event,
_require_resolved_assignee,
_resolve_assignee,
)
from govoplan_campaign.backend.schemas import CampaignWorkAssigneeInput
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.automation import (
ActionDefinition,
ActionExecutionRequest,
ActionExecutionResult,
ActionPreview,
EffectDefinition,
EffectPreview,
ObservedEffect,
)
from govoplan_core.core.campaigns import (
CampaignWorkHandoffInspection,
CampaignWorkHandoffRef,
CampaignWorkHandoffRequest,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.tasks import CAPABILITY_TASK_COMMANDS
from govoplan_core.security.time import utc_now
ACTION_KEY = "campaigns.work.prepare"
ASSIGNMENT_EFFECT = "campaigns.work.assignment_created"
CAMPAIGN_EFFECT = "campaigns.work.campaign_created"
class SqlCampaignWorkOrchestrationProvider:
"""Campaign-owned adapter used through optional Core capabilities only."""
def __init__(self, *, registry: object | None = None) -> None:
self._registry = registry
def action_definitions(self) -> tuple[ActionDefinition, ...]:
return (
ActionDefinition(
action_key=ACTION_KEY,
owner_module="campaigns",
description=(
"Reference or create a Campaign and open one authorization-neutral "
"accountable work hand-off."
),
input_schema_ref="govoplan/campaigns/work-handoff.v1",
required_scopes=(
"campaigns:campaign:read",
"campaigns:campaign:create",
"campaigns:assignment:manage",
),
policy_checks=(
"campaign access is checked independently of assignment",
"the assignee must already have Campaign access",
"the expected Campaign revision must still be current",
),
risk_level="moderate",
reversibility="compensatable",
expected_effect_keys=(ASSIGNMENT_EFFECT, CAMPAIGN_EFFECT),
idempotency_strategy="caller_supplied",
audit_event_types=(
"campaign.assignment.created",
"campaign.created_minimal",
),
preview_required=True,
recovery_mode="atomic",
recovery_verification=(
"resolve the assignment by tenant and orchestration idempotency key",
"verify the exact Campaign version and assignment revisions",
"confirm the assigned principal still has independent Campaign access",
),
),
)
def effect_definitions(self) -> tuple[EffectDefinition, ...]:
return (
EffectDefinition(
effect_key=ASSIGNMENT_EFFECT,
owner_module="campaigns",
operation="created",
description="Create an accountable Campaign work assignment.",
resource_types=("campaign_work_assignment",),
audit_event_types=("campaign.assignment.created",),
compensation_hint="Cancel the open assignment through Campaign work.",
),
EffectDefinition(
effect_key=CAMPAIGN_EFFECT,
owner_module="campaigns",
operation="created",
description="Create a minimal Campaign draft when no campaign is referenced.",
resource_types=("campaign", "campaign_version"),
audit_event_types=("campaign.created_minimal",),
compensation_hint=(
"Delete the untouched draft under the normal Campaign lifecycle policy."
),
),
)
def preview_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionPreview:
if request.action_key != ACTION_KEY:
return _blocked_preview("The Campaign work action is not supported.")
try:
sql_session, api_principal = _context(session, principal)
handoff = _request(request)
_preview_handoff(sql_session, api_principal, handoff)
except (HTTPException, TypeError, ValueError) as exc:
return _blocked_preview(_message(exc))
creating = handoff.campaign_id is None
effects = [
EffectPreview(
effect_key=ASSIGNMENT_EFFECT,
summary="Open one revision-bearing Campaign work assignment.",
)
]
if creating:
effects.insert(
0,
EffectPreview(
effect_key=CAMPAIGN_EFFECT,
summary="Create one minimal Campaign draft and initial version.",
),
)
return ActionPreview(
action_key=ACTION_KEY,
allowed=True,
summary=(
"Create a Campaign draft and open accountable work."
if creating
else "Reference the current Campaign revision and open accountable work."
),
risk_level="moderate",
reversibility="compensatable",
effects=tuple(effects),
policy_provenance=(
{
"code": "campaign_assignment_does_not_grant_access",
"assignment_authorization_neutral": True,
"campaign_access_rechecked_on_resume": True,
},
),
preview_ref=f"campaign-work-preview:{_request_hash(handoff)}",
)
def execute_action(
self,
session: object,
principal: object,
*,
request: ActionExecutionRequest,
) -> ActionExecutionResult:
if request.action_key != ACTION_KEY:
raise ValueError("The Campaign work action is not supported.")
sql_session, api_principal = _context(session, principal)
handoff = _request(request)
ref = self.prepare_handoff(
sql_session,
api_principal,
request=handoff,
)
effects = [
ObservedEffect(
effect_key=ASSIGNMENT_EFFECT,
operation="created",
resource_ref=ref.assignment_ref,
summary=(
"Reused the existing idempotent Campaign work assignment."
if ref.replayed
else "Created the Campaign work assignment."
),
metadata={"replayed": ref.replayed},
)
]
if not ref.replayed and handoff.campaign_id is None:
effects.insert(
0,
ObservedEffect(
effect_key=CAMPAIGN_EFFECT,
operation="created",
resource_ref=ref.campaign_ref,
summary="Created the minimal Campaign draft.",
),
)
return ActionExecutionResult(
state="completed",
output=_ref_payload(ref),
observed_effects=tuple(effects),
audit_event_refs=(
str(ref.provenance["audit_event_ref"]),
)
if ref.provenance.get("audit_event_ref")
else (),
)
def prepare_handoff(
self,
session: object,
principal: object,
*,
request: CampaignWorkHandoffRequest,
) -> CampaignWorkHandoffRef:
sql_session, api_principal = _context(session, principal)
if api_principal.tenant_id != request.tenant_id:
raise ValueError("Campaign hand-off tenant does not match the principal")
request_hash = _request_hash(request)
existing = (
sql_session.query(CampaignWorkAssignment)
.filter(
CampaignWorkAssignment.tenant_id == request.tenant_id,
CampaignWorkAssignment.orchestration_idempotency_key
== request.idempotency_key,
)
.one_or_none()
)
if existing is not None:
if existing.orchestration_request_sha256 != request_hash:
raise ValueError(
"Campaign hand-off idempotency key was already used for "
"different input."
)
campaign = _get_campaign_for_principal(
sql_session,
existing.campaign_id,
api_principal,
)
return _handoff_ref(
sql_session,
campaign=campaign,
assignment=existing,
registry=self._registry,
replayed=True,
)
campaign, version, created = _campaign_and_version(
sql_session,
api_principal,
request,
create=True,
)
resolution = _resolve_assignee(
sql_session,
campaign=campaign,
assignee=CampaignWorkAssigneeInput(
type=request.assignee_kind,
id=request.assignee_id,
),
)
_require_resolved_assignee(resolution)
now = utc_now()
assignment = CampaignWorkAssignment(
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
reference_kind="campaign_version",
reference_id=version.id,
reference_label=f"Campaign version {version.version_number}",
purpose=request.purpose.strip(),
status="open",
due_at=request.due_at,
assignee_type=request.assignee_kind,
assignee_id=request.assignee_id.strip(),
assignee_label_snapshot=(resolution.label or request.assignee_id)[:500],
assignee_current_label=resolution.label,
assignee_resolution_state=resolution.state,
resolution_provenance={
**resolution.provenance,
"source": "workflow",
"workflow_instance_id": request.workflow_instance_id,
"workflow_step_id": request.workflow_step_id,
"expected_campaign_revision": request.expected_campaign_revision,
},
resolution_checked_at=now,
assigned_by_user_id=api_principal.user.id,
assigned_by_label_snapshot=_actor_label(api_principal),
orchestration_idempotency_key=request.idempotency_key,
orchestration_request_sha256=request_hash,
orchestration_correlation_id=request.correlation_id,
workflow_instance_id=request.workflow_instance_id,
workflow_step_id=request.workflow_step_id,
)
sql_session.add(assignment)
sql_session.flush()
_record_event(
sql_session,
assignment=assignment,
principal=api_principal,
event_kind="assigned",
details={
"source": "workflow",
"workflow_instance_id": request.workflow_instance_id,
"workflow_step_id": request.workflow_step_id,
},
)
if request.mirror_to_tasks:
_mirror_assignment_to_tasks(
sql_session,
campaign=campaign,
assignment=assignment,
principal=api_principal,
)
else:
assignment.task_mirror_status = "skipped"
_notify_assignment(
sql_session,
campaign=campaign,
assignment=assignment,
event_kind="assigned",
)
audit_ref = audit_from_principal(
sql_session,
api_principal,
action="campaign.assignment.created",
object_type="campaign_work_assignment",
object_id=assignment.id,
details={
"campaign_id": campaign.id,
"campaign_version_id": version.id,
"campaign_revision": version.edit_revision,
"resource_revision": assignment.resource_revision,
"source": "workflow",
"workflow_instance_id": request.workflow_instance_id,
"workflow_step_id": request.workflow_step_id,
"assignment_authorization_neutral": True,
"purpose_disclosed": False,
},
correlation_id=request.correlation_id,
causation_id=request.workflow_step_id,
commit=False,
)
if created:
audit_from_principal(
sql_session,
api_principal,
action="campaign.created_minimal",
object_type="campaign",
object_id=campaign.id,
details={
"version_id": version.id,
"external_id": campaign.external_id,
"source": "workflow",
"workflow_instance_id": request.workflow_instance_id,
},
correlation_id=request.correlation_id,
causation_id=request.workflow_step_id,
commit=False,
)
sql_session.flush()
ref = _handoff_ref(
sql_session,
campaign=campaign,
assignment=assignment,
registry=self._registry,
)
return replace(
ref,
provenance={**dict(ref.provenance), "audit_event_ref": audit_ref.id},
)
def inspect_handoff(
self,
session: object,
principal: object,
*,
tenant_id: str,
assignment_id: str,
expected_revision: int | None = None,
) -> CampaignWorkHandoffInspection:
try:
sql_session, api_principal = _context(session, principal)
except TypeError as exc:
return CampaignWorkHandoffInspection(allowed=False, reason=str(exc))
if api_principal.tenant_id != tenant_id:
return CampaignWorkHandoffInspection(
allowed=False,
reason="Campaign hand-off tenant does not match the principal.",
provenance={"code": "campaign_handoff_tenant_mismatch"},
)
assignment = sql_session.get(CampaignWorkAssignment, assignment_id)
if assignment is None or assignment.tenant_id != tenant_id:
return CampaignWorkHandoffInspection(
allowed=False,
reason="Campaign work assignment is unavailable.",
provenance={"code": "campaign_handoff_missing"},
)
try:
_get_campaign_for_principal(
sql_session,
assignment.campaign_id,
api_principal,
)
except HTTPException as exc:
return CampaignWorkHandoffInspection(
allowed=False,
status=assignment.status, # type: ignore[arg-type]
assignment_revision=assignment.resource_revision,
reason=_message(exc),
provenance={
"code": "campaign_handoff_access_revoked",
"campaign_id": assignment.campaign_id,
"assignment_does_not_grant_access": True,
},
)
if (
expected_revision is not None
and assignment.resource_revision != expected_revision
):
return CampaignWorkHandoffInspection(
allowed=False,
status=assignment.status, # type: ignore[arg-type]
assignment_revision=assignment.resource_revision,
action_url=_action_url(assignment),
assignment_ref=_assignment_ref(assignment),
reason="Campaign work assignment revision changed; reload its event.",
provenance={
"code": "campaign_handoff_revision_conflict",
"expected_revision": expected_revision,
"current_revision": assignment.resource_revision,
},
)
return CampaignWorkHandoffInspection(
allowed=True,
status=assignment.status, # type: ignore[arg-type]
assignment_revision=assignment.resource_revision,
action_url=_action_url(assignment),
assignment_ref=_assignment_ref(assignment),
provenance={
"code": "campaign_handoff_access_rechecked",
"campaign_id": assignment.campaign_id,
"assignment_does_not_grant_access": True,
},
)
def _context(
session: object,
principal: object,
) -> tuple[Session, ApiPrincipal]:
if not isinstance(session, Session):
raise TypeError("Campaign work orchestration requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Campaign work orchestration requires an API principal.")
return session, principal
def _request(request: ActionExecutionRequest) -> CampaignWorkHandoffRequest:
value = request.input
assignee = value.get("assignee")
if not isinstance(assignee, Mapping):
raise ValueError("Campaign work hand-offs require an assignee object.")
create = value.get("create_campaign")
if create is not None and not isinstance(create, Mapping):
raise ValueError("Campaign creation input must be an object.")
due_at = _date(value.get("due_at"))
return CampaignWorkHandoffRequest(
tenant_id=request.tenant_id,
idempotency_key=request.idempotency_key,
purpose=str(value.get("purpose") or ""),
assignee_kind=str(assignee.get("kind") or ""), # type: ignore[arg-type]
assignee_id=str(assignee.get("id") or ""),
campaign_id=_optional(value.get("campaign_id")),
create_external_id=_optional(create.get("external_id")) if create else None,
create_name=_optional(create.get("name")) if create else None,
create_description=(
_optional(create.get("description")) if create else None
),
expected_campaign_revision=_integer(
value.get("expected_campaign_revision")
),
due_at=due_at,
mirror_to_tasks=bool(value.get("mirror_to_tasks", True)),
correlation_id=request.invocation.correlation_id,
workflow_instance_id=_reference_id(
request.metadata.get("workflow_instance_ref"),
"workflow-instance:",
),
workflow_step_id=_reference_id(
request.metadata.get("workflow_step_ref"),
"workflow-step:",
),
)
def _preview_handoff(
session: Session,
principal: ApiPrincipal,
request: CampaignWorkHandoffRequest,
) -> None:
if principal.tenant_id != request.tenant_id:
raise ValueError("Campaign hand-off tenant does not match the principal")
for scope in (
"campaigns:campaign:read",
"campaigns:campaign:create",
"campaigns:assignment:manage",
):
if not has_scope(principal, scope):
raise ValueError(f"Campaign work hand-off requires {scope}.")
existing = (
session.query(CampaignWorkAssignment)
.filter(
CampaignWorkAssignment.tenant_id == request.tenant_id,
CampaignWorkAssignment.orchestration_idempotency_key
== request.idempotency_key,
)
.one_or_none()
)
if existing is not None:
if existing.orchestration_request_sha256 != _request_hash(request):
raise ValueError(
"Campaign hand-off idempotency key was already used for different input."
)
_get_campaign_for_principal(session, existing.campaign_id, principal)
return
if request.campaign_id is None:
if request.assignee_kind != "account" or (
request.assignee_id != principal.account_id
):
raise ValueError(
"A newly created Campaign can initially be assigned only to its "
"creating account; share it explicitly before assigning other principals."
)
duplicate = (
session.query(Campaign.id)
.filter(
Campaign.tenant_id == request.tenant_id,
Campaign.external_id == request.create_external_id,
)
.first()
)
if duplicate is not None:
raise ValueError("Campaign external ID already exists for this tenant.")
if request.expected_campaign_revision not in {None, 1}:
raise ValueError("A new Campaign starts at revision one.")
return
campaign, _version, _created = _campaign_and_version(
session,
principal,
request,
create=False,
)
resolution = _resolve_assignee(
session,
campaign=campaign,
assignee=CampaignWorkAssigneeInput(
type=request.assignee_kind,
id=request.assignee_id,
),
)
_require_resolved_assignee(resolution)
def _campaign_and_version(
session: Session,
principal: ApiPrincipal,
request: CampaignWorkHandoffRequest,
*,
create: bool,
) -> tuple[Campaign, CampaignVersion, bool]:
if request.campaign_id is None:
if not create:
raise ValueError("Campaign creation is not available during preview.")
campaign, version = create_minimal_campaign(
session,
tenant_id=request.tenant_id,
user_id=principal.user.id,
external_id=str(request.create_external_id),
name=str(request.create_name),
description=request.create_description,
current_flow="create",
current_step="basics",
commit=False,
)
return campaign, version, True
campaign = _get_campaign_for_principal(
session,
request.campaign_id,
principal,
)
version = session.get(CampaignVersion, campaign.current_version_id)
if version is None or version.campaign_id != campaign.id:
raise ValueError("The Campaign current version is unavailable.")
if (
request.expected_campaign_revision is not None
and version.edit_revision != request.expected_campaign_revision
):
raise ValueError(
"Campaign revision changed; reload the Campaign before opening work."
)
return campaign, version, False
def _handoff_ref(
session: Session,
*,
campaign: Campaign,
assignment: CampaignWorkAssignment,
registry: object | None,
replayed: bool = False,
) -> CampaignWorkHandoffRef:
version = session.get(CampaignVersion, assignment.campaign_version_id)
if version is None or version.campaign_id != campaign.id:
raise ValueError("The pinned Campaign hand-off version is unavailable.")
return CampaignWorkHandoffRef(
tenant_id=assignment.tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
campaign_revision=version.edit_revision,
assignment_id=assignment.id,
assignment_revision=assignment.resource_revision,
status=assignment.status, # type: ignore[arg-type]
action_url=_action_url(assignment),
campaign_ref=(
f"campaign:{campaign.id}:version:{version.id}:r{version.edit_revision}"
),
assignment_ref=_assignment_ref(assignment),
replayed=replayed,
optional_capabilities={
"tasks": _has_capability(registry, CAPABILITY_TASK_COMMANDS),
"notifications": _has_capability(
registry,
CAPABILITY_NOTIFICATIONS_DISPATCH,
),
},
provenance={
"assignment_authorization_neutral": True,
"campaign_access_checked": True,
"workflow_instance_id": assignment.workflow_instance_id,
"workflow_step_id": assignment.workflow_step_id,
"correlation_id": assignment.orchestration_correlation_id,
},
)
def _ref_payload(ref: CampaignWorkHandoffRef) -> dict[str, object]:
return {
"campaign_id": ref.campaign_id,
"campaign_version_id": ref.campaign_version_id,
"campaign_revision": ref.campaign_revision,
"assignment_id": ref.assignment_id,
"assignment_revision": ref.assignment_revision,
"status": ref.status,
"action_url": ref.action_url,
"campaign_ref": ref.campaign_ref,
"assignment_ref": ref.assignment_ref,
"event_type": ref.event_type,
"replayed": ref.replayed,
"optional_capabilities": dict(ref.optional_capabilities),
"provenance": dict(ref.provenance),
"outcome": "success",
}
def _request_hash(request: CampaignWorkHandoffRequest) -> str:
payload = {
"tenant_id": request.tenant_id,
"purpose": request.purpose.strip(),
"assignee_kind": request.assignee_kind,
"assignee_id": request.assignee_id.strip(),
"campaign_id": request.campaign_id,
"create_external_id": request.create_external_id,
"create_name": request.create_name,
"create_description": request.create_description,
"expected_campaign_revision": request.expected_campaign_revision,
"due_at": request.due_at.isoformat() if request.due_at else None,
"mirror_to_tasks": request.mirror_to_tasks,
"correlation_id": request.correlation_id,
"workflow_instance_id": request.workflow_instance_id,
"workflow_step_id": request.workflow_step_id,
}
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _blocked_preview(reason: str) -> ActionPreview:
return ActionPreview(
action_key=ACTION_KEY,
allowed=False,
summary=reason,
risk_level="moderate",
reversibility="compensatable",
blockers=(reason,),
policy_provenance=(
{
"code": "campaign_work_handoff_blocked",
"reason": reason,
},
),
)
def _message(exc: Exception) -> str:
if isinstance(exc, HTTPException):
detail = exc.detail
if isinstance(detail, Mapping):
return str(detail.get("explanation") or detail.get("code") or detail)
return str(detail)
return str(exc)
def _date(value: object) -> datetime | None:
if value is None or value == "":
return None
if isinstance(value, datetime):
return value
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError as exc:
raise ValueError("Campaign hand-off due date must use ISO 8601.") from exc
def _integer(value: object) -> int | None:
if value is None or value == "":
return None
if isinstance(value, bool):
raise ValueError("Campaign revisions must be integers.")
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError("Campaign revisions must be integers.") from exc
def _optional(value: object) -> str | None:
candidate = str(value or "").strip()
return candidate or None
def _reference_id(value: object, prefix: str) -> str | None:
candidate = str(value or "").strip()
return candidate.removeprefix(prefix) or None if candidate.startswith(prefix) else None
def _assignment_ref(assignment: CampaignWorkAssignment) -> str:
return f"campaign-work-assignment:{assignment.id}:r{assignment.resource_revision}"
def _action_url(assignment: CampaignWorkAssignment) -> str:
return (
f"/campaigns/{assignment.campaign_id}/work"
f"?assignment={assignment.id}"
)
def _has_capability(registry: object | None, name: str) -> bool:
return bool(
registry is not None
and hasattr(registry, "has_capability")
and registry.has_capability(name)
)
__all__ = ["ACTION_KEY", "SqlCampaignWorkOrchestrationProvider"]
@@ -0,0 +1,205 @@
from __future__ import annotations
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
from govoplan_core.core.workflows import WorkflowDefinitionContribution
def campaign_workflow_definitions(
*,
module_version: str,
) -> tuple[WorkflowDefinitionContribution, ...]:
"""Return opt-in Campaign workflow templates owned by this module."""
return (
WorkflowDefinitionContribution(
origin_module_id="campaigns",
origin_module_version=module_version,
definition_key="accountable-campaign-work-handoff",
name="Accountable Campaign work hand-off",
description=(
"Create or reference a Campaign, assign bounded work, and wait "
"for its revision-bearing completion, rejection, cancellation, "
"or timeout event."
),
graph=_campaign_work_handoff_graph(),
definition_kind="template",
scope_type="system",
inherit_to_lower_scopes=True,
allow_start=True,
allow_reuse=True,
allow_automation=False,
execution_mode="guided",
activate_on_install=False,
required_capabilities=(CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,),
required_interfaces=("campaigns.work_orchestration",),
metadata={
"domain": "campaigns.accountable_work",
"state_owner": "campaigns",
"template_requires_configuration": True,
},
policy_metadata={
"assignment_authorization_neutral": True,
"campaign_access_rechecked_on_resume": True,
"navigation_does_not_complete_work": True,
},
),
)
def _campaign_work_handoff_graph() -> dict[str, object]:
return {
"schema_version": 1,
"nodes": [
{
"id": "start",
"type": "workflow.start.manual",
"label": "Campaign work requested",
"position": {"x": 20, "y": 140},
"config": {
"input_schema_ref": "govoplan/campaigns/work-handoff.v1",
},
},
{
"id": "prepare",
"type": "workflow.capability",
"label": "Prepare Campaign work",
"position": {"x": 250, "y": 140},
"config": {
"capability": CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
"operation": "campaigns.work.prepare",
"input_mapping": {
"campaign_id": "$input.campaign_id",
"create_campaign": "$input.create_campaign",
"expected_campaign_revision": (
"$input.expected_campaign_revision"
),
"purpose": "$input.purpose",
"assignee": "$input.assignee",
"due_at": "$input.due_at",
"mirror_to_tasks": "$input.mirror_to_tasks",
},
"idempotency_key": "workflow-step",
"failure_policy": "manual",
"view_surface_ids": ["campaigns.page.work"],
},
},
{
"id": "campaign_work",
"type": "workflow.external_handoff",
"label": "Complete Campaign work",
"position": {"x": 510, "y": 140},
"config": {
"provider_capability": (
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
),
"event_type": "campaign.work.changed",
"event_filter": {
"payload": {
"assignment_id": (
"$steps.prepare.execution.output.assignment_id"
)
}
},
"outcome_path": "payload.outcome",
"terminal_outcomes": {
"completed": "completed",
"rejected": "rejected",
"cancelled": "cancelled",
},
"observed_outcomes": [
"assigned",
"accepted",
"started",
"reassigned",
],
"external_id": (
"$steps.prepare.execution.output.assignment_id"
),
"expected_revision": (
"$steps.prepare.execution.output.assignment_revision"
),
"action_url": "$steps.prepare.execution.output.action_url",
"immutable_ref": (
"$steps.prepare.execution.output.assignment_ref"
),
"optional_capabilities": (
"$steps.prepare.execution.output.optional_capabilities"
),
"timeout_after": "$input.timeout_after",
"view_surface_ids": ["campaigns.page.work"],
},
},
{
"id": "completed",
"type": "workflow.end.completed",
"label": "Campaign work completed",
"position": {"x": 790, "y": 20},
"config": {"output_mapping": {}},
},
{
"id": "rejected",
"type": "workflow.end.cancelled",
"label": "Campaign work rejected",
"position": {"x": 790, "y": 120},
"config": {"reason": "Campaign work was rejected"},
},
{
"id": "cancelled",
"type": "workflow.end.cancelled",
"label": "Campaign work cancelled",
"position": {"x": 790, "y": 220},
"config": {"reason": "Campaign work was cancelled"},
},
{
"id": "timed_out",
"type": "workflow.end.cancelled",
"label": "Campaign work timed out",
"position": {"x": 790, "y": 320},
"config": {"reason": "Campaign work timed out"},
},
],
"edges": [
{"id": "start-prepare", "source": "start", "target": "prepare"},
{
"id": "prepare-work",
"source": "prepare",
"source_port": "success",
"target": "campaign_work",
},
{
"id": "work-completed",
"source": "campaign_work",
"source_port": "completed",
"target": "completed",
},
{
"id": "work-rejected",
"source": "campaign_work",
"source_port": "rejected",
"target": "rejected",
},
{
"id": "work-cancelled",
"source": "campaign_work",
"source_port": "cancelled",
"target": "cancelled",
},
{
"id": "work-timeout",
"source": "campaign_work",
"source_port": "timed_out",
"target": "timed_out",
},
],
"metadata": {
"notation": "govoplan.workflow.native",
"domain": "campaigns.accountable_work",
"configuration_notes": (
"Provide either campaign_id or create_campaign and explicit null "
"values for unused optional inputs."
),
},
}
__all__ = ["campaign_workflow_definitions"]
+528
View File
@@ -0,0 +1,528 @@
from __future__ import annotations
import copy
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from sqlalchemy import Column, String, Table, create_engine
from sqlalchemy.orm import Session, sessionmaker
from govoplan_campaign.backend.campaign.transfers import (
DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
build_campaign_portable_package,
canonical_sha256,
inspect_campaign_portable_package,
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignIssue,
CampaignJob,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.persistence.versions import minimal_campaign_json
from govoplan_campaign.backend.routes.transfers import (
export_campaign_package,
import_campaign_package,
preview_campaign_import,
)
from govoplan_campaign.backend.schemas import (
CampaignExportRequest,
CampaignImportApplyRequest,
CampaignImportPreviewRequest,
CampaignPortablePackageResponse,
)
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.db.base import Base
def _source() -> tuple[Campaign, CampaignVersion]:
raw_json = minimal_campaign_json(
external_id="monthly-notice",
name="Monthly notice",
description="Portable source",
)
raw_json["fields"] = [
{"name": "case_id", "type": "string"},
{"name": "private_code", "type": "password"},
]
raw_json["global_values"] = {
"office": "Permits",
"private_code": "must-not-leave-the-source",
}
raw_json["server"] = {
"mail_profile_id": "mail-profile-source",
"smtp_server_id": "smtp-source",
"smtp_credential_id": "credential-source",
}
raw_json["template"] = {
"subject": "Case {{case_id}}",
"text": "Hello",
"html": None,
}
raw_json["attachments"]["global"] = [
{"base_dir": ".", "file_filter": "notice.pdf", "required": True}
]
raw_json["entries"]["inline"] = [
{
"id": "recipient-1",
"to": [{"email": "person@example.test"}],
"fields": {
"case_id": "A-1",
"private_code": "recipient-secret",
},
"attachments": [
{"base_dir": ".", "file_filter": "A-1.pdf", "required": True}
],
}
]
campaign = Campaign(
id="campaign-source",
tenant_id="tenant-source",
external_id="monthly-notice",
name="Monthly notice",
description="Portable source",
status="completed",
settings={
"retention_days": 90,
"provider_token": "must-not-export",
},
mail_profile_policy={
"profile_id": "mail-profile-source",
"credential_id": "credential-source",
},
)
version = CampaignVersion(
id="version-source",
campaign_id=campaign.id,
version_number=4,
raw_json=raw_json,
schema_version="1.0",
workflow_state="completed",
validation_summary={"ok": True, "error_count": 0},
build_summary={"built_count": 1},
editor_state={
"review_send": {
"inspection_complete": True,
"reviewed_message_keys": ["message-1"],
"issue_decisions": [
{
"decision": "accept",
"issue_codes": ["attachment_warning"],
"issue_fingerprint": "fingerprint-1",
"message_sha256": "a" * 64,
"reason": "Verified manually",
}
],
}
},
)
return campaign, version
def _job(campaign: Campaign, version: CampaignVersion) -> CampaignJob:
return CampaignJob(
id="job-1",
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
entry_index=0,
entry_id="recipient-1",
recipient_email="person@example.test",
message_id_header="<message@example.test>",
eml_sha256="b" * 64,
build_status="built",
validation_status="ready",
queue_status="cancelled",
send_status="smtp_accepted",
postbox_status="not_requested",
print_status="not_requested",
imap_status="appended",
attempt_count=1,
delivery_provenance={"route": "mail", "storage_key": "hidden"},
)
def test_privacy_default_export_is_configuration_only_and_redacts_secrets() -> None:
campaign, version = _source()
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
module_version="0.1.24",
)
assert package["scopes"] == ["metadata", "template_config"]
assert set(package["payload"]) == {"metadata", "template_config"}
assert package["manifest"]["secrets_included"] is False
assert package["manifest"]["redactions"] == {
"deployment_credential_reference": 1,
"password_field_value": 2,
"sensitive_setting": 2,
}
template = package["payload"]["template_config"]
assert "private_code" not in template["configuration"]["global_values"]
assert "smtp_credential_id" not in template["configuration"]["server"]
assert "provider_token" not in template["campaign_settings"]
assert "credential_id" not in template["mail_profile_policy"]
serialized = CampaignPortablePackageResponse.model_validate(package).model_dump(
mode="json"
)
assert inspect_campaign_portable_package(
serialized,
selected_scopes=None,
external_id="serialized-import",
name="Serialized import",
).preview["compatible"] is True
def test_full_export_import_applies_configuration_but_never_replays_evidence() -> None:
campaign, version = _source()
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=(
"metadata",
"template_config",
"recipients",
"attachments",
"review_state",
"delivery_history",
),
jobs=(_job(campaign, version),),
issues=(
CampaignIssue(
id="issue-1",
tenant_id=campaign.tenant_id,
campaign_id=campaign.id,
campaign_version_id=version.id,
severity="warning",
code="attachment_warning",
message="Review attachment",
),
),
module_version="0.1.24",
)
inspection = inspect_campaign_portable_package(
package,
selected_scopes=None,
external_id="monthly-notice-import",
name="Imported monthly notice",
)
assert inspection.preview["compatible"] is True
assert inspection.configuration is not None
assert inspection.configuration["campaign"] == {
"id": "monthly-notice-import",
"name": "Imported monthly notice",
"description": "Portable source",
"mode": "draft",
}
assert inspection.configuration["server"] == {}
assert inspection.configuration["entries"]["inline"][0]["to"] == [
{"email": "person@example.test"}
]
assert inspection.configuration["entries"]["inline"][0]["attachments"][0][
"file_filter"
] == "A-1.pdf"
assert "private_code" not in inspection.configuration["entries"]["inline"][0][
"fields"
]
skipped_codes = {item["code"] for item in inspection.preview["will_skip"]}
assert skipped_codes == {
"deployment_bound_mail_profile",
"operational_evidence_not_replayed",
}
assert package["payload"]["review_state"]["decision_count"] == 1
assert package["payload"]["delivery_history"]["jobs"][0][
"recipient_email"
] == "person@example.test"
assert "storage_key" not in package["payload"]["delivery_history"]["jobs"][0][
"delivery_provenance"
]
def test_import_preview_reports_unselected_recipient_attachment_rules() -> None:
campaign, version = _source()
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=("metadata", "attachments", "recipients"),
module_version="0.1.24",
)
inspection = inspect_campaign_portable_package(
package,
selected_scopes=("metadata", "attachments"),
external_id="attachment-import",
name="Attachment import",
)
assert inspection.preview["compatible"] is True
skipped = {item["code"]: item for item in inspection.preview["will_skip"]}
assert skipped["recipient_scope_required"]["item_count"] == 1
assert skipped["scope_not_selected"]["scope"] == "recipients"
assert inspection.configuration is not None
assert inspection.configuration["entries"]["inline"] == []
def test_import_preview_fails_closed_when_package_is_tampered() -> None:
campaign, version = _source()
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
module_version="0.1.24",
)
tampered = copy.deepcopy(package)
tampered["payload"]["metadata"]["name"] = "Tampered"
inspection = inspect_campaign_portable_package(
tampered,
selected_scopes=None,
external_id="tampered-import",
name="Tampered",
)
assert inspection.preview["compatible"] is False
assert inspection.configuration is None
assert any("integrity checksum" in error for error in inspection.preview["errors"])
def test_import_preview_rejects_unsupported_campaign_schema_even_with_valid_checksum() -> None:
campaign, version = _source()
package = build_campaign_portable_package(
campaign=campaign,
version=version,
scopes=DEFAULT_PORTABLE_CAMPAIGN_SCOPES,
module_version="0.1.24",
)
package["source"]["campaign_schema_version"] = "2.0"
package["integrity"]["package_sha256"] = canonical_package_hash(package)
inspection = inspect_campaign_portable_package(
package,
selected_scopes=None,
external_id="future-import",
name="Future import",
)
assert inspection.preview["compatible"] is False
assert any("schema version" in error for error in inspection.preview["errors"])
def canonical_package_hash(package: dict[str, object]) -> str:
content = copy.deepcopy(package)
content.pop("integrity", None)
return canonical_sha256(content)
class _Principal:
tenant_id = "tenant-1"
api_key = None
def __init__(self, *scopes: str) -> None:
self.user = SimpleNamespace(id="user-1", display_name="Importer")
self.scopes = frozenset(scopes)
def has(self, scope: str) -> bool:
return scope in self.scopes or "tenant:*" in self.scopes
@pytest.fixture()
def route_session() -> Session:
engine = create_engine("sqlite+pysqlite:///:memory:")
access_users = Base.metadata.tables.get("access_users")
if access_users is None:
access_users = Table(
"access_users",
Base.metadata,
Column("id", String(36), primary_key=True),
)
access_groups = Base.metadata.tables.get("access_groups")
if access_groups is None:
access_groups = Table(
"access_groups",
Base.metadata,
Column("id", String(36), primary_key=True),
)
Base.metadata.create_all(
engine,
tables=[
access_users,
access_groups,
Campaign.__table__,
CampaignVersion.__table__,
CampaignShare.__table__,
CampaignJob.__table__,
CampaignIssue.__table__,
ChangeSequenceEntry.__table__,
],
)
session_factory = sessionmaker(bind=engine, class_=Session, expire_on_commit=False)
database = session_factory()
user_values = {"id": "user-1"}
if "tenant_id" in access_users.c:
user_values.update(
tenant_id="tenant-1",
account_id="account-1",
email="user-1@example.test",
)
database.execute(access_users.insert().values(**user_values))
raw_json = minimal_campaign_json(external_id="source", name="Source")
raw_json["entries"]["inline"] = [
{"id": "one", "to": [{"email": "one@example.test"}]}
]
source = Campaign(
id="source-campaign",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id="source",
name="Source",
status="draft",
current_version_id="source-version",
)
source_version = CampaignVersion(
id="source-version",
campaign_id=source.id,
version_number=1,
raw_json=raw_json,
)
database.add_all((source, source_version))
database.commit()
try:
yield database
finally:
database.close()
engine.dispose()
def test_export_route_enforces_recipient_export_scope(route_session: Session) -> None:
principal = _Principal(
"campaigns:campaign:read",
"campaigns:campaign:export",
"campaigns:recipient:read",
)
with pytest.raises(HTTPException) as denied:
export_campaign_package(
"source-campaign",
"source-version",
CampaignExportRequest(scopes=["metadata", "recipients"]),
session=route_session,
principal=principal,
)
assert denied.value.status_code == 403
assert denied.value.detail == "Missing scope: campaigns:recipient:export"
def test_export_preview_and_apply_routes_keep_matching_provenance(
route_session: Session,
) -> None:
exporter = _Principal(
"campaigns:campaign:read",
"campaigns:campaign:export",
"campaigns:recipient:read",
"campaigns:recipient:export",
)
def commit_audit(active_session: Session, *_args, **_kwargs) -> None:
active_session.commit()
with patch(
"govoplan_campaign.backend.routes.transfers.audit_from_principal",
side_effect=commit_audit,
):
package = export_campaign_package(
"source-campaign",
"source-version",
CampaignExportRequest(scopes=["metadata", "template_config", "recipients"]),
session=route_session,
principal=exporter,
)
limited_importer = _Principal(
"campaigns:campaign:create",
"campaigns:campaign:import",
)
limited_preview = preview_campaign_import(
CampaignImportPreviewRequest(package=package),
session=route_session,
principal=limited_importer,
)
assert limited_preview["compatible"] is True
assert "recipients" in limited_preview["selected_scopes"]
importer = _Principal(
"campaigns:campaign:create",
"campaigns:campaign:import",
"campaigns:recipient:write",
"campaigns:recipient:import",
)
preview = preview_campaign_import(
CampaignImportPreviewRequest(package=package),
session=route_session,
principal=importer,
)
assert preview["compatible"] is True
assert preview["destination"]["external_id"] == "source-import"
def create_import(active_session: Session, **kwargs):
raw_json = kwargs["raw_json"]
destination = Campaign(
id="imported-campaign",
tenant_id="tenant-1",
created_by_user_id="user-1",
owner_user_id="user-1",
external_id=raw_json["campaign"]["id"],
name=raw_json["campaign"]["name"],
status="draft",
current_version_id="imported-version",
)
version = CampaignVersion(
id="imported-version",
campaign_id=destination.id,
version_number=1,
raw_json=raw_json,
)
active_session.add_all((destination, version))
active_session.flush()
return destination, version
with (
patch(
"govoplan_campaign.backend.routes.transfers.create_campaign_version_from_json",
side_effect=create_import,
),
patch(
"govoplan_campaign.backend.routes.transfers.audit_from_principal",
side_effect=commit_audit,
),
patch(
"govoplan_campaign.backend.routes.transfers._write_current_version_snapshot_if_available"
),
):
response = import_campaign_package(
CampaignImportApplyRequest(
package=package,
selected_scopes=preview["selected_scopes"],
external_id=preview["destination"]["external_id"],
name=preview["destination"]["name"],
expected_package_sha256=preview["package_sha256"],
),
session=route_session,
principal=importer,
)
assert response.campaign.external_id == "source-import"
assert response.receipt["package_id"] == package["package_id"]
assert response.receipt["package_sha256"] == package["integrity"]["package_sha256"]
imported = route_session.get(Campaign, "imported-campaign")
assert imported is not None
assert imported.settings["portable_import"]["package_id"] == package["package_id"]
assert route_session.query(CampaignJob).filter_by(campaign_id=imported.id).count() == 0
+261 -2
View File
@@ -32,8 +32,14 @@ from govoplan_campaign.backend.schemas import (
CampaignWorkAssignmentReassignRequest,
CampaignWorkAssignmentTransitionRequest,
)
from govoplan_core.core.access import GroupRef, UserRef
from govoplan_campaign.backend.work_orchestration import (
SqlCampaignWorkOrchestrationProvider,
)
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import GroupRef, PrincipalRef, UserRef
from govoplan_core.core.campaigns import CampaignWorkHandoffRequest
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.events import EventBus, event_bus_context
from govoplan_core.core.organizations import OrganizationFunctionRef
from govoplan_core.core.tasks import WorkItem
from govoplan_core.db.base import Base
@@ -257,6 +263,31 @@ def _assignee() -> _Principal:
)
def _api_principal(user_id: str, account_id: str) -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id=account_id,
membership_id=user_id,
tenant_id=TENANT_ID,
scopes=frozenset(
{
"campaigns:campaign:read",
"campaigns:campaign:create",
"campaigns:assignment:read",
"campaigns:assignment:manage",
"campaigns:assignment:complete",
}
),
),
account=SimpleNamespace(id=account_id),
user=SimpleNamespace(
id=user_id,
display_name=f"User {user_id}",
email=f"{user_id}@example.test",
),
)
def _commit_audit(session: Session, *_args, **_kwargs) -> None:
session.commit()
@@ -407,10 +438,189 @@ def test_reassignment_and_deactivation_reconciliation_preserve_history(session:
assert reassigned.assignee_type == "account"
assert result.changed == 1
assert result.assignments[0].assignee_resolution_state == "unavailable"
assert [item.event_kind for item in reversed(history.items)] == ["assigned", "reassigned", "assignee_unavailable"]
assert [item.event_kind for item in reversed(history.items)] == [
"assigned",
"reassigned",
"assignee_unavailable",
]
assert history.items[1].details["assignee_id"] == "group-1"
def test_workflow_provider_is_idempotent_emits_typed_events_and_rechecks_access(
session: Session,
) -> None:
directory = _Directory()
registry = _Registry(_Tasks(), _Notifications())
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
manager = _api_principal("user-1", "account-1")
assignee = _api_principal("user-2", "account-2")
request = CampaignWorkHandoffRequest(
tenant_id=TENANT_ID,
campaign_id="campaign-1",
expected_campaign_revision=1,
idempotency_key="workflow-handoff-1",
purpose="Review the Campaign evidence",
assignee_kind="account",
assignee_id="account-2",
correlation_id="workflow-correlation-1",
workflow_instance_id="workflow-instance-1",
workflow_step_id="workflow-step-1",
)
bus = EventBus()
events = []
bus.subscribe("campaign.work.changed", events.append)
with (
patch(
"govoplan_campaign.backend.routes.assignments._access_directory",
return_value=directory,
),
patch(
"govoplan_campaign.backend.route_support._access_directory",
return_value=directory,
),
patch(
"govoplan_campaign.backend.routes.assignments.get_registry",
return_value=registry,
),
patch(
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
return_value=SimpleNamespace(id="audit-workflow-1"),
),
patch(
"govoplan_campaign.backend.routes.assignments.audit_from_principal",
side_effect=_commit_audit,
),
event_bus_context(bus),
):
created = provider.prepare_handoff(session, manager, request=request)
session.commit()
replayed = provider.prepare_handoff(session, manager, request=request)
accepted = transition_campaign_work_assignment(
"campaign-1",
created.assignment_id,
CampaignWorkAssignmentTransitionRequest(
expected_revision=1,
action="accept",
),
session,
assignee,
)
completed = transition_campaign_work_assignment(
"campaign-1",
created.assignment_id,
CampaignWorkAssignmentTransitionRequest(
expected_revision=2,
action="complete",
),
session,
assignee,
)
assert created.replayed is False
assert replayed.replayed is True
assert created.assignment_id == replayed.assignment_id
assert created.campaign_ref == "campaign:campaign-1:version:version-1:r1"
assert created.assignment_ref.endswith(":r1")
assert created.optional_capabilities == {"tasks": True, "notifications": True}
assert session.query(CampaignWorkAssignment).filter(
CampaignWorkAssignment.orchestration_idempotency_key
== "workflow-handoff-1"
).count() == 1
assert accepted.status == "in_progress"
assert completed.status == "completed"
assert [event.payload["outcome"] for event in events] == [
"assigned",
"accepted",
"completed",
]
assert [event.payload["assignment_revision"] for event in events] == [1, 2, 3]
assert all(event.correlation_id == "workflow-correlation-1" for event in events)
with patch(
"govoplan_campaign.backend.route_support._access_directory",
return_value=directory,
):
allowed = provider.inspect_handoff(
session,
assignee,
tenant_id=TENANT_ID,
assignment_id=created.assignment_id,
expected_revision=3,
)
share = session.get(CampaignShare, "share-1")
assert share is not None
share.revoked_at = completed.updated_at
session.flush()
revoked = provider.inspect_handoff(
session,
assignee,
tenant_id=TENANT_ID,
assignment_id=created.assignment_id,
expected_revision=3,
)
assert allowed.allowed is True
assert revoked.allowed is False
assert revoked.provenance["code"] == "campaign_handoff_access_revoked"
def test_workflow_provider_can_create_self_assigned_campaign_and_rejects_stale_revision(
session: Session,
) -> None:
directory = _Directory()
registry = _Registry()
provider = SqlCampaignWorkOrchestrationProvider(registry=registry)
manager = _api_principal("user-1", "account-1")
with (
patch(
"govoplan_campaign.backend.routes.assignments._access_directory",
return_value=directory,
),
patch(
"govoplan_campaign.backend.routes.assignments.get_registry",
return_value=registry,
),
patch(
"govoplan_campaign.backend.work_orchestration.audit_from_principal",
return_value=SimpleNamespace(id="audit-workflow-create"),
),
):
created = provider.prepare_handoff(
session,
manager,
request=CampaignWorkHandoffRequest(
tenant_id=TENANT_ID,
create_external_id="workflow-created",
create_name="Workflow-created Campaign",
idempotency_key="workflow-create-1",
purpose="Prepare the Campaign",
assignee_kind="account",
assignee_id="account-1",
workflow_instance_id="workflow-instance-create",
workflow_step_id="workflow-step-create",
),
)
assert session.get(Campaign, created.campaign_id).external_id == "workflow-created"
version = session.get(CampaignVersion, "version-1")
assert version is not None
version.edit_revision = 2
with pytest.raises(ValueError, match="Campaign revision changed"):
provider.prepare_handoff(
session,
manager,
request=CampaignWorkHandoffRequest(
tenant_id=TENANT_ID,
campaign_id="campaign-1",
expected_campaign_revision=1,
idempotency_key="workflow-stale-1",
purpose="Review stale Campaign",
assignee_kind="account",
assignee_id="account-1",
),
)
def test_organization_function_requires_authorized_current_incumbencies(session: Session) -> None:
with (
patch("govoplan_campaign.backend.routes.assignments._access_directory", return_value=_Directory()),
@@ -470,3 +680,52 @@ def test_assignment_migration_is_repeatable_and_creates_history_indexes() -> Non
migration.downgrade()
assert not inspect(connection).has_table("campaign_work_assignment_events")
assert not inspect(connection).has_table("campaign_work_assignments")
def test_workflow_orchestration_migration_is_repeatable() -> None:
assignments = importlib.import_module(
"govoplan_campaign.backend.migrations.versions."
"d8e9f0a1b2c3_v0121_campaign_work_assignments"
)
orchestration = importlib.import_module(
"govoplan_campaign.backend.migrations.versions."
"f3c7a9d2e6b1_v0123_campaign_work_orchestration"
)
engine = create_engine("sqlite+pysqlite:///:memory:")
with engine.begin() as connection:
connection.execute(text("CREATE TABLE access_users (id VARCHAR(36) PRIMARY KEY)"))
connection.execute(text("CREATE TABLE campaigns (id VARCHAR(36) PRIMARY KEY)"))
connection.execute(text("CREATE TABLE campaign_versions (id VARCHAR(36) PRIMARY KEY, campaign_id VARCHAR(36) NOT NULL)"))
context = MigrationContext.configure(connection)
with patch.object(assignments, "op", Operations(context)):
assignments.upgrade()
with patch.object(orchestration, "op", Operations(context)):
orchestration.upgrade()
orchestration.upgrade()
inspector = inspect(connection)
columns = {
item["name"]
for item in inspector.get_columns("campaign_work_assignments")
}
indexes = {
item["name"]
for item in inspector.get_indexes("campaign_work_assignments")
}
assert {
"orchestration_idempotency_key",
"orchestration_request_sha256",
"orchestration_correlation_id",
"workflow_instance_id",
"workflow_step_id",
}.issubset(columns)
assert "uq_campaign_work_assignment_orchestration_key" in indexes
with patch.object(orchestration, "op", Operations(context)):
orchestration.downgrade()
assert "workflow_instance_id" not in {
item["name"]
for item in inspect(connection).get_columns(
"campaign_work_assignments"
)
}
+6 -3
View File
@@ -464,7 +464,9 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.fields",
"campaign.template",
"campaign.template.content-library",
"campaigns.action.schedule-drafts",
"campaigns.action.schedule-drafts",
"campaigns.action.export-package",
"campaigns.action.import-package",
"campaign.attachments",
"campaign.attachments.reuse-policy",
"campaign.attachments.residual-files",
@@ -484,8 +486,9 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
"campaign.work",
"campaign.work.create",
"campaign.work.action.start",
"campaign.work.action.complete",
"campaign.work.action.reassign",
"campaign.work.action.complete",
"campaign.work.action.reject",
"campaign.work.action.reassign",
"campaign.work.action.cancel",
"campaign.work.history",
}
+7 -1
View File
@@ -13,6 +13,7 @@ from govoplan_campaign.backend.routes.operations import router as operations_rou
from govoplan_campaign.backend.routes.reports import router as reports_router
from govoplan_campaign.backend.routes.schedules import router as schedules_router
from govoplan_campaign.backend.routes.sharing import router as sharing_router
from govoplan_campaign.backend.routes.transfers import router as transfers_router
from govoplan_campaign.backend.routes.versions import router as versions_router
@@ -27,6 +28,7 @@ def _operation_keys(candidate_router) -> list[tuple[str, str]]:
def test_campaign_router_composes_every_workflow_operation_once() -> None:
workflow_routers = (
operations_router,
transfers_router,
campaigns_router,
assignments_router,
collaboration_router,
@@ -46,7 +48,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
actual = _operation_keys(router)
assert actual == expected
assert len(actual) == 93
assert len(actual) == 96
assert not [operation for operation, count in Counter(actual).items() if count > 1]
@@ -57,6 +59,10 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
("POST", "/campaigns/operations/artifacts/reconcile"),
),
(campaigns_router, ("GET", "/campaigns/{campaign_id}/workspace")),
(
transfers_router,
("POST", "/campaign-transfers/imports/preview"),
),
(collaboration_router, ("POST", "/campaigns/{campaign_id}/collaboration")),
(assignments_router, ("POST", "/campaigns/{campaign_id}/assignments")),
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
from govoplan_campaign.backend.manifest import get_manifest
from govoplan_campaign.backend.workflow_definitions import (
campaign_workflow_definitions,
)
from govoplan_core.core.campaigns import CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION
def test_campaign_work_handoff_is_an_opt_in_reusable_template() -> None:
contribution = campaign_workflow_definitions(module_version="0.1.23")[0]
assert contribution.origin_module_id == "campaigns"
assert contribution.definition_kind == "template"
assert contribution.activate_on_install is False
assert contribution.allow_reuse is True
assert contribution.required_capabilities == (
CAPABILITY_CAMPAIGNS_WORK_ORCHESTRATION,
)
assert contribution.policy_metadata["assignment_authorization_neutral"] is True
nodes = {
str(node["id"]): node
for node in contribution.graph["nodes"] # type: ignore[index]
}
prepare = nodes["prepare"]
handoff = nodes["campaign_work"]
assert prepare["config"]["operation"] == "campaigns.work.prepare" # type: ignore[index]
assert handoff["type"] == "workflow.external_handoff"
assert handoff["config"]["event_type"] == "campaign.work.changed" # type: ignore[index]
assert handoff["config"]["terminal_outcomes"] == { # type: ignore[index]
"completed": "completed",
"rejected": "rejected",
"cancelled": "cancelled",
}
def test_manifest_contributes_the_current_campaign_work_template() -> None:
manifest = get_manifest()
assert len(manifest.workflow_definitions) == 1
assert manifest.workflow_definitions[0].origin_module_version == manifest.version
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/campaign-webui",
"version": "0.1.21",
"version": "0.1.24",
"private": true,
"type": "module",
"main": "src/index.ts",
+91 -2
View File
@@ -82,7 +82,7 @@ export type CampaignCollaborationCreate = {
};
export type CampaignWorkAssigneeType = "account" | "group" | "organization_function";
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "cancelled";
export type CampaignWorkAssignmentStatus = "open" | "in_progress" | "completed" | "rejected" | "cancelled";
export type CampaignWorkAssignmentResolutionState = "resolved" | "unavailable" | "provider_unavailable";
export type CampaignWorkAssignment = {
@@ -234,6 +234,52 @@ export type CampaignCopyOptions = {
include_mail_profile: boolean;
};
export type CampaignTransferScope =
| "metadata"
| "template_config"
| "recipients"
| "attachments"
| "review_state"
| "delivery_history";
export type CampaignPortablePackage = {
format: "govoplan.campaign-portable";
format_version: string;
package_id: string;
exported_at: string;
source: Record<string, unknown>;
scopes: CampaignTransferScope[];
manifest: Record<string, unknown>;
payload: Record<string, unknown>;
integrity: { algorithm: string; package_sha256: string };
};
export type CampaignTransferPlanItem = {
scope: CampaignTransferScope;
code: string;
summary: string;
item_count?: number | null;
};
export type CampaignImportPreview = {
compatible: boolean;
package_id?: string | null;
package_sha256?: string | null;
format_version?: string | null;
source: Record<string, unknown>;
available_scopes: CampaignTransferScope[];
selected_scopes: CampaignTransferScope[];
destination: { external_id?: string; name?: string; status?: string };
will_create: CampaignTransferPlanItem[];
will_skip: CampaignTransferPlanItem[];
warnings: string[];
errors: string[];
};
export type CampaignImportApplyResponse = CampaignCreateResponse & {
receipt: Record<string, unknown>;
};
export type CampaignScheduleOccurrence = {
id: string;
schedule_id: string;
@@ -1339,6 +1385,49 @@ options: CampaignCopyOptions)
});
}
export async function exportCampaignPackage(
settings: ApiSettings,
campaignId: string,
versionId: string,
scopes: CampaignTransferScope[])
: Promise<CampaignPortablePackage> {
return apiFetch<CampaignPortablePackage>(settings, `/api/v1/campaigns/${campaignId}/versions/${versionId}/exports`, {
method: "POST",
body: JSON.stringify({ scopes })
});
}
export async function previewCampaignImport(
settings: ApiSettings,
payload: {
package: Record<string, unknown>;
selected_scopes?: CampaignTransferScope[] | null;
external_id?: string;
name?: string;
})
: Promise<CampaignImportPreview> {
return apiFetch<CampaignImportPreview>(settings, "/api/v1/campaign-transfers/imports/preview", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function importCampaignPackage(
settings: ApiSettings,
payload: {
package: Record<string, unknown>;
selected_scopes: CampaignTransferScope[];
external_id?: string;
name?: string;
expected_package_sha256: string;
})
: Promise<CampaignImportApplyResponse> {
return apiFetch<CampaignImportApplyResponse>(settings, "/api/v1/campaign-transfers/imports", {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function listCampaignSchedules(
settings: ApiSettings,
campaignId: string)
@@ -1976,7 +2065,7 @@ export async function transitionCampaignWorkAssignment(
settings: ApiSettings,
campaignId: string,
assignment: Pick<CampaignWorkAssignment, "id" | "resource_revision">,
action: "start" | "complete" | "cancel"
action: "accept" | "start" | "complete" | "reject" | "cancel"
): Promise<CampaignWorkAssignment> {
return apiFetch<CampaignWorkAssignment>(
settings,
@@ -1,18 +1,28 @@
import { useEffect, useState } from "react";
import { ExternalLink } from "lucide-react";
import { ExternalLink, Upload } from "lucide-react";
import { formatDateTime as formatPlatformDateTime, formatDateTimeFromDate, mergeDeltaRows } from "@govoplan/core-webui";
import { Link } from "react-router";
import type { ApiSettings } from "../../types";
import type { ApiSettings, AuthInfo } from "../../types";
import { Card } from "@govoplan/core-webui";
import { Button } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { StatusBadge } from "@govoplan/core-webui";
import { LoadingFrame } from "@govoplan/core-webui";
import { PageActionBar, PageLayout, TableActionGroup, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DismissibleAlert, PageActionBar, PageLayout, TableActionGroup, ToggleSwitch, hasScope, i18nMessage, useGuardedNavigate } from "@govoplan/core-webui";
import { DataGrid, type DataGridColumn } from "@govoplan/core-webui";
import { createNewCampaign, listCampaignsDelta, type CampaignDeltaResponse } from "../../api/campaigns";
import {
createNewCampaign,
importCampaignPackage,
listCampaignsDelta,
previewCampaignImport,
type CampaignDeltaResponse,
type CampaignImportPreview,
type CampaignTransferScope
} from "../../api/campaigns";
import type { CampaignListItem } from "../../types";
export default function CampaignListPage({ settings }: {settings: ApiSettings;}) {
export default function CampaignListPage({ settings, auth }: {settings: ApiSettings;auth: AuthInfo;}) {
const navigate = useGuardedNavigate();
const [campaigns, setCampaigns] = useState<CampaignListItem[]>([]);
const [error, setError] = useState<string>("");
@@ -20,6 +30,16 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
const [creating, setCreating] = useState(false);
const [lastLoadedAt, setLastLoadedAt] = useState<string>("");
const [campaignDeltaWatermark, setCampaignDeltaWatermark] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false);
const [importPackage, setImportPackage] = useState<Record<string, unknown> | null>(null);
const [importPreview, setImportPreview] = useState<CampaignImportPreview | null>(null);
const [importScopes, setImportScopes] = useState<CampaignTransferScope[]>([]);
const [importIdentity, setImportIdentity] = useState({ external_id: "", name: "" });
const [importPreviewStale, setImportPreviewStale] = useState(false);
const [importBusy, setImportBusy] = useState(false);
const [importError, setImportError] = useState("");
const canImport = hasScope(auth, "campaigns:campaign:import") && hasScope(auth, "campaigns:campaign:create");
const canImportRecipients = hasScope(auth, "campaigns:recipient:import") && hasScope(auth, "campaigns:recipient:write");
async function load(forcedSince: string | null | undefined = campaignDeltaWatermark) {
setLoading(true);
@@ -60,6 +80,101 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}
}
function openImport() {
setImportPackage(null);
setImportPreview(null);
setImportScopes([]);
setImportIdentity({ external_id: "", name: "" });
setImportPreviewStale(false);
setImportError("");
setImportOpen(true);
}
async function readImportFile(file: File | undefined) {
if (!file) return;
setImportBusy(true);
setImportError("");
try {
if (file.size > 25 * 1024 * 1024) throw new Error("Campaign packages larger than 25 MB must be reviewed and imported through a governed integration.");
const parsed: unknown = JSON.parse(await file.text());
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Select a JSON object exported as a portable Campaign package.");
const packageData = parsed as Record<string, unknown>;
const preview = await previewCampaignImport(settings, { package: packageData });
const allowedScopes = preview.selected_scopes.filter((scope) => scope !== "recipients" || canImportRecipients);
setImportPackage(packageData);
setImportPreview(preview);
setImportScopes(allowedScopes);
setImportIdentity({
external_id: preview.destination.external_id ?? "",
name: preview.destination.name ?? ""
});
setImportPreviewStale(allowedScopes.length !== preview.selected_scopes.length);
} catch (err) {
setImportPackage(null);
setImportPreview(null);
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setImportBusy(false);
}
}
function patchImportIdentity(key: "external_id" | "name", value: string) {
setImportIdentity((current) => ({ ...current, [key]: value }));
setImportPreviewStale(true);
}
function toggleImportScope(scope: CampaignTransferScope, checked: boolean) {
setImportScopes((current) => checked
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
: current.filter((item) => item !== scope));
setImportPreviewStale(true);
}
async function refreshImportPreview() {
if (!importPackage || importBusy || importScopes.length === 0) return;
setImportBusy(true);
setImportError("");
try {
const preview = await previewCampaignImport(settings, {
package: importPackage,
selected_scopes: importScopes,
external_id: importIdentity.external_id.trim() || undefined,
name: importIdentity.name.trim() || undefined
});
setImportPreview(preview);
setImportIdentity({
external_id: preview.destination.external_id ?? importIdentity.external_id,
name: preview.destination.name ?? importIdentity.name
});
setImportPreviewStale(false);
} catch (err) {
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setImportBusy(false);
}
}
async function applyImport() {
if (!importPackage || !importPreview?.compatible || !importPreview.package_sha256 || importPreviewStale || importBusy) return;
setImportBusy(true);
setImportError("");
try {
const created = await importCampaignPackage(settings, {
package: importPackage,
selected_scopes: importScopes,
external_id: importIdentity.external_id.trim() || undefined,
name: importIdentity.name.trim() || undefined,
expected_package_sha256: importPreview.package_sha256
});
setImportOpen(false);
navigate(`/campaigns/${created.campaign.id}`);
} catch (err) {
setImportError(err instanceof Error ? err.message : String(err));
} finally {
setImportBusy(false);
}
}
useEffect(() => {
setCampaignDeltaWatermark(null);
load(null);
@@ -141,7 +256,7 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}];
return (
return (<>
<PageLayout
archetype="collection"
mode="workspace"
@@ -154,9 +269,15 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
variant="collection"
refreshable
reloadAction={{ onReload: () => void load(null), loading }}
createAction={<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
createAction={<>
{canImport && <Button onClick={openImport} disabled={creating || importBusy}>
<Upload size={16} aria-hidden="true" />
Import package
</Button>}
<Button variant="primary" onClick={create} disabled={creating}>
{creating ? "i18n:govoplan-campaign.creating.94d7d8ee" : "i18n:govoplan-campaign.new_campaign.aaf9a8a4"}
</Button>
</>}
/>}
>
@@ -185,7 +306,75 @@ export default function CampaignListPage({ settings }: {settings: ApiSettings;})
}
</LoadingFrame>
</Card>
</PageLayout>);
</PageLayout>
<Dialog
open={importOpen}
title="Import portable Campaign package"
className="campaign-copy-dialog campaign-import-dialog"
helpContextId="campaigns.action.import-package"
closeDisabled={importBusy}
onClose={() => setImportOpen(false)}
footer={<>
<Button onClick={() => setImportOpen(false)} disabled={importBusy}>Cancel</Button>
{importPackage && <Button onClick={() => void refreshImportPreview()} disabled={importBusy || importScopes.length === 0 || !importPreviewStale}>
{importBusy ? "Checking..." : "Refresh preview"}
</Button>}
<Button
variant="primary"
onClick={() => void applyImport()}
disabled={importBusy || importPreviewStale || !importPreview?.compatible || !importPreview.package_sha256}>
{importBusy ? "Importing..." : "Create draft"}
</Button>
</>}>
<div className="campaign-copy-form">
<DismissibleAlert tone="info" resetKey="campaign-portable-import-safety">
Import always creates a new draft. Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state.
</DismissibleAlert>
<FormField label="Portable Campaign package" help="Select a .govoplan-campaign.json file. Packages are integrity-checked before any draft is created.">
<input type="file" accept="application/json,.json,.govoplan-campaign.json" disabled={importBusy} onChange={(event) => void readImportFile(event.target.files?.[0])} />
</FormField>
{importError && <div className="inline-alert is-error" role="alert">{importError}</div>}
{importPreview && <>
<div className="campaign-copy-identity">
<FormField label="Campaign name">
<input value={importIdentity.name} disabled={importBusy} onChange={(event) => patchImportIdentity("name", event.target.value)} />
</FormField>
<FormField label="Campaign ID">
<input value={importIdentity.external_id} disabled={importBusy} onChange={(event) => patchImportIdentity("external_id", event.target.value)} />
</FormField>
</div>
<div className="campaign-copy-options">
{importPreview.available_scopes.map((scope) => <div className="campaign-copy-option" key={scope}>
<div>
<strong>{transferScopeLabel(scope)}</strong>
<small>{transferScopeDescription(scope)}</small>
</div>
<ToggleSwitch
label={`Import ${transferScopeLabel(scope)}`}
checked={importScopes.includes(scope)}
disabled={importBusy || scope === "recipients" && !canImportRecipients}
onChange={(checked) => toggleImportScope(scope, checked)} />
</div>)}
</div>
{importPreviewStale && <p className="muted small-note">Identity or scope choices changed. Refresh the preview before importing.</p>}
{!importPreview.compatible && <div className="inline-alert is-error" role="alert">
<strong>This package cannot be imported.</strong>
<ul>{importPreview.errors.map((item) => <li key={item}>{item}</li>)}</ul>
</div>}
{importPreview.warnings.length > 0 && <div className="inline-alert is-warning">
<strong>Review before import</strong>
<ul>{importPreview.warnings.map((item) => <li key={item}>{item}</li>)}</ul>
</div>}
<div className="campaign-import-plan">
<ImportPlan title="Will create" items={importPreview.will_create} />
<ImportPlan title="Will skip" items={importPreview.will_skip} />
</div>
<p className="muted mono-small">Package {importPreview.package_id ?? "unknown"} · SHA-256 {importPreview.package_sha256 ?? "unavailable"}</p>
</>}
</div>
</Dialog>
</>);
}
@@ -194,6 +383,40 @@ function shortId(value: string): string {
return `${value.slice(0, 12)}${value.slice(-6)}`;
}
function ImportPlan({ title, items }: {title: string;items: CampaignImportPreview["will_create"];}) {
return <div>
<h3>{title}</h3>
{items.length === 0
? <p className="muted">Nothing.</p>
: <ul>{items.map((item) => <li key={`${item.scope}:${item.code}`}>
<strong>{transferScopeLabel(item.scope)}</strong>: {item.summary}
{typeof item.item_count === "number" ? ` (${item.item_count})` : ""}
</li>)}</ul>}
</div>;
}
function transferScopeLabel(scope: CampaignTransferScope): string {
return ({
metadata: "Metadata",
template_config: "Template and configuration",
recipients: "Recipients",
attachments: "Attachments",
review_state: "Review state",
delivery_history: "Delivery history"
} satisfies Record<CampaignTransferScope, string>)[scope];
}
function transferScopeDescription(scope: CampaignTransferScope): string {
return ({
metadata: "Identity and source description for the new draft.",
template_config: "Portable fields, templates, policies, and delivery settings.",
recipients: "Campaign-local recipient rows and import provenance.",
attachments: "Attachment rules and references; never file content.",
review_state: "Historical evidence retained in the package, never replayed.",
delivery_history: "Historical outcomes retained in the package, never replayed."
} satisfies Record<CampaignTransferScope, string>)[scope];
}
function formatDateTime(value?: string): string {
return formatPlatformDateTime(value);
}
@@ -77,7 +77,7 @@ export default function CampaignModulePage({
? <OperatorQueuePage settings={settings} auth={auth} />
: active === "reports"
? <AggregateReportsPage settings={settings} />
: <CampaignListPage settings={settings} />}
: <CampaignListPage settings={settings} auth={auth} />}
</WorkspaceLayout>
);
}
@@ -1,6 +1,6 @@
import { MetricGrid } from "@govoplan/core-webui";
import { useEffect, useMemo, useState } from "react";
import { Archive, CalendarClock, Copy, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
import { Archive, CalendarClock, Copy, Download, ExternalLink, LockKeyhole, LockOpen, Pause, Play, Trash2 } from "lucide-react";
import { Link } from "react-router";
import type { ApiSettings, AuthInfo } from "../../types";
import { FormGrid, Button } from "@govoplan/core-webui";
@@ -21,6 +21,7 @@ import {
copyCampaign,
createCampaignSchedule,
deleteCampaign,
exportCampaignPackage,
getCampaignLifecyclePolicy,
lockCampaignVersionPermanently,
lockCampaignVersionTemporarily,
@@ -32,6 +33,7 @@ import {
type CampaignScheduleCreate,
type CampaignLifecyclePolicy,
type CampaignCopyOptions,
type CampaignTransferScope,
type CampaignVersionDetail,
type CampaignVersionListItem } from
"../../api/campaigns";
@@ -49,6 +51,7 @@ import {
summaryValue } from
"./utils/campaignView";
import { buildUndefinedPlaceholders, extractTemplatePlaceholders, recipientAddressTemplateFieldOptions } from "./utils/templatePlaceholders";
import { downloadJson, safeFileStem } from "./utils/draftEditor";
const campaignModeOptions = ["draft", "test", "send"];
type LockAction = "temporary" | "unlock" | "permanent";
@@ -68,6 +71,7 @@ const defaultCopyOptions: CampaignCopyOptions = {
include_policies: true,
include_mail_profile: true
};
const defaultExportScopes: CampaignTransferScope[] = ["metadata", "template_config"];
function defaultScheduleDraft(): CampaignScheduleCreate {
const start = new Date(Date.now() + 60 * 60 * 1000);
@@ -104,6 +108,9 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
const [pendingLifecycleAction, setPendingLifecycleAction] = useState<PendingLifecycleAction>(null);
const [copyOptions, setCopyOptions] = useState<CampaignCopyOptions>(defaultCopyOptions);
const [lifecycleBusy, setLifecycleBusy] = useState(false);
const [exportDialogOpen, setExportDialogOpen] = useState(false);
const [exportScopes, setExportScopes] = useState<CampaignTransferScope[]>(defaultExportScopes);
const [exportBusy, setExportBusy] = useState(false);
const [message, setMessage] = useState("");
const [schedules, setSchedules] = useState<CampaignSchedule[]>([]);
const [scheduleDialogOpen, setScheduleDialogOpen] = useState(false);
@@ -113,11 +120,15 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
const canArchive = Boolean(campaign) && campaign?.status !== "archived" && hasScope(auth, "campaigns:campaign:archive");
const canDelete = Boolean(campaign) && campaign?.status === "draft" && hasScope(auth, "campaigns:campaign:delete");
const canCopy = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:copy");
const canExport = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:export");
const canSchedule = Boolean(data.currentVersion) && hasScope(auth, "campaigns:campaign:schedule") && hasScope(auth, "campaigns:campaign:copy");
const canAutonomousSchedule = canSchedule
&& hasScope(auth, "campaigns:campaign:queue")
&& hasScope(auth, "campaigns:campaign:send")
&& hasScope(auth, "mail:profile:use");
const canExportRecipients = hasScope(auth, "campaigns:recipient:read") && hasScope(auth, "campaigns:recipient:export");
const canExportReview = hasScope(auth, "campaigns:report:read");
const canExportDelivery = canExportRecipients && hasScope(auth, "campaigns:report:export");
function openSection(section: string, fragment = "") {
const params = new URLSearchParams();
@@ -352,6 +363,36 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
}
}
function toggleExportScope(scope: CampaignTransferScope, checked: boolean) {
setExportScopes((current) => checked
? [...current, scope].filter((item, index, rows) => rows.indexOf(item) === index)
: current.filter((item) => item !== scope));
}
async function exportPortablePackage() {
if (!campaign || !data.currentVersion || exportBusy || exportScopes.length === 0) return;
setExportBusy(true);
setError("");
try {
const portablePackage = await exportCampaignPackage(
settings,
campaign.id,
data.currentVersion.id,
exportScopes
);
downloadJson(
`${safeFileStem(campaign.external_id || campaign.name)}-v${data.currentVersion.version_number ?? 1}.govoplan-campaign.json`,
portablePackage
);
setExportDialogOpen(false);
setMessage("Portable Campaign package downloaded. Keep recipient or delivery packages in an approved location.");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setExportBusy(false);
}
}
return (
<PageLayout
archetype="editor"
@@ -364,7 +405,16 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
actions={<PageActionBar
variant="editor"
state={savingIdentity ? "saving" : identityDirty ? "dirty" : "clean"}
refreshable
reloadAction={{ onReload: () => void reload(), loading }}
primaryActions={<>
{canExport && <Button
onClick={() => setExportDialogOpen(true)}
disabled={loading || savingIdentity || identityDirty || exportBusy}
disabledReason={identityDirty ? "Save or discard overview changes before exporting." : undefined}>
<Download size={16} aria-hidden="true" />
Export package
</Button>}
{canCopy && data.currentVersion && <Button
onClick={() => void prepareLifecycleAction("copy_campaign", data.currentVersion ?? undefined)}
disabled={loading || savingIdentity || lockBusy || lifecycleBusy || identityDirty}
@@ -580,6 +630,34 @@ export default function CampaignOverviewPage({ settings, auth, campaignId }: {se
</div>
</Dialog>
<Dialog
open={exportDialogOpen}
title="Export portable Campaign package"
className="campaign-copy-dialog"
helpContextId="campaigns.action.export-package"
closeDisabled={exportBusy}
onClose={() => setExportDialogOpen(false)}
footer={<>
<Button onClick={() => setExportDialogOpen(false)} disabled={exportBusy}>Cancel</Button>
<Button variant="primary" onClick={() => void exportPortablePackage()} disabled={exportBusy || exportScopes.length === 0}>
{exportBusy ? "Preparing package..." : "Download package"}
</Button>
</>}>
<div className="campaign-copy-form">
<p className="muted small-note">
Configuration-only is the privacy-safe default. The package contains JSON and attachment references, never file content, credentials, or transport secrets.
</p>
<div className="campaign-copy-options">
<CopyOption label="Metadata" detail="Campaign identity, description, and source status." checked={exportScopes.includes("metadata")} onChange={(checked) => toggleExportScope("metadata", checked)} />
<CopyOption label="Template and configuration" detail="Fields, template, validation and delivery settings. Deployment-bound Mail credentials are excluded." checked={exportScopes.includes("template_config")} onChange={(checked) => toggleExportScope("template_config", checked)} />
<CopyOption label="Recipients" detail="Recipient rows and import provenance. This can contain personal data and needs recipient-export authority." checked={exportScopes.includes("recipients")} disabled={!canExportRecipients} onChange={(checked) => toggleExportScope("recipients", checked)} />
<CopyOption label="Attachments" detail="Global and per-recipient attachment rules. File bytes are not embedded." checked={exportScopes.includes("attachments")} onChange={(checked) => toggleExportScope("attachments", checked)} />
<CopyOption label="Review state" detail="Aggregate validation, build, issue, and review evidence. Imports retain provenance but never replay approval state." checked={exportScopes.includes("review_state")} disabled={!canExportReview} onChange={(checked) => toggleExportScope("review_state", checked)} />
<CopyOption label="Delivery history" detail="Recipient-level delivery outcomes and safe provenance. Imports never recreate sent state." checked={exportScopes.includes("delivery_history")} disabled={!canExportDelivery} onChange={(checked) => toggleExportScope("delivery_history", checked)} />
</div>
</div>
</Dialog>
<Dialog
open={scheduleDialogOpen}
title="Schedule campaign"
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { CheckCircle2, History, Play, Plus, RefreshCw, UserRoundCog } from "lucide-react";
import { useSearchParams } from "react-router";
import {
Button,
Card,
@@ -59,6 +60,8 @@ export default function CampaignWorkPage({
campaignId: string;
}) {
const workspace = useCampaignWorkspaceData(settings, campaignId);
const [searchParams] = useSearchParams();
const requestedAssignmentId = searchParams.get("assignment");
const [assignments, setAssignments] = useState<CampaignWorkAssignment[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
@@ -71,6 +74,7 @@ export default function CampaignWorkPage({
const [createOpen, setCreateOpen] = useState(false);
const [reassigning, setReassigning] = useState<CampaignWorkAssignment | null>(null);
const [cancelling, setCancelling] = useState<CampaignWorkAssignment | null>(null);
const [rejecting, setRejecting] = useState<CampaignWorkAssignment | null>(null);
const [historyFor, setHistoryFor] = useState<CampaignWorkAssignment | null>(null);
const [history, setHistory] = useState<CampaignWorkAssignmentEvent[]>([]);
const [historyCursor, setHistoryCursor] = useState<string | null>(null);
@@ -102,6 +106,13 @@ export default function CampaignWorkPage({
void loadAssignments();
}, [loadAssignments]);
useEffect(() => {
if (!requestedAssignmentId || loading) return;
document
.getElementById(`campaign-assignment-${requestedAssignmentId}`)
?.focus({ preventScroll: false });
}, [assignments, loading, requestedAssignmentId]);
function replaceAssignment(updated: CampaignWorkAssignment) {
setAssignments((current) => current.map((item) => item.id === updated.id ? updated : item));
}
@@ -175,7 +186,7 @@ export default function CampaignWorkPage({
}
}
async function transition(assignment: CampaignWorkAssignment, action: "start" | "complete" | "cancel") {
async function transition(assignment: CampaignWorkAssignment, action: "accept" | "start" | "complete" | "reject" | "cancel") {
if (busyId) return;
setBusyId(assignment.id);
setError("");
@@ -183,7 +194,15 @@ export default function CampaignWorkPage({
const updated = await transitionCampaignWorkAssignment(settings, campaignId, assignment, action);
replaceAssignment(updated);
setCancelling(null);
setMessage(`Work ${action === "start" ? "started" : action === "complete" ? "completed" : "cancelled"}.`);
setRejecting(null);
const resultLabel = {
accept: "accepted",
start: "started",
complete: "completed",
reject: "rejected",
cancel: "cancelled"
}[action];
setMessage(`Work ${resultLabel}.`);
} catch (err) {
setError(errorText(err));
} finally {
@@ -285,7 +304,12 @@ export default function CampaignWorkPage({
) : (
<ol className="campaign-work-list" aria-label="Campaign work assignments">
{assignments.map((assignment) => (
<li key={assignment.id} className="campaign-work-item">
<li
key={assignment.id}
id={`campaign-assignment-${assignment.id}`}
className={`campaign-work-item${assignment.id === requestedAssignmentId ? " is-focused" : ""}`}
tabIndex={-1}
>
<article>
<header className="campaign-work-item-header">
<div>
@@ -319,8 +343,8 @@ export default function CampaignWorkPage({
<History size={16} aria-hidden="true" /> History
</Button>
{canComplete && assignment.status === "open" ? (
<Button onClick={() => void transition(assignment, "start")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
<Play size={16} aria-hidden="true" /> Start
<Button onClick={() => void transition(assignment, "accept")} disabled={Boolean(busyId)} helpContextId="campaign.work.action.start" helpModuleId="campaign">
<Play size={16} aria-hidden="true" /> Accept
</Button>
) : null}
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
@@ -333,6 +357,13 @@ export default function CampaignWorkPage({
<UserRoundCog size={16} aria-hidden="true" /> Reassign
</Button>
) : null}
{canComplete && (assignment.status === "open" || assignment.status === "in_progress") ? (
<span className="campaign-work-destructive-action">
<Button variant="danger" onClick={() => setRejecting(assignment)} disabled={Boolean(busyId)}>
Reject work
</Button>
</span>
) : null}
{canManage && (assignment.status === "open" || assignment.status === "in_progress") ? (
<span className="campaign-work-destructive-action">
<Button variant="danger" onClick={() => setCancelling(assignment)} disabled={Boolean(busyId)} helpContextId="campaign.work.action.cancel" helpModuleId="campaign">
@@ -391,6 +422,17 @@ export default function CampaignWorkPage({
{historyHasMore ? <Button onClick={() => void loadOlderHistory()} disabled={historyLoading}>Load older history</Button> : null}
</Dialog>
<ConfirmDialog
open={Boolean(rejecting)}
title="Reject assigned work?"
message="The work will close as rejected, separately from cancellation. Its purpose, assignee and transition history remain durable evidence."
confirmLabel="Reject work"
tone="danger"
busy={Boolean(busyId)}
onCancel={() => setRejecting(null)}
onConfirm={() => rejecting ? void transition(rejecting, "reject") : undefined}
/>
<ConfirmDialog
open={Boolean(cancelling)}
title="Cancel assigned work?"
@@ -121,7 +121,7 @@ export function getText(record: Record<string, unknown>, key: string, fallback =
return fallback;
}
export function downloadJson(filename: string, data: Record<string, unknown>) {
export function downloadJson(filename: string, data: unknown) {
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
+6
View File
@@ -2726,10 +2726,15 @@
.campaign-copy-option strong, .campaign-copy-option small { display: block; }
.campaign-copy-option small { margin-top: 3px; color: var(--muted); line-height: 1.35; }
.campaign-copy-option.is-disabled { opacity: .62; }
.campaign-import-plan { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.campaign-import-plan h3 { margin: 0 0 8px; font-size: 1rem; }
.campaign-import-plan ul { margin: 0; padding-left: 20px; }
.campaign-import-plan li + li { margin-top: 6px; }
@media (max-width: 760px) {
.campaign-copy-identity { grid-template-columns: 1fr; }
.campaign-copy-option { grid-template-columns: 1fr; }
.campaign-import-plan { grid-template-columns: 1fr; }
}
.campaign-content-library-dialog { width: min(880px, calc(100vw - 32px)); }
@@ -2802,6 +2807,7 @@
.campaign-work-list,
.campaign-work-history { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
.campaign-work-item { border: var(--border-line); border-radius: var(--radius-sm); background: var(--panel-bg); }
.campaign-work-item.is-focused { box-shadow: var(--focus-ring-strong); }
.campaign-work-item article { display: grid; gap: 14px; padding: 16px; }
.campaign-work-item-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 12px; }
.campaign-work-item-header h3 { margin: 0 0 4px; font-size: var(--font-size-md); }
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
const workspace = readFileSync("src/features/campaigns/CampaignWorkspace.tsx", "utf8");
const overview = readFileSync("src/features/campaigns/CampaignOverviewPage.tsx", "utf8");
const campaignList = readFileSync("src/features/campaigns/CampaignListPage.tsx", "utf8");
const recipients = readFileSync("src/features/campaigns/RecipientDataPage.tsx", "utf8");
const fieldValueInput = readFileSync("src/features/campaigns/components/FieldValueInput.tsx", "utf8");
const mailSettings = readFileSync("src/features/campaigns/MailSettingsPage.tsx", "utf8");
@@ -29,10 +30,20 @@ assert.match(overview, /schedule\.last_outcome/);
assert.match(overview, /schedule\.last_recovery_state/);
assert.match(overview, /Unknown outcomes pause the schedule and are never retried automatically/);
assert.match(overview, /Delivery jobs, outcomes, locks, reports, and audit evidence are never copied/);
assert.match(overview, /defaultExportScopes: CampaignTransferScope\[\] = \["metadata", "template_config"\]/);
assert.match(overview, /hasScope\(auth, "campaigns:campaign:export"\)/);
assert.match(overview, /await exportCampaignPackage/);
assert.match(overview, /credentials, or transport secrets/);
assert.match(overview, /await archiveCampaignVersion\(settings, campaign\.id, pending\.version\.id, pending\.policy\.state_token\)/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/archive/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/copies/);
assert.match(api, /\/api\/v1\/campaigns\/\$\{campaignId\}\/lifecycle-policy/);
assert.match(api, /\/api\/v1\/campaign-transfers\/imports\/preview/);
assert.match(api, /expected_package_sha256/);
assert.match(campaignList, /hasScope\(auth, "campaigns:campaign:import"\)/);
assert.match(campaignList, /setImportPreviewStale\(true\)/);
assert.match(campaignList, /Historical review, approval, and delivery evidence is shown in the preview but never replayed as live state/);
assert.match(campaignList, /expected_package_sha256: importPreview\.package_sha256/);
assert.match(recipients, /requestBulkActivation\(true\)/);
assert.match(recipients, /requestBulkActivation\(false\)/);
assert.match(recipients, /const count = inlineEntries\.filter/);