feat(campaigns): discover and adopt campaigns created in Meta

Campaigns can be authored on either side. Anything MaskanX did not create is
origin="imported": MaskanX reports on it and can pause or stop it, but it was
built elsewhere.

Adoption is keyed on meta_campaign_id, which carries a unique index, so
adopting the same campaign twice returns the existing record instead of
violating the constraint. Discover excludes anything already adopted.

The literal /discover and /adopt routes are declared before /{campaign_id} so
they cannot be captured as campaign ids; a test pins that ordering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AFFAANh
2026-08-03 16:34:32 +05:30
co-authored by Claude Opus 5
parent 8153edfce9
commit 09aee8b621
3 changed files with 363 additions and 0 deletions
+49
View File
@@ -20,6 +20,7 @@ from ...campaigns.state import (
TransitionError,
next_status,
)
from ...campaigns.adopt import adopt_campaign, discover_campaigns
from ...campaigns.sync import SyncConfigurationError, sync_campaign
from ...campaigns.validation import validate_campaign
from ...meta.client import MetaClient, MetaError, access_token_from_env
@@ -58,6 +59,12 @@ class ActorPayload(BaseModel):
reason: str | None = None
class AdoptRequest(BaseModel):
ad_account_id: str
meta_campaign_id: str
company_id: str | None = None
class PreviewRequest(BaseModel):
ad_account_id: str
creative: dict[str, Any]
@@ -187,6 +194,48 @@ async def preview_campaign(payload: PreviewRequest) -> PreviewResponse:
return PreviewResponse(previews=previews)
@router.get("/discover")
async def discover_meta_campaigns(ad_account_id: str) -> list[dict[str, Any]]:
"""List campaigns in the ad account that MaskanX does not know about."""
try:
return await discover_campaigns(
get_repository(), get_meta_client(), ad_account_id,
)
except MetaError as exc:
raise _meta_http_error(exc) from exc
@router.post(
"/adopt",
response_model=CampaignSpec,
status_code=http_status.HTTP_201_CREATED,
)
async def adopt_meta_campaign(
payload: AdoptRequest,
operator: str = Depends(require_operator),
) -> CampaignSpec:
"""Import an existing Meta campaign as a MaskanX record.
Idempotent: adopting the same campaign twice returns the existing record.
"""
try:
return await adopt_campaign(
get_repository(),
get_meta_client(),
ad_account_id=payload.ad_account_id,
meta_campaign_id=payload.meta_campaign_id,
actor=operator,
company_id=payload.company_id,
)
except LookupError as exc:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except MetaError as exc:
raise _meta_http_error(exc) from exc
@router.get("/account/{ad_account_id}")
async def get_ad_account(ad_account_id: str) -> dict[str, Any]:
"""Return billing and limit fields for an ad account."""
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""Discover campaigns created directly in Meta Ads Manager, and adopt them.
Campaigns can be created on either side. Anything MaskanX did not create is
`origin="imported"`: MaskanX reports on it and can pause or stop it, but it
was authored elsewhere.
Adoption is keyed on `meta_campaign_id`, which carries a unique index, so
adopting the same campaign twice returns the existing record rather than
creating a duplicate.
"""
from __future__ import annotations
import logging
import uuid
from typing import Any
from .models import CampaignSpec
logger = logging.getLogger(__name__)
# Meta delivery status -> MaskanX status. An imported campaign has already
# been through whatever approval its author used, so it lands in a delivery
# state rather than back in `draft`.
_STATUS_MAP = {
"ACTIVE": "live",
"PAUSED": "paused",
"DELETED": "archived",
"ARCHIVED": "archived",
}
def local_status_for(meta_status: str | None) -> str:
"""Map a Meta campaign status onto a MaskanX status."""
return _STATUS_MAP.get((meta_status or "").upper(), "paused")
def spec_from_meta(
meta_campaign: dict[str, Any],
ad_account_id: str,
company_id: str | None = None,
) -> CampaignSpec:
"""Build a local record describing a campaign that already exists in Meta."""
return CampaignSpec(
id=f"camp_{uuid.uuid4().hex[:16]}",
company_id=company_id,
name=meta_campaign.get("name") or "Imported campaign",
status=local_status_for(meta_campaign.get("status")),
origin="imported",
objective=meta_campaign.get("objective"),
ad_account_id=ad_account_id,
meta_campaign_id=meta_campaign["id"],
sync_status="synced",
advanced={
"imported_from_meta": {
"effective_status": meta_campaign.get("effective_status"),
"created_time": meta_campaign.get("created_time"),
},
},
)
async def _known_meta_ids(repo) -> dict[str, CampaignSpec]:
campaigns = await repo.list_campaigns()
return {c.meta_campaign_id: c for c in campaigns if c.meta_campaign_id}
async def discover_campaigns(repo, meta, ad_account_id: str) -> list[dict[str, Any]]:
"""Return Meta campaigns in this account that MaskanX does not know about."""
known = await _known_meta_ids(repo)
remote = await meta.list_campaigns(ad_account_id)
return [c for c in remote if c.get("id") and c["id"] not in known]
async def adopt_campaign(
repo,
meta,
ad_account_id: str,
meta_campaign_id: str,
actor: str,
company_id: str | None = None,
) -> CampaignSpec:
"""Create a local record for an existing Meta campaign.
Idempotent: adopting an already-adopted campaign returns the existing
record untouched rather than creating a second one.
"""
known = await _known_meta_ids(repo)
existing = known.get(meta_campaign_id)
if existing is not None:
logger.info(
"Campaign %s is already adopted as %s", meta_campaign_id, existing.id,
)
return existing
remote = await meta.list_campaigns(ad_account_id)
match = next((c for c in remote if c.get("id") == meta_campaign_id), None)
if match is None:
raise LookupError(
f"Campaign {meta_campaign_id} was not found in {ad_account_id}.",
)
spec = spec_from_meta(match, ad_account_id, company_id=company_id)
created = await repo.create_campaign(spec)
await repo.add_event(
created.id,
event_type="campaign.adopted",
actor=actor,
reason=f"Imported from Meta campaign {meta_campaign_id}",
payload={"meta_campaign_id": meta_campaign_id},
)
return created
+202
View File
@@ -0,0 +1,202 @@
# -*- coding: utf-8 -*-
"""Discovering and adopting campaigns created directly in Meta Ads Manager."""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from adclaw.app.routers import campaigns as campaigns_router
from adclaw.campaigns.adopt import (
adopt_campaign,
discover_campaigns,
local_status_for,
)
from adclaw.campaigns.models import CampaignSpec
class FakeRepo:
def __init__(self, campaigns=None):
self.items: dict[str, CampaignSpec] = {c.id: c for c in (campaigns or [])}
self.events: list[dict] = []
async def list_campaigns(self, company_id=None, status=None):
return list(self.items.values())
async def get_campaign(self, campaign_id):
return self.items.get(campaign_id)
async def create_campaign(self, spec):
self.items[spec.id] = spec
return spec
async def add_event(
self, campaign_id, event_type, actor=None, reason=None, payload=None,
):
self.events.append({"campaign_id": campaign_id, "event_type": event_type})
class FakeMeta:
def __init__(self, campaigns=None):
self.campaigns = campaigns if campaigns is not None else [
{"id": "meta_1", "name": "Remote one", "status": "ACTIVE"},
{"id": "meta_2", "name": "Remote two", "status": "PAUSED"},
]
self.list_calls = 0
async def list_campaigns(self, ad_account_id):
self.list_calls += 1
return self.campaigns
def _local(meta_id: str | None, **overrides) -> CampaignSpec:
data = {
"id": f"camp_{meta_id or 'local'}",
"name": "Local campaign",
"status": "draft",
"ad_account_id": "act_1",
"meta_campaign_id": meta_id,
}
data.update(overrides)
return CampaignSpec(**data)
@pytest.mark.parametrize(
("meta_status", "expected"),
[
("ACTIVE", "live"),
("PAUSED", "paused"),
("DELETED", "archived"),
("ARCHIVED", "archived"),
("active", "live"),
(None, "paused"),
("SOMETHING_NEW", "paused"),
],
)
def test_meta_status_maps_to_local_status(meta_status, expected):
assert local_status_for(meta_status) == expected
@pytest.mark.asyncio
async def test_discover_lists_only_unknown_campaigns():
repo = FakeRepo([_local("meta_1")])
meta = FakeMeta()
found = await discover_campaigns(repo, meta, "act_1")
assert [c["id"] for c in found] == ["meta_2"]
@pytest.mark.asyncio
async def test_discover_ignores_local_campaigns_with_no_meta_id():
repo = FakeRepo([_local(None)])
meta = FakeMeta()
found = await discover_campaigns(repo, meta, "act_1")
assert [c["id"] for c in found] == ["meta_1", "meta_2"]
@pytest.mark.asyncio
async def test_adopt_creates_an_imported_record():
repo, meta = FakeRepo(), FakeMeta()
created = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_2", actor="owner",
)
assert created.origin == "imported"
assert created.meta_campaign_id == "meta_2"
assert created.sync_status == "synced"
assert created.status == "paused"
assert created.name == "Remote two"
assert repo.events[-1]["event_type"] == "campaign.adopted"
@pytest.mark.asyncio
async def test_adopting_twice_returns_the_existing_record():
"""The unique index on meta_campaign_id must never be violated."""
repo, meta = FakeRepo(), FakeMeta()
first = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_1", actor="owner",
)
second = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_1", actor="owner",
)
assert second.id == first.id
assert len(repo.items) == 1
# Only the first adoption writes an event.
assert len(repo.events) == 1
@pytest.mark.asyncio
async def test_adopting_an_unknown_campaign_raises_lookup_error():
repo, meta = FakeRepo(), FakeMeta()
with pytest.raises(LookupError):
await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="nope", actor="owner",
)
@pytest.mark.asyncio
async def test_adopted_record_keeps_meta_provenance():
repo = FakeRepo()
meta = FakeMeta([
{
"id": "meta_9",
"name": "With detail",
"status": "ACTIVE",
"effective_status": "ACTIVE",
"created_time": "2026-07-01T10:00:00+0000",
},
])
created = await adopt_campaign(
repo, meta, ad_account_id="act_1", meta_campaign_id="meta_9", actor="owner",
)
provenance = created.advanced["imported_from_meta"]
assert provenance["effective_status"] == "ACTIVE"
assert provenance["created_time"] == "2026-07-01T10:00:00+0000"
@pytest.fixture()
def client(monkeypatch):
repo, meta = FakeRepo(), FakeMeta()
monkeypatch.setattr(campaigns_router, "get_repository", lambda: repo)
monkeypatch.setattr(campaigns_router, "get_meta_client", lambda: meta)
app = FastAPI()
app.include_router(campaigns_router.router, prefix="/api")
test_client = TestClient(app)
test_client.repo = repo
return test_client
def test_discover_endpoint_is_not_shadowed_by_the_id_route(client):
"""`/discover` is a literal path and must not be read as a campaign id."""
response = client.get("/api/campaigns/discover?ad_account_id=act_1")
assert response.status_code == 200
assert [c["id"] for c in response.json()] == ["meta_1", "meta_2"]
def test_adopt_endpoint_creates_and_is_idempotent(client):
body = {"ad_account_id": "act_1", "meta_campaign_id": "meta_1"}
first = client.post("/api/campaigns/adopt", json=body)
second = client.post("/api/campaigns/adopt", json=body)
assert first.status_code == 201
assert first.json()["origin"] == "imported"
assert second.json()["id"] == first.json()["id"]
assert len(client.repo.items) == 1
def test_adopt_unknown_campaign_returns_404(client):
response = client.post(
"/api/campaigns/adopt",
json={"ad_account_id": "act_1", "meta_campaign_id": "missing"},
)
assert response.status_code == 404