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
+120
View File
@@ -52,6 +52,126 @@ class CampaignCopyRequest(CampaignLifecycleMutationRequest):
source_version_id: str = Field(min_length=1, max_length=36)
external_id: str | None = Field(default=None, min_length=1, max_length=255)
name: str | None = Field(default=None, min_length=1, max_length=255)
include_recipients: bool = True
include_files: bool = True
include_shares: bool = False
include_policies: bool = True
include_mail_profile: bool = True
class CampaignScheduleCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
source_version_id: str = Field(min_length=1, max_length=36)
name: str = Field(min_length=1, max_length=255)
recurrence_kind: Literal["once", "daily", "weekly", "monthly"] = "once"
interval_count: int = Field(default=1, ge=1, le=365)
timezone: str = Field(default="UTC", min_length=1, max_length=100)
starts_at: datetime
ends_at: datetime | None = None
max_occurrences: int = Field(default=1, ge=1, le=1000)
include_recipients: bool = True
include_files: bool = True
include_shares: bool = False
include_policies: bool = True
include_mail_profile: bool = True
@model_validator(mode="after")
def validate_schedule(self) -> "CampaignScheduleCreateRequest":
if self.starts_at.tzinfo is None:
raise ValueError("Campaign schedule start must include a timezone.")
if self.ends_at is not None:
if self.ends_at.tzinfo is None:
raise ValueError("Campaign schedule end must include a timezone.")
if self.ends_at <= self.starts_at:
raise ValueError("Campaign schedule end must be after its start.")
if self.recurrence_kind == "once":
self.max_occurrences = 1
self.interval_count = 1
elif self.max_occurrences < 2:
raise ValueError("A recurring campaign schedule needs at least two occurrences.")
return self
class CampaignScheduleStateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
active: bool
base_revision: int = Field(ge=1)
class CampaignScheduleOccurrenceResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
schedule_id: str
scheduled_for: datetime
status: str
generated_campaign_id: str | None = None
generated_version_id: str | None = None
error: str | None = None
created_at: datetime
class CampaignScheduleResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
campaign_id: str
source_version_id: str
name: str
recurrence_kind: str
interval_count: int
timezone: str
starts_at: datetime
next_fire_at: datetime | None = None
ends_at: datetime | None = None
max_occurrences: int
occurrence_count: int
active: bool
resource_revision: int
last_fired_at: datetime | None = None
last_campaign_id: str | None = None
last_error: str | None = None
created_at: datetime
updated_at: datetime
occurrences: list[CampaignScheduleOccurrenceResponse] = Field(default_factory=list)
class CampaignScheduleListResponse(BaseModel):
items: list[CampaignScheduleResponse]
class CampaignContentLibrarySaveRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=300)
description: str | None = Field(default=None, max_length=4000)
kind: Literal["fragment", "campaign_part"]
target: Literal["subject", "text", "html"] | None = None
subject: str | None = Field(default=None, max_length=1000)
text: str | None = Field(default=None, max_length=1_000_000)
html: str | None = Field(default=None, max_length=2_000_000)
body_mode: Literal["text", "html", "both"] = "both"
locale: str = Field(default="de", min_length=2, max_length=35)
visibility: Literal["personal", "tenant"] = "personal"
@model_validator(mode="after")
def validate_content(self) -> "CampaignContentLibrarySaveRequest":
if self.kind == "fragment" and self.target is None:
raise ValueError("A content fragment requires a target field.")
values = {
"subject": self.subject,
"text": self.text,
"html": self.html,
}
if self.kind == "fragment":
selected = values[self.target or "text"]
if not selected or not selected.strip():
raise ValueError("The selected fragment field is empty.")
elif not any(value and value.strip() for value in (self.text, self.html)):
raise ValueError("A campaign part requires text or HTML body content.")
return self
class CampaignCreateMinimalRequest(BaseModel):