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:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user