docs: add adaptive Campaign composition guide
This commit is contained in:
256
src/govoplan_campaign/backend/documentation.py
Normal file
256
src/govoplan_campaign/backend/documentation.py
Normal file
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from govoplan_core.core.modules import DocumentationCondition, DocumentationContext, DocumentationLink, DocumentationTopic
|
||||
|
||||
|
||||
_CAMPAIGN_USER_SCOPES = (
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:copy",
|
||||
"campaigns:campaign:archive",
|
||||
"campaigns:campaign:delete",
|
||||
"campaigns:campaign:share",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:campaign:review",
|
||||
"campaigns:campaign:send_test",
|
||||
"campaigns:campaign:queue",
|
||||
"campaigns:campaign:control",
|
||||
"campaigns:campaign:send",
|
||||
"campaigns:campaign:retry",
|
||||
"campaigns:campaign:reconcile",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
"campaigns:recipient:export",
|
||||
"campaigns:report:read",
|
||||
"campaigns:report:export",
|
||||
)
|
||||
|
||||
_CAMPAIGN_HELP_CONTEXTS = (
|
||||
"campaigns.list",
|
||||
"campaign.overview",
|
||||
"campaign.settings",
|
||||
"campaign.fields",
|
||||
"campaign.template",
|
||||
"campaign.attachments",
|
||||
"campaign.recipients",
|
||||
"campaign.recipient-data",
|
||||
"campaign.server-settings",
|
||||
"campaign.global-settings",
|
||||
"campaign.review-send",
|
||||
"campaign.report",
|
||||
"campaign.audit",
|
||||
"campaign.json",
|
||||
)
|
||||
|
||||
_FILES_INTEGRATION = "files.campaign_attachments"
|
||||
_MAIL_INTEGRATION = "mail.campaign_delivery"
|
||||
_ADDRESSES_LOOKUP_INTEGRATION = "addresses.lookup"
|
||||
_ADDRESSES_SOURCE_INTEGRATION = "addresses.recipient_source"
|
||||
_NOTIFICATIONS_INTEGRATION = "notifications.dispatch"
|
||||
|
||||
|
||||
def documentation_topics(context: DocumentationContext) -> tuple[DocumentationTopic, ...]:
|
||||
"""Describe the current Campaign composition without resolving module data.
|
||||
|
||||
The provider deliberately uses only the actor's permission evaluator and the
|
||||
registry's declared contracts. Resource access, policy decisions, and
|
||||
campaign state remain authoritative at the point where an action is used.
|
||||
"""
|
||||
|
||||
if context.documentation_type != "user":
|
||||
return ()
|
||||
principal = context.principal
|
||||
if principal is None or not _has_any_scope(principal, _CAMPAIGN_USER_SCOPES):
|
||||
return ()
|
||||
|
||||
current_configuration = list(_actor_capabilities(principal))
|
||||
integration_configuration, integration_limitations = _integration_summary(context.registry, principal)
|
||||
current_configuration.extend(integration_configuration)
|
||||
limitations = [
|
||||
"Access to a particular campaign still depends on its owner or sharing rules and on the campaign's current state.",
|
||||
*integration_limitations,
|
||||
]
|
||||
|
||||
return (
|
||||
DocumentationTopic(
|
||||
id="campaigns.current-composition",
|
||||
title="Campaign capabilities available to you",
|
||||
summary="This guide reflects your current Campaign actions and the connected services available in this installation.",
|
||||
body=_composition_body(current_configuration, limitations),
|
||||
layer="configured",
|
||||
documentation_types=("user",),
|
||||
audience=("campaign_user",),
|
||||
order=29,
|
||||
conditions=(
|
||||
DocumentationCondition(
|
||||
required_modules=("campaigns",),
|
||||
any_scopes=_CAMPAIGN_USER_SCOPES,
|
||||
),
|
||||
),
|
||||
links=(DocumentationLink(label="Campaigns", href="/campaigns", kind="runtime"),),
|
||||
related_modules=("mail", "files", "addresses", "notifications"),
|
||||
source_module_id="campaigns",
|
||||
metadata={
|
||||
"kind": "reference",
|
||||
"route": "/campaigns",
|
||||
"screen": "Campaigns",
|
||||
"help_contexts": list(_CAMPAIGN_HELP_CONTEXTS),
|
||||
"current_configuration": current_configuration,
|
||||
"limitations": limitations,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _actor_capabilities(principal: object) -> tuple[str, ...]:
|
||||
capabilities: list[str] = []
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:read",), "Open campaigns shared with you and inspect their business state.")
|
||||
_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: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.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:validate",), "Validate campaign inputs and resolve blocking issues.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:build",), "Build exact recipient messages for review.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:review",), "Record review completion for an exact build.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send_test",), "Run authorized delivery verification tools.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:queue",), "Queue an eligible reviewed campaign for controlled delivery.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:control",), "Pause, resume, or cancel eligible delivery jobs.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:send",), "Start an eligible real delivery run.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:retry",), "Retry delivery jobs whose recorded state permits a retry.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:reconcile",), "Reconcile delivery effects whose outcome is uncertain.")
|
||||
_append_if(capabilities, principal, ("campaigns:report:read",), "View authorized campaign delivery reports.")
|
||||
_append_if(
|
||||
capabilities,
|
||||
principal,
|
||||
("campaigns:report:export", "campaigns:recipient:export"),
|
||||
"Export authorized delivery and recipient report data.",
|
||||
require_all=True,
|
||||
)
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:share",), "Manage explicit access to eligible campaigns.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:archive",), "Archive eligible campaigns while retaining their evidence.")
|
||||
_append_if(capabilities, principal, ("campaigns:campaign:delete",), "Delete eligible untouched drafts.")
|
||||
return tuple(capabilities)
|
||||
|
||||
|
||||
def _integration_summary(registry: object, principal: object) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
configured: list[str] = []
|
||||
limitations: list[str] = []
|
||||
|
||||
mail_available = _integration_available(registry, _MAIL_INTEGRATION)
|
||||
if mail_available and _has_scope(principal, "mail:profile:use"):
|
||||
configured.append("Mail profile selection is available for Campaign delivery steps you are authorized to perform.")
|
||||
elif mail_available:
|
||||
limitations.append("Mail delivery is configured, but selecting a Mail profile is not included in your current tasks.")
|
||||
else:
|
||||
limitations.append("Mail-backed Campaign delivery is not available in the current composition.")
|
||||
|
||||
files_available = _integration_available(registry, _FILES_INTEGRATION)
|
||||
can_preview_files = _has_all_scopes(
|
||||
principal,
|
||||
("campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"),
|
||||
)
|
||||
can_link_files = _has_all_scopes(
|
||||
principal,
|
||||
("campaigns:campaign:validate", "campaigns:recipient:read", "files:file:read", "files:file:share"),
|
||||
)
|
||||
if files_available and can_link_files:
|
||||
configured.append("Managed file versions can be selected, previewed, and linked as governed campaign attachments.")
|
||||
elif files_available and can_preview_files:
|
||||
configured.append("Managed campaign attachments can be previewed; linking a version requires separate attachment-sharing authority.")
|
||||
elif files_available:
|
||||
limitations.append("Managed attachments are configured, but they are not included in your current Campaign tasks.")
|
||||
else:
|
||||
limitations.append("Managed file attachments are not available in the current composition; campaign-owned uploads remain separate.")
|
||||
|
||||
lookup_available = _integration_available(registry, _ADDRESSES_LOOKUP_INTEGRATION)
|
||||
source_available = _integration_available(registry, _ADDRESSES_SOURCE_INTEGRATION)
|
||||
if lookup_available and _has_scope(principal, "campaigns:recipient:read"):
|
||||
configured.append("Address records can be looked up while preparing recipients.")
|
||||
elif lookup_available:
|
||||
limitations.append("Address lookup is configured, but recipient inspection is not included in your current Campaign tasks.")
|
||||
else:
|
||||
limitations.append("Connected address lookup is not available in the current composition.")
|
||||
if source_available and _has_scope(principal, "campaigns:recipient:import"):
|
||||
configured.append("An authorized address source can be copied into a campaign as a traceable recipient snapshot.")
|
||||
elif source_available:
|
||||
limitations.append("Connected recipient sources are configured, but source import is not included in your current Campaign tasks.")
|
||||
else:
|
||||
limitations.append("Connected recipient-source snapshots are not available; direct campaign imports remain available when authorized.")
|
||||
|
||||
if _integration_available(registry, _NOTIFICATIONS_INTEGRATION):
|
||||
configured.append("In-app notifications can report important Campaign delivery status changes.")
|
||||
else:
|
||||
limitations.append("Automatic in-app Campaign status notifications are not configured.")
|
||||
|
||||
return tuple(configured), tuple(limitations)
|
||||
|
||||
|
||||
def _append_if(
|
||||
target: list[str],
|
||||
principal: object,
|
||||
scopes: tuple[str, ...],
|
||||
text: str,
|
||||
*,
|
||||
require_all: bool = False,
|
||||
) -> None:
|
||||
allowed = _has_all_scopes(principal, scopes) if require_all else _has_any_scope(principal, scopes)
|
||||
if allowed:
|
||||
target.append(text)
|
||||
|
||||
|
||||
def _has_scope(principal: object, scope: str) -> bool:
|
||||
checker = getattr(principal, "has", None)
|
||||
if not callable(checker):
|
||||
return False
|
||||
try:
|
||||
return bool(checker(scope))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_any_scope(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||
return any(_has_scope(principal, scope) for scope in scopes)
|
||||
|
||||
|
||||
def _has_all_scopes(principal: object, scopes: tuple[str, ...]) -> bool:
|
||||
return all(_has_scope(principal, scope) for scope in scopes)
|
||||
|
||||
|
||||
def _integration_available(registry: object, interface_name: str) -> bool:
|
||||
return _registry_has_interface(registry, interface_name) and _registry_has_capability(registry, interface_name)
|
||||
|
||||
|
||||
def _registry_has_interface(registry: object, interface_name: str) -> bool:
|
||||
manifests_provider = getattr(registry, "manifests", None)
|
||||
if not callable(manifests_provider):
|
||||
return False
|
||||
try:
|
||||
manifests = tuple(manifests_provider())
|
||||
except Exception:
|
||||
return False
|
||||
return any(
|
||||
getattr(provider, "name", None) == interface_name
|
||||
for manifest in manifests
|
||||
for provider in (getattr(manifest, "provides_interfaces", ()) or ())
|
||||
)
|
||||
|
||||
|
||||
def _registry_has_capability(registry: object, capability_name: str) -> bool:
|
||||
capability_checker = getattr(registry, "has_capability", None)
|
||||
if not callable(capability_checker):
|
||||
return False
|
||||
try:
|
||||
return bool(capability_checker(capability_name))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _composition_body(current_configuration: list[str], limitations: list[str]) -> str:
|
||||
configured_lines = "\n".join(f"- {item}" for item in current_configuration)
|
||||
limitation_lines = "\n".join(f"- {item}" for item in limitations)
|
||||
return f"Available in your current Campaign workspace:\n{configured_lines}\n\nLimits that still apply:\n{limitation_lines}"
|
||||
Reference in New Issue
Block a user