feat: add campaign copying scheduling and residual handling

This commit is contained in:
2026-08-07 14:54:04 +02:00
parent 696f8f6385
commit c2efd6b7bd
35 changed files with 3359 additions and 39 deletions
+195 -12
View File
@@ -24,11 +24,13 @@ from govoplan_campaign.backend.campaign.entries import load_campaign_entries
from govoplan_campaign.backend.campaign.field_values import ignored_entry_field_overrides
from govoplan_campaign.backend.campaign.models import (
Behavior,
AttachmentConfig,
BuildStatus,
CampaignConfig,
EntryConfig,
MissingAddressBehavior,
RecipientConfig,
ResidualFileMode,
SendStatus,
TemplateBodyMode,
ZipArchiveConfig,
@@ -92,6 +94,13 @@ class _MimeBuildResult:
attachment_count: int
@dataclass(slots=True)
class _ResidualFileGroup:
source_name: str
directory: Path
files: list[Path]
def _resolve(campaign_file: str | Path, raw_path: str) -> Path:
campaign_path = Path(campaign_file).resolve()
path = Path(raw_path).expanduser()
@@ -890,17 +899,13 @@ def build_entry_message(
def _unsent_attachment_issues(
def _residual_attachment_files(
*,
config: CampaignConfig,
campaign_file: str | Path,
built_messages: list[BuiltMessage],
attachment_match_index: AttachmentMatchIndex | None = None,
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if behavior == Behavior.CONTINUE.value:
return []
) -> list[_ResidualFileGroup]:
matched_files = {
Path(match).resolve()
for built in built_messages
@@ -908,7 +913,7 @@ def _unsent_attachment_issues(
for match in attachment.matches
}
issues: list[MessageIssue] = []
groups: list[_ResidualFileGroup] = []
for base_path in config.attachments.base_paths:
if not base_path.unsent_warning:
continue
@@ -922,20 +927,182 @@ def _unsent_attachment_issues(
unsent = [path for path in all_files if path not in matched_files]
if not unsent:
continue
groups.append(
_ResidualFileGroup(
source_name=base_path.name,
directory=directory,
files=unsent,
)
)
return groups
def _unsent_attachment_issues(
*,
config: CampaignConfig,
residual_groups: list[_ResidualFileGroup],
) -> list[MessageIssue]:
behavior = config.validation_policy.unsent_attachment_files.value
if (
behavior == Behavior.CONTINUE.value
or config.attachments.residual_files.mode != ResidualFileMode.NONE
):
return []
issues: list[MessageIssue] = []
for group in residual_groups:
unsent = group.files
directory = group.directory
shown = ", ".join(str(path.relative_to(directory)) for path in unsent[:10])
if len(unsent) > 10:
shown += f", … (+{len(unsent) - 10} more)"
issues.append(
_issue_from_behavior(
code="unsent_attachment_files",
message=f"{len(unsent)} file(s) in attachment source {base_path.name!r} are not used by any message: {shown}",
message=f"{len(unsent)} file(s) in attachment source {group.source_name!r} are not used by any message: {shown}",
behavior=behavior,
source=f"attachments:{base_path.name}",
source=f"attachments:{group.source_name}",
)
)
return issues
def _build_residual_file_message(
*,
config: CampaignConfig,
campaign_file: Path,
residual_groups: list[_ResidualFileGroup],
entry_index: int,
output_dir: Path | None,
write_eml: bool,
work_dir: Path,
attachment_match_index: AttachmentMatchIndex,
) -> BuiltMessage | None:
disposition = config.attachments.residual_files
if disposition.mode == ResidualFileMode.NONE or disposition.recipient is None:
return None
files = sorted({path.resolve() for group in residual_groups for path in group.files})
if not files:
return None
file_lines = [
f"{group.source_name}: {path.relative_to(group.directory)}"
for group in residual_groups
for path in group.files
]
entry = EntryConfig(
id="__residual_files__",
to=[disposition.recipient],
merge_to=False,
combine_attachments=True,
fields={
"campaign_name": config.campaign.name,
"residual_file_count": len(files),
"residual_file_list": "\n".join(file_lines),
},
)
residual_config = config.model_copy(deep=True)
residual_config.attachments.global_ = []
residual_config.attachments.zip.enabled = False
if disposition.mode == ResidualFileMode.ATTACH:
residual_config.attachments.global_ = [
AttachmentConfig(
id=f"residual-file-{index}",
label=path.name,
base_dir=str(path.parent),
file_filter=path.name,
required=True,
allow_multiple=False,
)
for index, path in enumerate(files, start=1)
]
context = _entry_message_context(
config=residual_config,
campaign_file=campaign_file,
entry=entry,
entry_index=entry_index,
attachment_match_index=attachment_match_index,
)
context.validation_status = _validate_required_sender(
context.senders,
context.issues,
context.validation_status,
)
context.validation_status = _validate_required_recipients(
residual_config,
context.recipients,
context.issues,
context.validation_status,
)
values = build_template_values(residual_config, entry)
rendered = _RenderedMessageTemplate(
subject=_render_template(disposition.subject, values, keep_missing=True),
text_body=_render_template(disposition.text, values, keep_missing=True),
html_body=None,
body_mode=TemplateBodyMode.TEXT.value,
values=values,
)
context.validation_status = _validate_rendered_template(
residual_config,
rendered,
context.issues,
context.validation_status,
)
context.issues.append(
MessageIssue(
severity="warning",
code="residual_attachment_disposition",
message=(
f"{len(files)} unassigned file(s) are routed as a reviewed "
f"{disposition.mode.value} message."
),
behavior="ask",
source="attachments:residual_files",
details={
"mode": disposition.mode.value,
"file_count": len(files),
"source_count": len(residual_groups),
},
)
)
if context.validation_status not in {
MessageValidationStatus.BLOCKED,
MessageValidationStatus.EXCLUDED,
}:
context.validation_status = MessageValidationStatus.NEEDS_REVIEW
mime_result = _build_mime_message(
config=residual_config,
entry=entry,
entry_index=entry_index,
output_dir=output_dir,
work_dir=work_dir,
context=context,
rendered=rendered,
)
eml_path: str | None = None
eml_size: int | None = None
if write_eml and output_dir is not None and mime_result.message is not None:
eml_path, eml_size = _write_eml(
mime_result.message,
output_dir,
entry,
entry_index,
)
return BuiltMessage(
draft=_message_draft(
config=residual_config,
entry=entry,
entry_index=entry_index,
context=context,
build_status=mime_result.build_status,
validation_status=mime_result.validation_status,
subject=rendered.subject,
attachment_count=mime_result.attachment_count,
eml_path=eml_path,
eml_size=eml_size,
),
mime=mime_result.message,
)
def _apply_campaign_level_issues(built_messages: list[BuiltMessage], issues: list[MessageIssue]) -> None:
if not issues:
return
@@ -978,15 +1145,31 @@ def build_campaign_messages(
for index, entry in enumerate(entries, start=1)
if entry.active
]
residual_groups = _residual_attachment_files(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
)
_apply_campaign_level_issues(
built_messages,
_unsent_attachment_issues(
config=config,
campaign_file=campaign_path,
built_messages=built_messages,
attachment_match_index=attachment_match_index,
residual_groups=residual_groups,
),
)
residual_message = _build_residual_file_message(
config=config,
campaign_file=campaign_path,
residual_groups=residual_groups,
entry_index=len(entries) + 1,
output_dir=output_path,
write_eml=write_eml,
work_dir=work_dir,
attachment_match_index=attachment_match_index,
)
if residual_message is not None:
built_messages.append(residual_message)
rules_resolved = sum(len(built.draft.attachments) for built in built_messages)
report = CampaignBuildReport(