feat(campaigns): add domain models and status state machine

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-01 12:30:10 +05:30
co-authored by Claude Opus 5
parent 38b6118fc3
commit d22e414869
4 changed files with 131 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
"""Campaign management domain package."""
+30
View File
@@ -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
+54
View File
@@ -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
+45
View File
@@ -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")