feat: add campaign copying scheduling and residual handling
This commit is contained in:
@@ -240,6 +240,7 @@ def test_process_loss_orphan_is_deleted_once_and_same_request_replays(
|
||||
def test_competing_node_cannot_acquire_cleanup_authority(
|
||||
recovery_session_factory,
|
||||
) -> None:
|
||||
lease_observed_at = datetime.now(timezone.utc)
|
||||
with recovery_session_factory() as session:
|
||||
claim = acquire_lease(
|
||||
session,
|
||||
@@ -248,7 +249,7 @@ def test_competing_node_cannot_acquire_cleanup_authority(
|
||||
holder_node_id="node-other",
|
||||
holder_incarnation="run-other",
|
||||
ttl_seconds=900,
|
||||
now=NOW,
|
||||
now=lease_observed_at,
|
||||
)
|
||||
assert claim is not None
|
||||
session.commit()
|
||||
|
||||
@@ -275,6 +275,92 @@ class CampaignAttachmentBuildTests(unittest.TestCase):
|
||||
self.assertEqual(archive.namelist(), ["matched.xlsx"])
|
||||
self.assertEqual(archive.read("matched.xlsx"), b"matched workbook")
|
||||
|
||||
def test_residual_files_become_a_separate_reviewed_report_or_attachment_message(self) -> None:
|
||||
for mode, expected_attachment_count in (("report", 0), ("attach", 1)):
|
||||
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
watched = root / "watched"
|
||||
watched.mkdir()
|
||||
(watched / "assigned.txt").write_text("assigned", encoding="utf-8")
|
||||
(watched / "residual.txt").write_text("residual", encoding="utf-8")
|
||||
campaign_file = root / "campaign.json"
|
||||
campaign_file.write_text("{}", encoding="utf-8")
|
||||
config = CampaignConfig.model_validate({
|
||||
"version": "1.0",
|
||||
"campaign": {"id": f"residual-{mode}", "name": "Monthly import", "mode": "test"},
|
||||
"fields": [],
|
||||
"global_values": {},
|
||||
"server": {
|
||||
"mail_profile_id": "profile-1",
|
||||
"profile_capabilities": {"smtp_available": True},
|
||||
},
|
||||
"recipients": {
|
||||
"from": {"email": "sender@example.org", "type": "to"},
|
||||
"allow_individual_to": True,
|
||||
},
|
||||
"template": {"subject": "Normal message", "text": "Normal body"},
|
||||
"attachments": {
|
||||
"base_paths": [{
|
||||
"id": "watched",
|
||||
"name": "Watched folder",
|
||||
"path": "watched",
|
||||
"unsent_warning": True,
|
||||
}],
|
||||
"global": [{
|
||||
"id": "assigned",
|
||||
"base_path_id": "watched",
|
||||
"base_dir": "watched",
|
||||
"file_filter": "assigned.txt",
|
||||
"required": True,
|
||||
}],
|
||||
"residual_files": {
|
||||
"mode": mode,
|
||||
"recipient": {"email": "operator@example.org", "name": "Operator"},
|
||||
"subject": "Residual files for {{local:campaign_name}}",
|
||||
"text": "{{local:residual_file_count}} file(s):\n{{local:residual_file_list}}",
|
||||
},
|
||||
},
|
||||
"entries": {"inline": [{
|
||||
"id": "recipient-1",
|
||||
"to": [{"email": "recipient@example.org", "type": "to"}],
|
||||
}]},
|
||||
"validation_policy": {
|
||||
"missing_email": "block",
|
||||
"template_error": "block",
|
||||
"unsent_attachment_files": "block",
|
||||
},
|
||||
"delivery": {"imap_append_sent": {"enabled": False}},
|
||||
})
|
||||
|
||||
result = build_campaign_messages(
|
||||
config,
|
||||
campaign_file=campaign_file,
|
||||
output_dir=root / "out",
|
||||
write_eml=True,
|
||||
)
|
||||
|
||||
self.assertEqual(len(result.report.messages), 2)
|
||||
normal, residual = result.report.messages
|
||||
self.assertEqual(normal.validation_status.value, "ready")
|
||||
self.assertEqual(residual.entry_id, "__residual_files__")
|
||||
self.assertEqual(residual.validation_status.value, "needs_review")
|
||||
self.assertEqual(residual.to[0].email, "operator@example.org")
|
||||
self.assertEqual(residual.subject, "Residual files for Monthly import")
|
||||
self.assertEqual(residual.attachment_count, expected_attachment_count)
|
||||
self.assertIn(
|
||||
"residual_attachment_disposition",
|
||||
{issue.code for issue in residual.issues},
|
||||
)
|
||||
self.assertNotIn(
|
||||
"unsent_attachment_files",
|
||||
{issue.code for message in result.report.messages for issue in message.issues},
|
||||
)
|
||||
mime = result.built_messages[1].mime
|
||||
self.assertIsNotNone(mime)
|
||||
self.assertIn("residual.txt", mime.get_body(preferencelist=("plain",)).get_content())
|
||||
filenames = [part.get_filename() for part in mime.iter_attachments()]
|
||||
self.assertEqual(filenames, ["residual.txt"] if mode == "attach" else [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from govoplan_campaign.backend.integrations import TemplatesCampaignIntegration
|
||||
from govoplan_campaign.backend.routes.campaigns import _campaign_content_library_item
|
||||
from govoplan_campaign.backend.schemas import CampaignContentLibrarySaveRequest
|
||||
from govoplan_core.core.templates import (
|
||||
TemplateContentDraftRequest,
|
||||
TemplateRef,
|
||||
TemplateRevisionRef,
|
||||
)
|
||||
|
||||
|
||||
class _Catalog:
|
||||
def __init__(self, template: TemplateRef) -> None:
|
||||
self.template = template
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def list_templates(self, session, principal, **kwargs):
|
||||
del session, principal
|
||||
self.calls.append(kwargs)
|
||||
return (self.template,)
|
||||
|
||||
def get_template(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
return self.template
|
||||
|
||||
def check_compatibility(self, session, principal, **kwargs):
|
||||
del session, principal, kwargs
|
||||
raise AssertionError("Compatibility is not needed for raw reusable content.")
|
||||
|
||||
|
||||
class _ContentLibrary:
|
||||
def __init__(self, template: TemplateRef) -> None:
|
||||
self.template = template
|
||||
self.request: TemplateContentDraftRequest | None = None
|
||||
|
||||
def create_content_draft(self, session, principal, *, request):
|
||||
del session, principal
|
||||
self.request = request
|
||||
return self.template
|
||||
|
||||
|
||||
def _template_ref() -> TemplateRef:
|
||||
revision = TemplateRevisionRef(
|
||||
id="revision-1",
|
||||
template_id="template-1",
|
||||
revision=3,
|
||||
definition_hash="a" * 64,
|
||||
template_type="content_fragment",
|
||||
usages=("campaign.content",),
|
||||
locale="de",
|
||||
required_fields=(),
|
||||
output_profiles=(),
|
||||
content_text="Mit freundlichen Grussen",
|
||||
metadata={
|
||||
"campaign_kind": "fragment",
|
||||
"campaign_targets": ["subject"],
|
||||
},
|
||||
published_at=datetime.now(tz=UTC),
|
||||
)
|
||||
return TemplateRef(
|
||||
id="template-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Closing",
|
||||
slug="closing",
|
||||
template_type="content_fragment",
|
||||
status="published",
|
||||
current_revision=3,
|
||||
current_revision_id=revision.id,
|
||||
published_revision_id=revision.id,
|
||||
revision=revision,
|
||||
)
|
||||
|
||||
|
||||
def test_campaign_content_integration_filters_usage_and_delegates_draft_creation() -> None:
|
||||
template = _template_ref()
|
||||
catalog = _Catalog(template)
|
||||
writer = _ContentLibrary(template)
|
||||
integration = TemplatesCampaignIntegration(catalog, None, writer)
|
||||
|
||||
listed = integration.list_content_templates(object(), object(), query="close")
|
||||
request = TemplateContentDraftRequest(
|
||||
name="Closing",
|
||||
template_type="content_fragment",
|
||||
usages=("campaign.content",),
|
||||
content_text="Regards",
|
||||
)
|
||||
created = integration.create_content_draft(
|
||||
object(),
|
||||
object(),
|
||||
request=request,
|
||||
)
|
||||
|
||||
assert listed == (template,)
|
||||
assert catalog.calls == [
|
||||
{"query": "close", "usage": "campaign.content", "limit": 100}
|
||||
]
|
||||
assert writer.request is request
|
||||
assert created is template
|
||||
|
||||
|
||||
def test_campaign_content_payload_keeps_revision_and_declared_target() -> None:
|
||||
payload = _campaign_content_library_item(_template_ref())
|
||||
|
||||
assert payload["kind"] == "fragment"
|
||||
assert payload["targets"] == ["subject"]
|
||||
assert payload["text"] == "Mit freundlichen Grussen"
|
||||
assert payload["published"] is True
|
||||
assert payload["revision"] == 3
|
||||
|
||||
|
||||
def test_content_save_request_rejects_empty_selected_fragment() -> None:
|
||||
try:
|
||||
CampaignContentLibrarySaveRequest(
|
||||
name="Empty",
|
||||
kind="fragment",
|
||||
target="html",
|
||||
text="Only text",
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "selected fragment field is empty" in str(exc)
|
||||
else:
|
||||
raise AssertionError("Expected empty target validation to fail")
|
||||
@@ -13,10 +13,12 @@ from govoplan_campaign.backend.campaign.lifecycle import campaign_lifecycle_poli
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignJob,
|
||||
CampaignSchedule,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_campaign.backend.routes.campaigns import (
|
||||
_campaign_copy_configuration,
|
||||
archive_campaign_version,
|
||||
copy_campaign,
|
||||
delete_draft_campaign,
|
||||
@@ -26,6 +28,7 @@ from govoplan_campaign.backend.schemas import (
|
||||
CampaignLifecycleMutationRequest,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
|
||||
|
||||
class _Principal:
|
||||
@@ -65,6 +68,8 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignJob.__table__,
|
||||
CampaignSchedule.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
@@ -73,7 +78,14 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
expire_on_commit=False,
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
session.execute(access_users.insert().values(id="user-1"))
|
||||
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",
|
||||
)
|
||||
session.execute(access_users.insert().values(**user_values))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
@@ -153,6 +165,50 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
self.assertFalse(policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertIn("Active or uncertain", policy["actions"]["archive_campaign"]["reason"])
|
||||
|
||||
def test_schedule_evidence_blocks_destructive_lifecycle_actions(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
session.add(
|
||||
CampaignSchedule(
|
||||
id="schedule-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id="campaign-1",
|
||||
source_version_id="version-2",
|
||||
created_by_user_id="user-1",
|
||||
name="Recurring draft",
|
||||
recurrence_kind="daily",
|
||||
interval_count=1,
|
||||
timezone="UTC",
|
||||
starts_at=datetime(2026, 8, 8, tzinfo=UTC),
|
||||
next_fire_at=datetime(2026, 8, 8, tzinfo=UTC),
|
||||
max_occurrences=2,
|
||||
copy_options={},
|
||||
source_snapshot={"schema": "test"},
|
||||
source_snapshot_hash="a" * 64,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
active_policy = self._policy(session)
|
||||
self.assertFalse(active_policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertIn(
|
||||
"Pause active",
|
||||
active_policy["actions"]["archive_campaign"]["reason"],
|
||||
)
|
||||
self.assertFalse(active_policy["actions"]["delete_campaign"]["allowed"])
|
||||
self.assertIn(
|
||||
"schedule evidence",
|
||||
active_policy["actions"]["delete_campaign"]["reason"],
|
||||
)
|
||||
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.active = False
|
||||
schedule.resource_revision += 1
|
||||
session.commit()
|
||||
paused_policy = self._policy(session)
|
||||
self.assertTrue(paused_policy["actions"]["archive_campaign"]["allowed"])
|
||||
self.assertFalse(paused_policy["actions"]["delete_campaign"]["allowed"])
|
||||
|
||||
def test_stale_delete_token_is_rejected(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
policy = self._policy(session)
|
||||
@@ -203,6 +259,10 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
|
||||
def test_whole_campaign_copy_starts_without_operational_evidence(self) -> None:
|
||||
with self.SessionLocal() as session:
|
||||
source_campaign = session.get(Campaign, "campaign-1")
|
||||
assert source_campaign is not None
|
||||
source_campaign.settings = {"retention": "source-policy"}
|
||||
source_campaign.mail_profile_policy = {"profile_id": "mail-profile-1"}
|
||||
session.add_all(
|
||||
(
|
||||
CampaignShare(
|
||||
@@ -269,6 +329,8 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
CampaignCopyRequest(
|
||||
source_version_id="version-2",
|
||||
expected_state_token=policy["state_token"],
|
||||
include_policies=False,
|
||||
include_mail_profile=True,
|
||||
),
|
||||
session=session,
|
||||
principal=self.principal,
|
||||
@@ -277,6 +339,13 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
self.assertEqual(response.campaign.external_id, "campaign-1-copy")
|
||||
self.assertEqual(response.campaign.owner_user_id, "user-1")
|
||||
self.assertEqual(captured["raw_json"]["campaign"]["mode"], "draft")
|
||||
copied_campaign = session.get(Campaign, "campaign-copy")
|
||||
assert copied_campaign is not None
|
||||
self.assertEqual({}, copied_campaign.settings)
|
||||
self.assertEqual(
|
||||
{"profile_id": "mail-profile-1"},
|
||||
copied_campaign.mail_profile_policy,
|
||||
)
|
||||
self.assertEqual(
|
||||
session.query(CampaignJob)
|
||||
.filter(CampaignJob.campaign_id == "campaign-copy")
|
||||
@@ -290,6 +359,64 @@ class CampaignLifecycleTests(unittest.TestCase):
|
||||
0,
|
||||
)
|
||||
|
||||
def test_copy_choices_reset_only_selected_configuration_domains(self) -> None:
|
||||
source = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "campaign-1", "name": "Campaign"},
|
||||
"fields": [{"name": "case_id"}],
|
||||
"global_values": {"sender": "Office"},
|
||||
"recipients": {"to": [{"email": "team@example.test"}]},
|
||||
"entries": {
|
||||
"inline": [
|
||||
{
|
||||
"id": "one",
|
||||
"to": [{"email": "person@example.test"}],
|
||||
"attachments": [{"base_dir": ".", "file_filter": "one.pdf"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
"template": {"subject": "Hello", "text": "Body"},
|
||||
"attachments": {
|
||||
"global": [{"base_dir": ".", "file_filter": "global.pdf"}]
|
||||
},
|
||||
"validation_policy": {"missing_required_attachment": "warn"},
|
||||
"server": {"mail_profile_id": "profile-1"},
|
||||
"delivery": {"rate_limit": {"messages_per_minute": 20}},
|
||||
}
|
||||
|
||||
copied = _campaign_copy_configuration(
|
||||
source,
|
||||
CampaignCopyRequest(
|
||||
source_version_id="version-2",
|
||||
expected_state_token="a" * 64,
|
||||
include_recipients=True,
|
||||
include_files=False,
|
||||
include_policies=False,
|
||||
include_mail_profile=False,
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(source["attachments"]["global"][0]["file_filter"], "global.pdf")
|
||||
self.assertEqual({}, copied["attachments"])
|
||||
self.assertEqual([], copied["entries"]["inline"][0]["attachments"])
|
||||
self.assertEqual("person@example.test", copied["entries"]["inline"][0]["to"][0]["email"])
|
||||
self.assertEqual({}, copied["validation_policy"])
|
||||
self.assertEqual({}, copied["server"])
|
||||
self.assertEqual(20, copied["delivery"]["rate_limit"]["messages_per_minute"])
|
||||
|
||||
def test_copy_without_recipient_permission_is_allowed_when_recipient_data_is_excluded(self) -> None:
|
||||
principal = _Principal("campaigns:campaign:read", "campaigns:campaign:copy")
|
||||
with self.SessionLocal() as session:
|
||||
campaign = session.get(Campaign, "campaign-1")
|
||||
assert campaign is not None
|
||||
policy = campaign_lifecycle_policy(
|
||||
session,
|
||||
campaign=campaign,
|
||||
principal=principal,
|
||||
version_id="version-2",
|
||||
)
|
||||
self.assertTrue(policy["actions"]["copy_campaign"]["allowed"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from sqlalchemy import Column, String, Table, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from govoplan_campaign.backend.campaign.scheduling import (
|
||||
campaign_schedule_source_snapshot,
|
||||
canonical_configuration_hash,
|
||||
dispatch_due_campaign_schedules,
|
||||
next_schedule_fire,
|
||||
)
|
||||
from govoplan_campaign.backend.db.models import (
|
||||
Campaign,
|
||||
CampaignSchedule,
|
||||
CampaignScheduleOccurrence,
|
||||
CampaignShare,
|
||||
CampaignVersion,
|
||||
)
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
|
||||
|
||||
def _access_table(name: str) -> Table:
|
||||
existing = Base.metadata.tables.get(name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
return Table(
|
||||
name,
|
||||
Base.metadata,
|
||||
Column("id", String(36), primary_key=True),
|
||||
)
|
||||
|
||||
|
||||
def _create_generated_campaign(session: Session, **kwargs):
|
||||
raw = kwargs["raw_json"]
|
||||
metadata = raw["campaign"]
|
||||
campaign = Campaign(
|
||||
tenant_id=kwargs["tenant_id"],
|
||||
created_by_user_id=kwargs["user_id"],
|
||||
owner_user_id=kwargs["user_id"],
|
||||
external_id=metadata["id"],
|
||||
name=metadata["name"],
|
||||
status="draft",
|
||||
)
|
||||
session.add(campaign)
|
||||
session.flush()
|
||||
version = CampaignVersion(
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
raw_json=raw,
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
campaign.current_version_id = version.id
|
||||
return campaign, version
|
||||
|
||||
|
||||
class TestCampaignScheduling:
|
||||
def setup_method(self):
|
||||
self.engine = create_engine("sqlite+pysqlite:///:memory:")
|
||||
users = _access_table("access_users")
|
||||
groups = _access_table("access_groups")
|
||||
Base.metadata.create_all(
|
||||
self.engine,
|
||||
tables=[
|
||||
users,
|
||||
groups,
|
||||
Campaign.__table__,
|
||||
CampaignVersion.__table__,
|
||||
CampaignShare.__table__,
|
||||
CampaignSchedule.__table__,
|
||||
CampaignScheduleOccurrence.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
],
|
||||
)
|
||||
self.SessionLocal = sessionmaker(
|
||||
bind=self.engine,
|
||||
class_=Session,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
configuration = {
|
||||
"version": "1.0",
|
||||
"campaign": {"id": "source", "name": "Monthly notice"},
|
||||
}
|
||||
snapshot = campaign_schedule_source_snapshot(
|
||||
configuration=configuration,
|
||||
campaign_settings={"retention": "sealed"},
|
||||
mail_profile_policy={"profile_id": "profile-1"},
|
||||
shares=[],
|
||||
)
|
||||
with self.SessionLocal() as session:
|
||||
user_values = {"id": "user-1"}
|
||||
if "tenant_id" in users.c:
|
||||
user_values.update(
|
||||
tenant_id="tenant-1",
|
||||
account_id="account-1",
|
||||
email="user-1@example.test",
|
||||
)
|
||||
session.execute(users.insert().values(**user_values))
|
||||
campaign = Campaign(
|
||||
id="campaign-1",
|
||||
tenant_id="tenant-1",
|
||||
created_by_user_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
external_id="source",
|
||||
name="Monthly notice",
|
||||
status="sent",
|
||||
current_version_id="version-1",
|
||||
)
|
||||
version = CampaignVersion(
|
||||
id="version-1",
|
||||
campaign_id=campaign.id,
|
||||
version_number=1,
|
||||
workflow_state="completed",
|
||||
raw_json=configuration,
|
||||
)
|
||||
schedule = CampaignSchedule(
|
||||
id="schedule-1",
|
||||
tenant_id="tenant-1",
|
||||
campaign_id=campaign.id,
|
||||
source_version_id=version.id,
|
||||
created_by_user_id="user-1",
|
||||
name="Monthly notice",
|
||||
recurrence_kind="daily",
|
||||
interval_count=1,
|
||||
timezone="Europe/Berlin",
|
||||
starts_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
next_fire_at=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
max_occurrences=2,
|
||||
copy_options={
|
||||
"include_recipients": True,
|
||||
"include_files": True,
|
||||
"include_shares": False,
|
||||
"include_policies": True,
|
||||
"include_mail_profile": True,
|
||||
},
|
||||
source_snapshot=snapshot,
|
||||
source_snapshot_hash=canonical_configuration_hash(snapshot),
|
||||
)
|
||||
session.add_all((campaign, version, schedule))
|
||||
session.commit()
|
||||
|
||||
def teardown_method(self):
|
||||
self.engine.dispose()
|
||||
|
||||
def test_due_occurrences_prepare_distinct_drafts_and_complete_bound(self):
|
||||
with self.SessionLocal() as session, patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
|
||||
side_effect=lambda *args, **kwargs: _create_generated_campaign(session, **kwargs),
|
||||
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||
first = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
assert first["prepared"] == 1
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
assert schedule.active is True
|
||||
assert schedule.occurrence_count == 1
|
||||
assert schedule.next_fire_at is not None
|
||||
|
||||
second = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 8, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
assert second["prepared"] == 1
|
||||
assert schedule.active is False
|
||||
assert schedule.next_fire_at is None
|
||||
occurrences = session.query(CampaignScheduleOccurrence).all()
|
||||
assert len(occurrences) == 2
|
||||
assert len({item.generated_campaign_id for item in occurrences}) == 2
|
||||
assert session.get(Campaign, "campaign-1").status == "sent"
|
||||
|
||||
def test_snapshot_integrity_failure_pauses_schedule_for_operator(self):
|
||||
with self.SessionLocal() as session:
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
schedule.source_snapshot["configuration"]["campaign"]["name"] = "Tampered"
|
||||
flag_modified(schedule, "source_snapshot")
|
||||
session.commit()
|
||||
with patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
assert result["failed"] == 1
|
||||
assert schedule.active is False
|
||||
assert "integrity" in (schedule.last_error or "")
|
||||
occurrence = session.query(CampaignScheduleOccurrence).one()
|
||||
assert occurrence.status == "failed"
|
||||
|
||||
def test_monthly_recurrence_clamps_end_of_month(self):
|
||||
result = next_schedule_fire(
|
||||
datetime(2026, 1, 31, 9, tzinfo=UTC),
|
||||
recurrence_kind="monthly",
|
||||
interval_count=1,
|
||||
timezone_name="UTC",
|
||||
)
|
||||
assert result == datetime(2026, 2, 28, 9, tzinfo=UTC)
|
||||
|
||||
def test_occurrence_uses_sealed_policy_state_and_advances_revision(self):
|
||||
with self.SessionLocal() as session:
|
||||
source = session.get(Campaign, "campaign-1")
|
||||
assert source is not None
|
||||
source.settings = {"retention": "changed-after-scheduling"}
|
||||
source.mail_profile_policy = {"profile_id": "profile-2"}
|
||||
session.commit()
|
||||
|
||||
with patch(
|
||||
"govoplan_campaign.backend.campaign.scheduling.create_campaign_version_from_json",
|
||||
side_effect=lambda *args, **kwargs: _create_generated_campaign(
|
||||
session,
|
||||
**kwargs,
|
||||
),
|
||||
), patch("govoplan_campaign.backend.campaign.scheduling.audit_event"):
|
||||
result = dispatch_due_campaign_schedules(
|
||||
session,
|
||||
tenant_id="tenant-1",
|
||||
now=datetime(2026, 8, 7, 8, tzinfo=UTC),
|
||||
)
|
||||
session.commit()
|
||||
|
||||
assert result["prepared"] == 1
|
||||
schedule = session.get(CampaignSchedule, "schedule-1")
|
||||
assert schedule is not None
|
||||
generated = session.get(Campaign, schedule.last_campaign_id)
|
||||
assert generated is not None
|
||||
assert generated.settings == {"retention": "sealed"}
|
||||
assert generated.mail_profile_policy == {"profile_id": "profile-1"}
|
||||
assert schedule.resource_revision == 2
|
||||
@@ -416,7 +416,10 @@ def test_static_campaign_handbook_has_unique_ids_help_contexts_and_no_planned_re
|
||||
"campaign.settings",
|
||||
"campaign.fields",
|
||||
"campaign.template",
|
||||
"campaign.template.content-library",
|
||||
"campaigns.action.schedule-drafts",
|
||||
"campaign.attachments",
|
||||
"campaign.attachments.residual-files",
|
||||
"campaign.recipients",
|
||||
"campaign.recipient-data",
|
||||
"campaign.server-settings",
|
||||
|
||||
@@ -19,7 +19,9 @@ def test_operator_queue_is_an_integrated_campaign_view() -> None:
|
||||
assert "/operator" not in {item.path for item in manifest.frontend.nav_items}
|
||||
|
||||
routes = {route.path: route for route in manifest.frontend.routes}
|
||||
assert "/operator" not in routes
|
||||
legacy_redirect = routes["/operator"]
|
||||
assert legacy_redirect.component == "OperatorQueueRedirect"
|
||||
assert legacy_redirect.surface_id == "campaigns.route.operator-redirect"
|
||||
queue = routes["/campaigns/queue"]
|
||||
assert queue.component == "OperatorQueuePage"
|
||||
assert queue.required_all == ("campaigns:campaign:read",)
|
||||
|
||||
@@ -9,6 +9,7 @@ from govoplan_campaign.backend.routes.delivery import router as delivery_router
|
||||
from govoplan_campaign.backend.routes.jobs import router as jobs_router
|
||||
from govoplan_campaign.backend.routes.operations import router as operations_router
|
||||
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.versions import router as versions_router
|
||||
|
||||
@@ -28,6 +29,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
versions_router,
|
||||
jobs_router,
|
||||
reports_router,
|
||||
schedules_router,
|
||||
sharing_router,
|
||||
delivery_router,
|
||||
attachments_router,
|
||||
@@ -40,7 +42,7 @@ def test_campaign_router_composes_every_workflow_operation_once() -> None:
|
||||
actual = _operation_keys(router)
|
||||
|
||||
assert actual == expected
|
||||
assert len(actual) == 72
|
||||
assert len(actual) == 80
|
||||
assert not [operation for operation, count in Counter(actual).items() if count > 1]
|
||||
|
||||
|
||||
@@ -54,6 +56,7 @@ def test_key_routes_are_owned_by_their_focused_router() -> None:
|
||||
(versions_router, ("POST", "/campaigns/versions/{version_id}/build")),
|
||||
(jobs_router, ("GET", "/campaigns/{campaign_id}/jobs")),
|
||||
(reports_router, ("GET", "/campaigns/{campaign_id}/report")),
|
||||
(schedules_router, ("GET", "/campaigns/{campaign_id}/schedules")),
|
||||
(sharing_router, ("POST", "/campaigns/{campaign_id}/shares")),
|
||||
(delivery_router, ("POST", "/campaigns/{campaign_id}/send-now")),
|
||||
(
|
||||
|
||||
@@ -20,6 +20,7 @@ from govoplan_core.core.search import (
|
||||
SearchBackfillRequest,
|
||||
SearchResourceReference,
|
||||
)
|
||||
from govoplan_core.core.change_sequence import ChangeSequenceEntry
|
||||
from govoplan_core.db.base import Base
|
||||
|
||||
|
||||
@@ -34,6 +35,7 @@ class CampaignSearchSourceTests(unittest.TestCase):
|
||||
Group.__table__,
|
||||
Campaign.__table__,
|
||||
CampaignShare.__table__,
|
||||
ChangeSequenceEntry.__table__,
|
||||
),
|
||||
)
|
||||
self.session = Session(self.engine)
|
||||
|
||||
Reference in New Issue
Block a user