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
@@ -14,6 +14,7 @@ from govoplan_campaign.backend.schemas import (
CampaignCreateResponse,
CampaignCreateMinimalRequest,
CampaignCopyRequest,
CampaignContentLibrarySaveRequest,
CampaignLifecycleMutationRequest,
CampaignLifecyclePolicyResponse,
CampaignAddressLookupCandidate,
@@ -42,6 +43,7 @@ from govoplan_campaign.backend.schemas import (
)
from govoplan_core.auth import ApiPrincipal, has_scope, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.core.templates import TemplateContentDraftRequest, TemplateRef
from govoplan_core.core.change_sequence import (
decode_sequence_watermark,
encode_sequence_watermark,
@@ -60,6 +62,7 @@ from govoplan_campaign.backend.change_tracking import (
)
from govoplan_campaign.backend.db.models import (
Campaign,
CampaignShare,
CampaignVersion,
RecipientImportMappingProfile,
)
@@ -70,6 +73,7 @@ from govoplan_campaign.backend.campaign.lifecycle import (
assert_lifecycle_state_token,
campaign_lifecycle_policy,
)
from govoplan_campaign.backend.campaign.copying import campaign_copy_configuration
from govoplan_campaign.backend.integrations import (
calendar_integration,
PostboxDeliveryUnavailable,
@@ -214,6 +218,13 @@ def _campaign_copy_external_id(
)
def _campaign_copy_configuration(
source: dict[str, object],
payload: CampaignCopyRequest,
) -> dict[str, object]:
return campaign_copy_configuration(source, payload.model_dump())
@router.post("", response_model=CampaignCreateResponse)
def create_campaign(
payload: CampaignCreateRequest,
@@ -924,6 +935,192 @@ def campaign_print_templates(
}
def _campaign_content_library_item(template: TemplateRef) -> dict[str, object]:
revision = template.revision
revision_metadata = dict(revision.metadata) if revision else {}
raw_targets = revision_metadata.get("campaign_targets")
targets = (
[str(value) for value in raw_targets if str(value) in {"subject", "text", "html"}]
if isinstance(raw_targets, (list, tuple))
else []
)
if not targets and revision is not None:
if template.template_type == "email":
targets = ["subject", "text", "html"]
else:
if revision.content_text:
targets.append("text")
if revision.content_html:
targets.append("html")
kind = str(revision_metadata.get("campaign_kind") or "").strip()
if kind not in {"fragment", "campaign_part"}:
kind = "fragment" if template.template_type == "content_fragment" else "campaign_part"
return {
"id": template.id,
"name": template.name,
"description": template.description,
"template_type": template.template_type,
"kind": kind,
"status": template.status,
"scope_type": template.scope_type,
"scope_id": template.scope_id,
"read_only": template.read_only,
"current_revision": template.current_revision,
"revision": revision.revision if revision else template.current_revision,
"revision_id": revision.id if revision else template.current_revision_id,
"locale": revision.locale if revision else None,
"published": bool(template.published_revision_id),
"targets": list(dict.fromkeys(targets)),
"subject": revision_metadata.get("campaign_subject"),
"text": revision.content_text if revision else None,
"html": revision.content_html if revision else None,
"body_mode": revision_metadata.get("campaign_body_mode") or "both",
}
@router.get("/{campaign_id}/content-library")
def campaign_content_library(
campaign_id: str,
query: str = Query(default="", min_length=0, max_length=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
integration = templates_integration()
if not integration.content_available:
return {
"available": False,
"writable": False,
"reason": "The Templates content library is not active.",
"items": [],
}
try:
templates = integration.list_content_templates(
session,
principal,
query=query,
limit=250,
)
except PermissionError as exc:
return {
"available": True,
"writable": False,
"reason": str(exc),
"items": [],
}
return {
"available": True,
"writable": integration.content_writable,
"items": [_campaign_content_library_item(template) for template in templates],
}
@router.post("/{campaign_id}/content-library", status_code=status.HTTP_201_CREATED)
def save_campaign_content_library_item(
campaign_id: str,
payload: CampaignContentLibrarySaveRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:update")),
):
campaign = _get_campaign_for_principal(
session,
campaign_id,
principal,
write=True,
)
integration = templates_integration()
if not integration.content_writable:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="The Templates content-library capability is not active.",
)
target = payload.target if payload.kind == "fragment" else None
content_text = (
payload.subject
if target == "subject"
else payload.text
if target == "text"
else None
)
content_html = payload.html if target == "html" else None
if payload.kind == "campaign_part":
content_text = payload.text
content_html = payload.html
metadata: dict[str, object] = {
"campaign_kind": payload.kind,
"campaign_targets": (
[target]
if target
else [
field
for field, value in (
("subject", payload.subject),
("text", payload.text),
("html", payload.html),
)
if value and value.strip()
]
),
"campaign_body_mode": payload.body_mode,
"source_module": "campaigns",
"source_campaign_id": campaign.id,
}
if payload.kind == "campaign_part" and payload.subject:
metadata["campaign_subject"] = payload.subject
try:
template = integration.create_content_draft(
session,
principal,
request=TemplateContentDraftRequest(
name=payload.name,
description=payload.description,
template_type=(
"content_fragment" if payload.kind == "fragment" else "email"
),
usages=("campaign.content",),
content_text=content_text,
content_html=content_html,
locale=payload.locale,
scope_type=("user" if payload.visibility == "personal" else "tenant"),
scope_id=(
principal.account_id if payload.visibility == "personal" else None
),
metadata=metadata,
),
)
audit_from_principal(
session,
principal,
action="campaign.content_library_saved",
object_type="campaign",
object_id=campaign.id,
details={
"template_id": template.id,
"template_revision_id": (
template.revision.id if template.revision else None
),
"kind": payload.kind,
"target": payload.target,
"visibility": payload.visibility,
},
commit=False,
)
session.commit()
except PermissionError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=str(exc),
) from exc
except ValueError as exc:
session.rollback()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
return {"template": _campaign_content_library_item(template)}
@router.post(
"/{campaign_id}/recipient-address-sources/snapshot",
response_model=CampaignRecipientAddressSourceSnapshotResponse,
@@ -1652,7 +1849,10 @@ def copy_campaign(
action="copy_campaign",
version_id=payload.source_version_id,
)
_require_permission(principal, "campaigns:recipient:read")
if payload.include_recipients:
_require_permission(principal, "campaigns:recipient:read")
if payload.include_shares:
_require_permission(principal, "campaigns:campaign:share")
source_version = (
session.query(CampaignVersion)
.filter(
@@ -1674,7 +1874,7 @@ def copy_campaign(
requested=payload.external_id,
)
name = (payload.name or f"{source_campaign.name} (copy)").strip()
raw_json = copy.deepcopy(source_version.raw_json)
raw_json = _campaign_copy_configuration(source_version.raw_json, payload)
campaign_metadata = raw_json.get("campaign")
if not isinstance(campaign_metadata, dict):
raise HTTPException(
@@ -1696,6 +1896,36 @@ def copy_campaign(
source_base_path=source_version.source_base_path,
commit=False,
)
if payload.include_policies:
campaign.settings = copy.deepcopy(source_campaign.settings or {})
if payload.include_mail_profile:
campaign.mail_profile_policy = copy.deepcopy(
source_campaign.mail_profile_policy or {}
)
copied_share_count = 0
if payload.include_shares:
source_shares = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == source_campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
)
for source_share in source_shares:
session.add(
CampaignShare(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
target_type=source_share.target_type,
target_id=source_share.target_id,
permission=source_share.permission,
created_by_user_id=principal.user.id,
)
)
copied_share_count = len(source_shares)
audit_from_principal(
session,
principal,
@@ -1707,6 +1937,14 @@ def copy_campaign(
"source_version_id": source_version.id,
"destination_version_id": version.id,
"copied_evidence": False,
"copy_options": {
"recipients": payload.include_recipients,
"files": payload.include_files,
"shares": payload.include_shares,
"policies": payload.include_policies,
"mail_profile": payload.include_mail_profile,
},
"copied_share_count": copied_share_count,
},
commit=True,
)