docs: add adaptive Campaign composition guide

This commit is contained in:
2026-07-21 18:46:22 +02:00
parent 03100b77db
commit 99ef25b08f
3 changed files with 416 additions and 0 deletions

View 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}"

View File

@@ -28,6 +28,7 @@ from govoplan_core.core.modules import (
from govoplan_core.db.base import Base from govoplan_core.db.base import Base
from govoplan_campaign.backend.change_tracking import register_campaign_change_tracking 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.db import models as campaign_models # noqa: F401 - populate Campaign ORM metadata
from govoplan_campaign.backend.documentation import documentation_topics
register_campaign_change_tracking() register_campaign_change_tracking()
@@ -544,6 +545,7 @@ manifest = ModuleManifest(
}, },
), ),
), ),
documentation_providers=(documentation_topics,),
capability_factories={ capability_factories={
CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__( CAPABILITY_CAMPAIGNS_ACCESS: lambda context: __import__(
"govoplan_campaign.backend.capabilities", "govoplan_campaign.backend.capabilities",

158
tests/test_documentation.py Normal file
View 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()