feat: contribute governed campaign reports
This commit is contained in:
@@ -16,12 +16,17 @@ This repository owns:
|
|||||||
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
|
- campaign JSON schema, validation, message building, attachment resolution, ZIP handling, reports, queue/control services, and mock-send paths
|
||||||
- WebUI package `@govoplan/campaign-webui`
|
- WebUI package `@govoplan/campaign-webui`
|
||||||
- route contributions for `/campaigns`, the integrated `/campaigns/queue` view,
|
- route contributions for `/campaigns`, the integrated `/campaigns/queue` view,
|
||||||
`/campaigns/:campaignId/*`, `/reports`, and `/templates`
|
`/campaigns/reports`, `/campaigns/:campaignId/*`, and `/templates`
|
||||||
|
|
||||||
Core owns the auth facade, RBAC/capability contracts, database/session
|
Core owns the auth facade, RBAC/capability contracts, database/session
|
||||||
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
primitives, CSRF/API helpers, shell layout, and route rendering. Tenancy is an
|
||||||
optional platform module for tenant administration and tenant resolver behavior.
|
optional platform module for tenant administration and tenant resolver behavior.
|
||||||
Files and mail own their respective storage and transport capabilities.
|
Files and mail own their respective storage and transport capabilities.
|
||||||
|
When the optional Reporting module is enabled, Campaign contributes its
|
||||||
|
recipient-free aggregate delivery report through the versioned Core report
|
||||||
|
provider contract. Reporting owns the global `/reports` route. Campaign keeps
|
||||||
|
its module-local `/campaigns/reports` view and does not claim the global route
|
||||||
|
when Reporting is absent.
|
||||||
|
|
||||||
Generated EML is durable execution material, not a node-local runtime cache.
|
Generated EML is durable execution material, not a node-local runtime cache.
|
||||||
Campaign stores it through Core's shared object-storage contract under opaque
|
Campaign stores it through Core's shared object-storage contract under opaque
|
||||||
|
|||||||
@@ -167,6 +167,15 @@ every action control that the actor lacks. The server authorizes each action,
|
|||||||
but permission-aware action visibility on that detailed surface remains open
|
but permission-aware action visibility on that detailed surface remains open
|
||||||
work; do not confuse it with the aggregate reader experience.
|
work; do not confuse it with the aggregate reader experience.
|
||||||
|
|
||||||
|
The module-local aggregate surface remains at `/campaigns/reports`. When the
|
||||||
|
optional Reporting module is enabled, Campaign also contributes the same
|
||||||
|
recipient-free projection as the `campaigns/delivery-outcomes` report provider.
|
||||||
|
Reporting owns `/reports`, records the run purpose, effective audience, source
|
||||||
|
campaign/version revision, privacy transformations, retention, actor/time,
|
||||||
|
output hash, and export history, and applies Policy before returning the
|
||||||
|
result. Campaign does not register a fallback `/reports` route when Reporting
|
||||||
|
is absent.
|
||||||
|
|
||||||
The detailed Campaign Report includes the selected campaign's effective
|
The detailed Campaign Report includes the selected campaign's effective
|
||||||
retention policy, its system/tenant/owner/campaign provenance, and the current
|
retention policy, its system/tenant/owner/campaign provenance, and the current
|
||||||
evidence state. It distinguishes retained, redacted, expired, partially
|
evidence state. It distinguishes retained, redacted, expired, partially
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from govoplan_core.core.module_guards import (
|
|||||||
)
|
)
|
||||||
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
from govoplan_core.core.ownership import OwnershipProviderRegistration
|
||||||
from govoplan_core.core.modules import (
|
from govoplan_core.core.modules import (
|
||||||
|
CapabilityDocumentation,
|
||||||
DocumentationCondition,
|
DocumentationCondition,
|
||||||
DocumentationLink,
|
DocumentationLink,
|
||||||
DocumentationTopic,
|
DocumentationTopic,
|
||||||
@@ -34,6 +35,7 @@ from govoplan_core.core.modules import (
|
|||||||
PermissionDefinition,
|
PermissionDefinition,
|
||||||
RoleTemplate,
|
RoleTemplate,
|
||||||
)
|
)
|
||||||
|
from govoplan_core.core.reporting import REPORT_PROVIDER_CAPABILITY_PREFIX
|
||||||
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
from govoplan_core.core.operations import OperationalCheckProviderRegistration
|
||||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||||
from govoplan_core.core.views import ViewSurface
|
from govoplan_core.core.views import ViewSurface
|
||||||
@@ -363,6 +365,7 @@ manifest = ModuleManifest(
|
|||||||
"addresses",
|
"addresses",
|
||||||
"postbox",
|
"postbox",
|
||||||
"approvals",
|
"approvals",
|
||||||
|
"reporting",
|
||||||
),
|
),
|
||||||
provides_interfaces=(
|
provides_interfaces=(
|
||||||
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.access", version="0.1.6"),
|
||||||
@@ -370,6 +373,10 @@ manifest = ModuleManifest(
|
|||||||
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
ModuleInterfaceProvider(name="campaigns.mail_policy_context", version="0.1.6"),
|
||||||
ModuleInterfaceProvider(name="campaigns.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.retention", version="0.1.6"),
|
||||||
|
ModuleInterfaceProvider(
|
||||||
|
name=REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns",
|
||||||
|
version="1.0.0",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
requires_interfaces=(
|
requires_interfaces=(
|
||||||
ModuleInterfaceRequirement(
|
ModuleInterfaceRequirement(
|
||||||
@@ -1068,6 +1075,22 @@ manifest = ModuleManifest(
|
|||||||
"govoplan_campaign.backend.capabilities",
|
"govoplan_campaign.backend.capabilities",
|
||||||
fromlist=["retention_capability"],
|
fromlist=["retention_capability"],
|
||||||
).retention_capability(context),
|
).retention_capability(context),
|
||||||
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": lambda context: __import__(
|
||||||
|
"govoplan_campaign.backend.reports.provider",
|
||||||
|
fromlist=["CampaignAggregateReportProvider"],
|
||||||
|
).CampaignAggregateReportProvider(),
|
||||||
|
},
|
||||||
|
capability_documentation={
|
||||||
|
REPORT_PROVIDER_CAPABILITY_PREFIX + "campaigns": CapabilityDocumentation(
|
||||||
|
label="Campaign aggregate report provider",
|
||||||
|
summary=(
|
||||||
|
"Publishes recipient-free campaign delivery outcomes with "
|
||||||
|
"small-cell and complementary suppression."
|
||||||
|
),
|
||||||
|
contract_version="1.0",
|
||||||
|
documentation_types=("admin", "user"),
|
||||||
|
audience=("user", "reporting_analyst", "privacy_officer"),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
operational_check_providers=(
|
operational_check_providers=(
|
||||||
OperationalCheckProviderRegistration(
|
OperationalCheckProviderRegistration(
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Cross-module provider for Campaign's recipient-free aggregate report."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from govoplan_campaign.backend.db.models import Campaign
|
||||||
|
from govoplan_campaign.backend.report_privacy_policy import (
|
||||||
|
effective_campaign_report_privacy_policy,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.reports.aggregate import (
|
||||||
|
generate_aggregate_campaign_report,
|
||||||
|
)
|
||||||
|
from govoplan_campaign.backend.route_support import (
|
||||||
|
_campaign_query_for_principal,
|
||||||
|
_get_campaign_for_principal,
|
||||||
|
)
|
||||||
|
from govoplan_core.auth import has_scope
|
||||||
|
from govoplan_core.core.reporting import (
|
||||||
|
REPORT_PROVIDER_CONTRACT_VERSION,
|
||||||
|
ReportDescriptor,
|
||||||
|
ReportParameterDescriptor,
|
||||||
|
ReportParameterOption,
|
||||||
|
ReportPrivacyTransform,
|
||||||
|
ReportProviderRequest,
|
||||||
|
ReportProviderResult,
|
||||||
|
ReportResultField,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
CAMPAIGN_AGGREGATE_REPORT_ID = "delivery-outcomes"
|
||||||
|
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS = (
|
||||||
|
"server_side_aggregation",
|
||||||
|
"small_cell_suppression",
|
||||||
|
"complementary_suppression",
|
||||||
|
"explicit_denominator",
|
||||||
|
"recipient_payload_exclusion",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CampaignAggregateReportProvider:
|
||||||
|
provider_id = "campaigns"
|
||||||
|
contract_version = REPORT_PROVIDER_CONTRACT_VERSION
|
||||||
|
|
||||||
|
def list_reports(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
) -> tuple[ReportDescriptor, ...]:
|
||||||
|
del session
|
||||||
|
if not has_scope(principal, "campaigns:report:read"):
|
||||||
|
return ()
|
||||||
|
return (_descriptor(),)
|
||||||
|
|
||||||
|
def parameter_options(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
report_id: str,
|
||||||
|
parameter_key: str,
|
||||||
|
query: str,
|
||||||
|
limit: int,
|
||||||
|
) -> tuple[ReportParameterOption, ...]:
|
||||||
|
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or parameter_key != "campaign_id":
|
||||||
|
return ()
|
||||||
|
if not has_scope(principal, "campaigns:report:read"):
|
||||||
|
return ()
|
||||||
|
sql_session = _session(session)
|
||||||
|
rows = _campaign_query_for_principal(sql_session, principal)
|
||||||
|
clean_query = query.strip().casefold()
|
||||||
|
campaigns = (
|
||||||
|
rows.order_by(Campaign.updated_at.desc(), Campaign.id.asc())
|
||||||
|
.limit(max(1, min(limit, 200)) if not clean_query else 500)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if clean_query:
|
||||||
|
campaigns = [
|
||||||
|
campaign
|
||||||
|
for campaign in campaigns
|
||||||
|
if clean_query in campaign.name.casefold()
|
||||||
|
][: max(1, min(limit, 200))]
|
||||||
|
return tuple(
|
||||||
|
ReportParameterOption(
|
||||||
|
value=campaign.id,
|
||||||
|
label=campaign.name,
|
||||||
|
description=campaign.status,
|
||||||
|
)
|
||||||
|
for campaign in campaigns
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute_report(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
request: ReportProviderRequest,
|
||||||
|
) -> ReportProviderResult:
|
||||||
|
if request.report_id != CAMPAIGN_AGGREGATE_REPORT_ID:
|
||||||
|
raise LookupError("Campaign report provider does not know this report")
|
||||||
|
if not has_scope(principal, "campaigns:report:read"):
|
||||||
|
raise PermissionError("Missing scope: campaigns:report:read")
|
||||||
|
campaign_id = str(request.parameters.get("campaign_id") or "").strip()
|
||||||
|
if not campaign_id:
|
||||||
|
raise ValueError("campaign_id is required")
|
||||||
|
version_id = str(request.parameters.get("version_id") or "").strip() or None
|
||||||
|
sql_session = _session(session)
|
||||||
|
campaign = _get_campaign_for_principal(sql_session, campaign_id, principal)
|
||||||
|
report = generate_aggregate_campaign_report(
|
||||||
|
sql_session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
campaign_id=campaign.id,
|
||||||
|
version_id=version_id,
|
||||||
|
)
|
||||||
|
policy = effective_campaign_report_privacy_policy(
|
||||||
|
sql_session,
|
||||||
|
tenant_id=principal.tenant_id,
|
||||||
|
)
|
||||||
|
selected_version_id = version_id or campaign.current_version_id
|
||||||
|
return ReportProviderResult(
|
||||||
|
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
||||||
|
generated_at=report.generated_at,
|
||||||
|
payload=report.model_dump(mode="json"),
|
||||||
|
source_revisions=(
|
||||||
|
{
|
||||||
|
"module_id": "campaigns",
|
||||||
|
"resource_type": "campaign",
|
||||||
|
"resource_id": campaign.id,
|
||||||
|
"revision_type": "campaign_version",
|
||||||
|
"revision_id": selected_version_id,
|
||||||
|
"version_number": report.version_number,
|
||||||
|
"updated_at": campaign.updated_at.isoformat(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
effective_scope={
|
||||||
|
"tenant_id": principal.tenant_id,
|
||||||
|
"campaign_id": campaign.id,
|
||||||
|
"campaign_version_id": selected_version_id,
|
||||||
|
"audience": dict(request.audience_scope),
|
||||||
|
},
|
||||||
|
applied_privacy_transforms=CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
||||||
|
provenance={
|
||||||
|
"provider": "campaigns",
|
||||||
|
"projection": "recipient-free-delivery-outcomes-v1",
|
||||||
|
"privacy_policy": policy.as_dict(),
|
||||||
|
"purpose": request.purpose,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def authorize_result(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
principal: object,
|
||||||
|
*,
|
||||||
|
report_id: str,
|
||||||
|
source_revisions: tuple[Mapping[str, object], ...],
|
||||||
|
effective_scope: Mapping[str, object],
|
||||||
|
) -> bool:
|
||||||
|
del source_revisions
|
||||||
|
if report_id != CAMPAIGN_AGGREGATE_REPORT_ID or not has_scope(
|
||||||
|
principal, "campaigns:report:read"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
campaign_id = str(effective_scope.get("campaign_id") or "").strip()
|
||||||
|
tenant_id = str(effective_scope.get("tenant_id") or "").strip()
|
||||||
|
if not campaign_id or tenant_id != str(getattr(principal, "tenant_id", "")):
|
||||||
|
return False
|
||||||
|
return (
|
||||||
|
_campaign_query_for_principal(_session(session), principal)
|
||||||
|
.filter(Campaign.id == campaign_id)
|
||||||
|
.first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor() -> ReportDescriptor:
|
||||||
|
fields = (
|
||||||
|
("generated_at", "Generated", "datetime", "Campaign"),
|
||||||
|
("campaign.id", "Campaign ID", "string", "Campaign"),
|
||||||
|
("campaign.name", "Campaign", "string", "Campaign"),
|
||||||
|
("campaign.status", "Status", "string", "Campaign"),
|
||||||
|
("version_number", "Version", "integer", "Campaign"),
|
||||||
|
("completion_state", "Completion", "string", "Campaign"),
|
||||||
|
(
|
||||||
|
"population.denominator",
|
||||||
|
"Report denominator",
|
||||||
|
"suppressed_count",
|
||||||
|
"Population",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"population.denominator_definition",
|
||||||
|
"Denominator definition",
|
||||||
|
"string",
|
||||||
|
"Population",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"population.inactive_source_entries",
|
||||||
|
"Inactive source entries",
|
||||||
|
"suppressed_count",
|
||||||
|
"Population",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"population.excluded_or_blocked_jobs",
|
||||||
|
"Excluded or blocked jobs",
|
||||||
|
"suppressed_count",
|
||||||
|
"Population",
|
||||||
|
),
|
||||||
|
("outcomes.smtp_accepted", "SMTP accepted", "suppressed_count", "Outcomes"),
|
||||||
|
(
|
||||||
|
"outcomes.postbox_accepted",
|
||||||
|
"Postbox accepted",
|
||||||
|
"suppressed_count",
|
||||||
|
"Outcomes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"outcomes.delivered",
|
||||||
|
"Both channels accepted",
|
||||||
|
"suppressed_count",
|
||||||
|
"Outcomes",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"outcomes.partially_accepted",
|
||||||
|
"Partially accepted",
|
||||||
|
"suppressed_count",
|
||||||
|
"Outcomes",
|
||||||
|
),
|
||||||
|
("outcomes.failed", "Failed", "suppressed_count", "Outcomes"),
|
||||||
|
("outcomes.outcome_unknown", "Outcome unknown", "suppressed_count", "Outcomes"),
|
||||||
|
(
|
||||||
|
"outcomes.queued_or_active",
|
||||||
|
"Queued or active",
|
||||||
|
"suppressed_count",
|
||||||
|
"Outcomes",
|
||||||
|
),
|
||||||
|
("outcomes.not_attempted", "Not attempted", "suppressed_count", "Outcomes"),
|
||||||
|
("outcomes.cancelled", "Cancelled", "suppressed_count", "Outcomes"),
|
||||||
|
("outcomes.excluded", "Excluded", "suppressed_count", "Outcomes"),
|
||||||
|
("time_range.first_activity_at", "First activity", "datetime", "Activity"),
|
||||||
|
("time_range.last_activity_at", "Last activity", "datetime", "Activity"),
|
||||||
|
("time_range.suppressed", "Activity range suppressed", "boolean", "Activity"),
|
||||||
|
("privacy.small_cell_threshold", "Small-cell threshold", "integer", "Privacy"),
|
||||||
|
("privacy.suppression_applied", "Suppression applied", "boolean", "Privacy"),
|
||||||
|
("privacy.rule", "Privacy rule", "string", "Privacy"),
|
||||||
|
)
|
||||||
|
return ReportDescriptor(
|
||||||
|
provider_id="campaigns",
|
||||||
|
report_id=CAMPAIGN_AGGREGATE_REPORT_ID,
|
||||||
|
revision="campaign.aggregate.v1",
|
||||||
|
title="Campaign delivery outcomes",
|
||||||
|
summary=(
|
||||||
|
"Privacy-protected delivery outcomes without recipient-level records."
|
||||||
|
),
|
||||||
|
parameters=(
|
||||||
|
ReportParameterDescriptor(
|
||||||
|
key="campaign_id",
|
||||||
|
label="Campaign",
|
||||||
|
type="reference",
|
||||||
|
required=True,
|
||||||
|
options_from_provider=True,
|
||||||
|
),
|
||||||
|
ReportParameterDescriptor(
|
||||||
|
key="version_id",
|
||||||
|
label="Campaign version",
|
||||||
|
type="string",
|
||||||
|
required=False,
|
||||||
|
description="Leave empty to use the current campaign version.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
result_schema=tuple(
|
||||||
|
ReportResultField(
|
||||||
|
path=path,
|
||||||
|
label=label,
|
||||||
|
type=field_type, # type: ignore[arg-type]
|
||||||
|
group=group,
|
||||||
|
nullable=path.startswith("time_range.") or path == "version_number",
|
||||||
|
)
|
||||||
|
for path, label, field_type, group in fields
|
||||||
|
),
|
||||||
|
privacy_transforms=tuple(
|
||||||
|
ReportPrivacyTransform(id=item, label=item.replace("_", " ").title())
|
||||||
|
for item in CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
||||||
|
),
|
||||||
|
retention_class="stored_report_detail",
|
||||||
|
export_formats=("json",),
|
||||||
|
reidentification_risk="low",
|
||||||
|
presentation={"kind": "metric_summary"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _session(value: object) -> Session:
|
||||||
|
if not isinstance(value, Session):
|
||||||
|
raise TypeError("Campaign report provider requires a SQLAlchemy Session")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CAMPAIGN_AGGREGATE_REPORT_ID",
|
||||||
|
"CAMPAIGN_REPORT_PRIVACY_TRANSFORMS",
|
||||||
|
"CampaignAggregateReportProvider",
|
||||||
|
]
|
||||||
@@ -18,6 +18,11 @@ from govoplan_campaign.backend.reports.aggregate import (
|
|||||||
AggregateCampaignReportError,
|
AggregateCampaignReportError,
|
||||||
generate_aggregate_campaign_report,
|
generate_aggregate_campaign_report,
|
||||||
)
|
)
|
||||||
|
from govoplan_campaign.backend.reports.provider import (
|
||||||
|
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS,
|
||||||
|
CampaignAggregateReportProvider,
|
||||||
|
)
|
||||||
|
from govoplan_core.core.reporting import ReportProviderRequest
|
||||||
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
from govoplan_campaign.backend.schemas import ReportEmailRequest
|
||||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||||
from govoplan_core.db.base import Base
|
from govoplan_core.db.base import Base
|
||||||
@@ -40,7 +45,9 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|||||||
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
campaign = SimpleNamespace(id="campaign-1", tenant_id="tenant-1")
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(
|
||||||
|
report_routes, "_get_campaign_for_principal", return_value=campaign
|
||||||
|
),
|
||||||
pytest.raises(HTTPException) as full_report_denied,
|
pytest.raises(HTTPException) as full_report_denied,
|
||||||
):
|
):
|
||||||
report_routes.campaign_report(
|
report_routes.campaign_report(
|
||||||
@@ -64,7 +71,9 @@ def test_full_report_and_job_detail_reject_aggregate_only_principal() -> None:
|
|||||||
assert job_detail_denied.value.status_code == 403
|
assert job_detail_denied.value.status_code == 403
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch.object(report_routes, "_get_campaign_for_principal", return_value=campaign),
|
patch.object(
|
||||||
|
report_routes, "_get_campaign_for_principal", return_value=campaign
|
||||||
|
),
|
||||||
pytest.raises(HTTPException) as report_email_denied,
|
pytest.raises(HTTPException) as report_email_denied,
|
||||||
):
|
):
|
||||||
report_routes.email_campaign_report(
|
report_routes.email_campaign_report(
|
||||||
@@ -119,10 +128,15 @@ def test_aggregate_route_uses_only_the_safe_projection() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("path", ["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"])
|
@pytest.mark.parametrize(
|
||||||
|
"path",
|
||||||
|
["/campaigns/aggregate-reports", "/campaigns/aggregate-reports/{campaign_id}"],
|
||||||
|
)
|
||||||
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
def test_aggregate_routes_require_report_read_permission(path: str) -> None:
|
||||||
route = next(item for item in campaign_api.router.routes if item.path == path)
|
route = next(item for item in campaign_api.router.routes if item.path == path)
|
||||||
dependency = next(item for item in route.dependant.dependencies if item.name == "principal")
|
dependency = next(
|
||||||
|
item for item in route.dependant.dependencies if item.name == "principal"
|
||||||
|
)
|
||||||
|
|
||||||
with pytest.raises(HTTPException) as denied:
|
with pytest.raises(HTTPException) as denied:
|
||||||
dependency.call(_Principal())
|
dependency.call(_Principal())
|
||||||
@@ -148,7 +162,9 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
session.add(Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={}))
|
session.add(
|
||||||
|
Tenant(id="tenant-1", slug="tenant-1", name="Tenant 1", settings={})
|
||||||
|
)
|
||||||
campaign = Campaign(
|
campaign = Campaign(
|
||||||
id="campaign-1",
|
id="campaign-1",
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -168,20 +184,22 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
|||||||
campaign.current_version_id = version.id
|
campaign.current_version_id = version.id
|
||||||
session.add_all([campaign, version])
|
session.add_all([campaign, version])
|
||||||
for index in range(5):
|
for index in range(5):
|
||||||
session.add(CampaignJob(
|
session.add(
|
||||||
id=f"job-{index}",
|
CampaignJob(
|
||||||
tenant_id="tenant-1",
|
id=f"job-{index}",
|
||||||
campaign_id=campaign.id,
|
tenant_id="tenant-1",
|
||||||
campaign_version_id=version.id,
|
campaign_id=campaign.id,
|
||||||
entry_index=index,
|
campaign_version_id=version.id,
|
||||||
recipient_email=f"private-{index}@example.test",
|
entry_index=index,
|
||||||
subject="Private",
|
recipient_email=f"private-{index}@example.test",
|
||||||
build_status="built",
|
subject="Private",
|
||||||
validation_status="ready",
|
build_status="built",
|
||||||
queue_status="queued",
|
validation_status="ready",
|
||||||
send_status="smtp_accepted",
|
queue_status="queued",
|
||||||
imap_status="not_requested",
|
send_status="smtp_accepted",
|
||||||
))
|
imap_status="not_requested",
|
||||||
|
)
|
||||||
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
report = generate_aggregate_campaign_report(
|
report = generate_aggregate_campaign_report(
|
||||||
@@ -192,6 +210,30 @@ def test_aggregate_projection_is_tenant_isolated_and_needs_no_optional_module()
|
|||||||
assert report.population.denominator.value == 5
|
assert report.population.denominator.value == 5
|
||||||
assert report.outcomes.smtp_accepted.value == 5
|
assert report.outcomes.smtp_accepted.value == 5
|
||||||
|
|
||||||
|
provider = CampaignAggregateReportProvider()
|
||||||
|
provider_principal = _Principal(
|
||||||
|
"campaigns:report:read",
|
||||||
|
"tenant:*",
|
||||||
|
)
|
||||||
|
descriptors = provider.list_reports(session, provider_principal)
|
||||||
|
assert descriptors[0].report_id == "delivery-outcomes"
|
||||||
|
assert descriptors[0].reidentification_risk == "low"
|
||||||
|
provided = provider.execute_report(
|
||||||
|
session,
|
||||||
|
provider_principal,
|
||||||
|
request=ReportProviderRequest(
|
||||||
|
report_id="delivery-outcomes",
|
||||||
|
parameters={"campaign_id": campaign.id},
|
||||||
|
purpose="Tenant delivery overview",
|
||||||
|
audience_scope={"scope_type": "tenant", "scope_id": "tenant-1"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert set(provided.applied_privacy_transforms) == set(
|
||||||
|
CAMPAIGN_REPORT_PRIVACY_TRANSFORMS
|
||||||
|
)
|
||||||
|
assert "recipient_email" not in repr(provided.payload)
|
||||||
|
assert provided.source_revisions[0]["revision_id"] == version.id
|
||||||
|
|
||||||
with pytest.raises(AggregateCampaignReportError):
|
with pytest.raises(AggregateCampaignReportError):
|
||||||
generate_aggregate_campaign_report(
|
generate_aggregate_campaign_report(
|
||||||
session,
|
session,
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ const reportRead = ["campaigns:report:read"];
|
|||||||
const campaignModuleRead = [...campaignRead, ...reportRead];
|
const campaignModuleRead = [...campaignRead, ...reportRead];
|
||||||
// Preserve the former /operator route identity for saved View projections.
|
// Preserve the former /operator route identity for saved View projections.
|
||||||
const operatorQueueSurface = "campaigns.route.operator";
|
const operatorQueueSurface = "campaigns.route.operator";
|
||||||
// Preserve the former /reports route identity for saved View projections.
|
|
||||||
const reportsSurface = "campaigns.route.reports";
|
const reportsSurface = "campaigns.route.reports";
|
||||||
const translations = {
|
const translations = {
|
||||||
en: generatedTranslations.en,
|
en: generatedTranslations.en,
|
||||||
@@ -99,7 +98,6 @@ export const campaignModule: PlatformWebModule = {
|
|||||||
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) },
|
{ path: "/campaigns", anyOf: campaignModuleRead, order: 20, render: ({ settings, auth }) => createElement(CampaignModuleLandingRoute, { settings, auth }) },
|
||||||
{ path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) },
|
{ path: "/operator", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: () => createElement(Navigate, { to: "/campaigns/queue", replace: true }) },
|
||||||
{ path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) },
|
{ path: "/campaigns/queue", anyOf: OPERATOR_QUEUE_ROUTE_SCOPES, allOf: campaignRead, order: 21, surfaceId: operatorQueueSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "queue", settings, auth }) },
|
||||||
{ path: "/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: () => createElement(Navigate, { to: "/campaigns/reports", replace: true }) },
|
|
||||||
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
{ path: "/campaigns/reports", anyOf: reportRead, order: 22, surfaceId: reportsSurface, render: ({ settings, auth }) => createElement(CampaignModulePage, { active: "reports", settings, auth }) },
|
||||||
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
{ path: "/campaigns/:campaignId/*", anyOf: campaignRead, order: 22, render: ({ settings, auth }) => createElement(CampaignResourceRoute, { settings, auth }) },
|
||||||
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
|
{ path: "/templates", order: 90, render: () => createElement(TemplatesPage) }],
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ assert.match(campaignModulePage, /active === "reports"[\s\S]*<AggregateReportsPa
|
|||||||
assert.match(moduleSource, /path: "\/campaigns\/queue"/);
|
assert.match(moduleSource, /path: "\/campaigns\/queue"/);
|
||||||
assert.match(moduleSource, /path: "\/operator"[\s\S]*createElement\(Navigate, \{ to: "\/campaigns\/queue", replace: true \}\)/);
|
assert.match(moduleSource, /path: "\/operator"[\s\S]*createElement\(Navigate, \{ to: "\/campaigns\/queue", replace: true \}\)/);
|
||||||
assert.match(moduleSource, /path: "\/campaigns\/reports"/);
|
assert.match(moduleSource, /path: "\/campaigns\/reports"/);
|
||||||
assert.match(moduleSource, /path: "\/reports"[\s\S]*createElement\(Navigate, \{ to: "\/campaigns\/reports", replace: true \}\)/);
|
assert.doesNotMatch(moduleSource, /path: "\/reports"/);
|
||||||
assert.doesNotMatch(moduleSource, /to: "\/operator"/);
|
assert.doesNotMatch(moduleSource, /to: "\/operator"/);
|
||||||
assert.doesNotMatch(moduleSource, /to: "\/reports"/);
|
assert.doesNotMatch(moduleSource, /to: "\/reports"/);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -89,14 +89,10 @@ assert.equal(
|
|||||||
2,
|
2,
|
||||||
"the legacy redirect and canonical queue route share one configurable view surface"
|
"the legacy redirect and canonical queue route share one configurable view surface"
|
||||||
);
|
);
|
||||||
assert.ok(
|
|
||||||
moduleSource.indexOf('{ path: "/reports"') < moduleSource.indexOf('{ path: "/campaigns/reports"'),
|
|
||||||
"the canonical Campaign reports route must replace the legacy alias in the shared view-surface catalogue"
|
|
||||||
);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
moduleSource.split("surfaceId: reportsSurface").length - 1,
|
moduleSource.split("surfaceId: reportsSurface").length - 1,
|
||||||
2,
|
1,
|
||||||
"the legacy redirect and canonical reports route share one configurable view surface"
|
"Campaign contributes only its module-owned report route; Reporting owns /reports"
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
moduleSource.split("anyOf: campaignModuleRead").length - 1,
|
moduleSource.split("anyOf: campaignModuleRead").length - 1,
|
||||||
|
|||||||
Reference in New Issue
Block a user