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}"
|
||||
@@ -28,6 +28,7 @@ from govoplan_core.core.modules import (
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking
|
||||
from govoplan_campaign.backend.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
|
||||
from govoplan_campaign.backend.documentation import documentation_topics
|
||||
|
||||
register_campaign_change_tracking()
|
||||
|
||||
@@ -544,6 +545,7 @@ manifest = ModuleManifest(
|
||||
},
|
||||
),
|
||||
),
|
||||
documentation_providers=(documentation_topics,),
|
||||
capability_factories={
|
||||
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
|
||||
"govoplan_campaign.backend.capabilities",
|
||||
|
||||
158
tests/test_documentation.py
Normal file
158
tests/test_documentation.py
Normal file
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from govoplan_campaign.backend.documentation import documentation_topics
|
||||
from govoplan_campaign.backend.manifest import get_manifest
|
||||
from govoplan_core.core.modules import DocumentationContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Principal:
|
||||
scopes: frozenset[str]
|
||||
|
||||
def has(self, scope: str) -> bool:
|
||||
namespace, resource, _action = scope.split(":", 2)
|
||||
return scope in self.scopes or f"{namespace}:{resource}:*" in self.scopes or f"{namespace}:*" in self.scopes or "*:*" in self.scopes
|
||||
|
||||
|
||||
class _Registry:
|
||||
def __init__(self, integrations: set[str], *, interface_only: set[str] | None = None, capability_only: set[str] | None = None) -> None:
|
||||
interfaces = integrations | (interface_only or set())
|
||||
self._capabilities = integrations | (capability_only or set())
|
||||
self._manifests = (
|
||||
SimpleNamespace(
|
||||
provides_interfaces=tuple(SimpleNamespace(name=name) for name in sorted(interfaces)),
|
||||
),
|
||||
)
|
||||
|
||||
def manifests(self):
|
||||
return self._manifests
|
||||
|
||||
def has_capability(self, name: str) -> bool:
|
||||
return name in self._capabilities
|
||||
|
||||
|
||||
def _topics(scopes: set[str], integrations: set[str] | None = None, *, documentation_type: str = "user"):
|
||||
return documentation_topics(
|
||||
DocumentationContext(
|
||||
registry=_Registry(integrations or set()),
|
||||
principal=_Principal(frozenset(scopes)),
|
||||
documentation_type=documentation_type, # type: ignore[arg-type]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_campaign_runtime_documentation_provider_is_registered() -> None:
|
||||
assert documentation_topics in get_manifest().documentation_providers
|
||||
|
||||
|
||||
def test_runtime_documentation_is_user_only_and_requires_a_campaign_task() -> None:
|
||||
assert _topics({"docs:documentation:read"}) == ()
|
||||
assert _topics({"campaigns:campaign:read"}, documentation_type="admin") == ()
|
||||
|
||||
|
||||
def test_runtime_documentation_reflects_actor_authority_without_exposing_scopes() -> None:
|
||||
topic = _topics(
|
||||
{
|
||||
"campaigns:campaign:read",
|
||||
"campaigns:campaign:create",
|
||||
"campaigns:campaign:update",
|
||||
"campaigns:campaign:validate",
|
||||
"campaigns:campaign:build",
|
||||
"campaigns:recipient:read",
|
||||
"campaigns:recipient:write",
|
||||
"campaigns:recipient:import",
|
||||
}
|
||||
)[0]
|
||||
|
||||
configuration = topic.metadata["current_configuration"]
|
||||
assert "Create new campaigns." in configuration
|
||||
assert "Build exact recipient messages for review." in configuration
|
||||
assert not any("queue" in item.lower() for item in configuration)
|
||||
assert not any("campaigns:" in item or "files:" in item or "mail:" in item for item in configuration)
|
||||
|
||||
|
||||
def test_runtime_documentation_requires_both_interface_and_capability() -> None:
|
||||
integration = "mail.campaign_delivery"
|
||||
scopes = {"campaigns:campaign:read", "mail:profile:use"}
|
||||
|
||||
for registry in (
|
||||
_Registry(set(), interface_only={integration}),
|
||||
_Registry(set(), capability_only={integration}),
|
||||
):
|
||||
topic = documentation_topics(
|
||||
DocumentationContext(registry=registry, principal=_Principal(frozenset(scopes)), documentation_type="user")
|
||||
)[0]
|
||||
assert not any("selection is available" in item for item in topic.metadata["current_configuration"])
|
||||
|
||||
topic = _topics(scopes, {integration})[0]
|
||||
assert any("Mail profile selection is available" in item for item in topic.metadata["current_configuration"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("integrations", "scopes", "expected"),
|
||||
[
|
||||
(
|
||||
{"files.campaign_attachments"},
|
||||
{"campaigns:campaign:read", "campaigns:recipient:read", "files:file:read"},
|
||||
"Managed campaign attachments can be previewed",
|
||||
),
|
||||
(
|
||||
{"addresses.lookup"},
|
||||
{"campaigns:campaign:read", "campaigns:recipient:read"},
|
||||
"Address records can be looked up",
|
||||
),
|
||||
(
|
||||
{"addresses.recipient_source"},
|
||||
{"campaigns:campaign:read", "campaigns:recipient:import"},
|
||||
"traceable recipient snapshot",
|
||||
),
|
||||
(
|
||||
{"notifications.dispatch"},
|
||||
{"campaigns:campaign:read"},
|
||||
"status changes",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_runtime_documentation_reflects_optional_composition_permutations(
|
||||
integrations: set[str],
|
||||
scopes: set[str],
|
||||
expected: str,
|
||||
) -> None:
|
||||
topic = _topics(scopes, integrations)[0]
|
||||
assert any(expected in item for item in topic.metadata["current_configuration"])
|
||||
|
||||
|
||||
def test_runtime_documentation_full_composition_uses_only_user_facing_names() -> None:
|
||||
integrations = {
|
||||
"mail.campaign_delivery",
|
||||
"files.campaign_attachments",
|
||||
"addresses.lookup",
|
||||
"addresses.recipient_source",
|
||||
"notifications.dispatch",
|
||||
}
|
||||
topic = _topics({"*:*"}, integrations)[0]
|
||||
rendered = "\n".join(
|
||||
(
|
||||
topic.title,
|
||||
topic.summary,
|
||||
topic.body,
|
||||
*topic.metadata["current_configuration"],
|
||||
*topic.metadata["limitations"],
|
||||
)
|
||||
)
|
||||
|
||||
assert "Mail profile selection" in rendered
|
||||
assert "Managed file versions" in rendered
|
||||
assert "Address records" in rendered
|
||||
assert "In-app notifications" in rendered
|
||||
for technical_name in integrations:
|
||||
assert technical_name not in rendered
|
||||
assert "0.1." not in rendered
|
||||
assert "0.2." not in rendered
|
||||
assert "hostname" not in rendered.lower()
|
||||
assert "secret" not in rendered.lower()
|
||||
Reference in New Issue
Block a user