feat(campaigns): add domain models and status state machine
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Campaign management domain package."""
|
||||
@@ -0,0 +1,30 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Campaign Pydantic models."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CampaignSpec(BaseModel):
|
||||
id: str
|
||||
company_id: str | None = None
|
||||
name: str
|
||||
status: str = "draft"
|
||||
origin: str = "maskanx"
|
||||
objective: str | None = None
|
||||
ad_account_id: str | None = None
|
||||
budget: dict[str, Any] = Field(default_factory=dict)
|
||||
guardrails: dict[str, Any] = Field(default_factory=dict)
|
||||
targeting: dict[str, Any] = Field(default_factory=dict)
|
||||
advanced: dict[str, Any] = Field(default_factory=dict)
|
||||
channels: list[str] = Field(default_factory=list)
|
||||
schedule: dict[str, Any] = Field(default_factory=dict)
|
||||
meta_campaign_id: str | None = None
|
||||
sync_status: str = "not_synced"
|
||||
sync_error: str | None = None
|
||||
approved_by: str | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Campaign status state machine."""
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CampaignStatus(StrEnum):
|
||||
DRAFT = "draft"
|
||||
PENDING_APPROVAL = "pending_approval"
|
||||
APPROVED = "approved"
|
||||
SYNCED = "synced"
|
||||
LIVE = "live"
|
||||
PAUSED = "paused"
|
||||
STOPPED = "stopped"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class SyncStatus(StrEnum):
|
||||
NOT_SYNCED = "not_synced"
|
||||
SYNCING = "syncing"
|
||||
SYNCED = "synced"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class TransitionError(Exception):
|
||||
"""Raised when a status transition is not allowed."""
|
||||
|
||||
|
||||
ALLOWED_TRANSITIONS: dict[str, dict[str, str]] = {
|
||||
"draft": {"submit": "pending_approval"},
|
||||
"pending_approval": {"approve": "approved", "reject": "draft"},
|
||||
"approved": {"sync": "synced", "stop": "stopped"},
|
||||
"synced": {"launch": "live", "stop": "stopped"},
|
||||
"live": {"pause": "paused", "stop": "stopped"},
|
||||
"paused": {"launch": "live", "stop": "stopped"},
|
||||
"stopped": {"archive": "archived"},
|
||||
"archived": {},
|
||||
}
|
||||
|
||||
EDITABLE_STATUSES = frozenset({"draft", "pending_approval"})
|
||||
|
||||
|
||||
def next_status(current: str, action: str) -> str:
|
||||
"""Return the status reached by applying action to current."""
|
||||
allowed = ALLOWED_TRANSITIONS.get(current)
|
||||
if allowed is None:
|
||||
raise TransitionError(f"Unknown campaign status: {current}")
|
||||
target = allowed.get(action)
|
||||
if target is None:
|
||||
raise TransitionError(
|
||||
f"Action '{action}' is not allowed from status '{current}'.",
|
||||
)
|
||||
return target
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Campaign status transitions must follow the approved state machine."""
|
||||
import pytest
|
||||
|
||||
from adclaw.campaigns.state import TransitionError, next_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current", "action", "expected"),
|
||||
[
|
||||
("draft", "submit", "pending_approval"),
|
||||
("pending_approval", "approve", "approved"),
|
||||
("pending_approval", "reject", "draft"),
|
||||
("approved", "sync", "synced"),
|
||||
("synced", "launch", "live"),
|
||||
("paused", "launch", "live"),
|
||||
("live", "pause", "paused"),
|
||||
("live", "stop", "stopped"),
|
||||
("paused", "stop", "stopped"),
|
||||
("synced", "stop", "stopped"),
|
||||
("stopped", "archive", "archived"),
|
||||
],
|
||||
)
|
||||
def test_allowed_transitions(current, action, expected):
|
||||
assert next_status(current, action) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current", "action"),
|
||||
[
|
||||
("draft", "approve"),
|
||||
("draft", "launch"),
|
||||
("approved", "launch"),
|
||||
("archived", "launch"),
|
||||
("live", "approve"),
|
||||
],
|
||||
)
|
||||
def test_rejected_transitions(current, action):
|
||||
with pytest.raises(TransitionError):
|
||||
next_status(current, action)
|
||||
|
||||
|
||||
def test_draft_cannot_reach_live_directly():
|
||||
with pytest.raises(TransitionError):
|
||||
next_status("draft", "launch")
|
||||
Reference in New Issue
Block a user