feat: add campaign copying scheduling and residual handling
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user