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,
)
@@ -0,0 +1,290 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from govoplan_campaign.backend.campaign.scheduling import (
campaign_schedule_source_snapshot,
canonical_configuration_hash,
)
from govoplan_campaign.backend.db.models import (
CampaignSchedule,
CampaignScheduleOccurrence,
CampaignShare,
CampaignVersion,
)
from govoplan_campaign.backend.route_support import (
_get_campaign_for_principal,
_require_permission,
)
from govoplan_campaign.backend.schemas import (
CampaignScheduleCreateRequest,
CampaignScheduleListResponse,
CampaignScheduleOccurrenceResponse,
CampaignScheduleResponse,
CampaignScheduleStateRequest,
)
from govoplan_core.auth import ApiPrincipal, require_scope
from govoplan_core.audit.logging import audit_from_principal
from govoplan_core.db.session import get_session
router = APIRouter(prefix="/campaigns", tags=["campaign-schedules"])
@router.get(
"/{campaign_id}/schedules",
response_model=CampaignScheduleListResponse,
)
def list_campaign_schedules(
campaign_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:read")),
):
_get_campaign_for_principal(session, campaign_id, principal)
schedules = (
session.query(CampaignSchedule)
.filter(
CampaignSchedule.tenant_id == principal.tenant_id,
CampaignSchedule.campaign_id == campaign_id,
)
.order_by(CampaignSchedule.created_at.desc(), CampaignSchedule.id.asc())
.all()
)
occurrences = _occurrences_by_schedule(session, schedules)
return CampaignScheduleListResponse(
items=[
_schedule_response(item, occurrences.get(item.id, []))
for item in schedules
]
)
@router.post(
"/{campaign_id}/schedules",
response_model=CampaignScheduleResponse,
status_code=status.HTTP_201_CREATED,
)
def create_campaign_schedule(
campaign_id: str,
payload: CampaignScheduleCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
):
campaign = _get_campaign_for_principal(session, campaign_id, principal)
_require_permission(principal, "campaigns:campaign:copy")
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(
CampaignVersion.id == payload.source_version_id,
CampaignVersion.campaign_id == campaign.id,
)
.one_or_none()
)
if source_version is None:
raise HTTPException(status_code=404, detail="Campaign version not found")
starts_at = payload.starts_at.astimezone(UTC)
if starts_at < datetime.now(UTC) - timedelta(minutes=5):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Campaign schedules cannot start in the past.",
)
try:
ZoneInfo(payload.timezone)
except ZoneInfoNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="Unknown campaign schedule timezone.",
) from exc
source_shares = (
session.query(CampaignShare)
.filter(
CampaignShare.tenant_id == principal.tenant_id,
CampaignShare.campaign_id == campaign.id,
CampaignShare.revoked_at.is_(None),
)
.order_by(CampaignShare.id.asc())
.all()
if payload.include_shares
else []
)
snapshot = campaign_schedule_source_snapshot(
configuration=source_version.raw_json,
campaign_settings=campaign.settings or {},
mail_profile_policy=campaign.mail_profile_policy or {},
shares=[
{
"target_type": item.target_type,
"target_id": item.target_id,
"permission": item.permission,
}
for item in source_shares
],
)
schedule = CampaignSchedule(
tenant_id=principal.tenant_id,
campaign_id=campaign.id,
source_version_id=source_version.id,
created_by_user_id=principal.user.id,
name=payload.name.strip(),
recurrence_kind=payload.recurrence_kind,
interval_count=payload.interval_count,
timezone=payload.timezone,
starts_at=starts_at,
next_fire_at=starts_at,
ends_at=payload.ends_at.astimezone(UTC) if payload.ends_at else None,
max_occurrences=payload.max_occurrences,
copy_options={
"include_recipients": payload.include_recipients,
"include_files": payload.include_files,
"include_shares": payload.include_shares,
"include_policies": payload.include_policies,
"include_mail_profile": payload.include_mail_profile,
},
source_snapshot=snapshot,
source_snapshot_hash=canonical_configuration_hash(snapshot),
source_base_path=source_version.source_base_path,
)
session.add(schedule)
session.flush()
audit_from_principal(
session,
principal,
action="campaign.schedule.created",
object_type="campaign_schedule",
object_id=schedule.id,
details={
"campaign_id": campaign.id,
"source_version_id": source_version.id,
"recurrence_kind": schedule.recurrence_kind,
"interval_count": schedule.interval_count,
"starts_at": schedule.starts_at.isoformat(),
"ends_at": schedule.ends_at.isoformat() if schedule.ends_at else None,
"max_occurrences": schedule.max_occurrences,
"delivery_started": False,
},
commit=True,
)
session.refresh(schedule)
return _schedule_response(schedule, [])
@router.patch(
"/{campaign_id}/schedules/{schedule_id}",
response_model=CampaignScheduleResponse,
)
def set_campaign_schedule_state(
campaign_id: str,
schedule_id: str,
payload: CampaignScheduleStateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(require_scope("campaigns:campaign:schedule")),
):
_get_campaign_for_principal(session, campaign_id, principal)
schedule = _schedule_for_campaign(
session,
tenant_id=principal.tenant_id,
campaign_id=campaign_id,
schedule_id=schedule_id,
for_update=True,
)
if schedule.resource_revision != payload.base_revision:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Campaign schedule changed. Reload it before changing its state.",
)
if payload.active and schedule.next_fire_at is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="A completed campaign schedule cannot be resumed.",
)
schedule.active = payload.active
schedule.last_error = None if payload.active else schedule.last_error
schedule.resource_revision += 1
session.add(schedule)
audit_from_principal(
session,
principal,
action=(
"campaign.schedule.resumed"
if payload.active
else "campaign.schedule.paused"
),
object_type="campaign_schedule",
object_id=schedule.id,
details={"campaign_id": campaign_id},
commit=True,
)
session.refresh(schedule)
occurrences = _occurrences_by_schedule(session, [schedule]).get(schedule.id, [])
return _schedule_response(schedule, occurrences)
def _schedule_for_campaign(
session: Session,
*,
tenant_id: str,
campaign_id: str,
schedule_id: str,
for_update: bool = False,
) -> CampaignSchedule:
query = session.query(CampaignSchedule)
if for_update:
query = query.with_for_update()
schedule = (
query
.filter(
CampaignSchedule.id == schedule_id,
CampaignSchedule.tenant_id == tenant_id,
CampaignSchedule.campaign_id == campaign_id,
)
.one_or_none()
)
if schedule is None:
raise HTTPException(status_code=404, detail="Campaign schedule not found")
return schedule
def _occurrences_by_schedule(
session: Session,
schedules: list[CampaignSchedule],
) -> dict[str, list[CampaignScheduleOccurrence]]:
ids = [item.id for item in schedules]
if not ids:
return {}
rows = (
session.query(CampaignScheduleOccurrence)
.filter(CampaignScheduleOccurrence.schedule_id.in_(ids))
.order_by(
CampaignScheduleOccurrence.scheduled_for.desc(),
CampaignScheduleOccurrence.id.asc(),
)
.all()
)
grouped: dict[str, list[CampaignScheduleOccurrence]] = {}
for row in rows:
grouped.setdefault(row.schedule_id, []).append(row)
return grouped
def _schedule_response(
schedule: CampaignSchedule,
occurrences: list[CampaignScheduleOccurrence],
) -> CampaignScheduleResponse:
response = CampaignScheduleResponse.model_validate(schedule)
return response.model_copy(
update={
"occurrences": [
CampaignScheduleOccurrenceResponse.model_validate(item)
for item in occurrences
]
}
)