first
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
"""Meta Marketing API - reading the ad account structure.
|
||||
|
||||
Ad accounts, campaigns, ad sets, ads and creatives. All read-only: this module
|
||||
issues GETs only. Creating or updating entities is deliberately not wired yet
|
||||
(see documentation/overview.md, "Deliberately not wired").
|
||||
|
||||
Every list endpoint takes the same three knobs:
|
||||
|
||||
* ``fields`` - Graph field list; each endpoint has a useful default so a
|
||||
caller who does not know the Graph schema still gets meaningful data;
|
||||
* ``limit`` / ``after`` - page size and cursor;
|
||||
* ``all_pages`` - **on by default**: the proxy follows ``paging.next`` so the
|
||||
caller gets the complete list without looping. Capped by ``META_MAX_PAGES``
|
||||
and flagged ``truncated`` if the cap is hit. Set it to false for a single
|
||||
raw page with the upstream cursors.
|
||||
|
||||
Credentials: see ``app.credentials.get_meta_credentials``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
|
||||
from ..clients.graph import MetaGraphClient
|
||||
from ..credentials import MetaCredentials, get_meta_credentials
|
||||
|
||||
router = APIRouter(prefix="/ads", tags=["meta-ads: entities"])
|
||||
|
||||
# Field defaults. Kept modest on purpose - asking Graph for every field on a
|
||||
# large account is slow and often trips per-field permission errors.
|
||||
_ACCOUNT_FIELDS = (
|
||||
"id,account_id,name,account_status,currency,timezone_name,business,"
|
||||
"amount_spent,balance,spend_cap"
|
||||
)
|
||||
_CAMPAIGN_FIELDS = (
|
||||
"id,name,status,effective_status,objective,buying_type,daily_budget,"
|
||||
"lifetime_budget,budget_remaining,start_time,stop_time,created_time,updated_time"
|
||||
)
|
||||
_ADSET_FIELDS = (
|
||||
"id,name,status,effective_status,campaign_id,daily_budget,lifetime_budget,"
|
||||
"billing_event,optimization_goal,bid_amount,targeting,start_time,end_time,"
|
||||
"created_time,updated_time"
|
||||
)
|
||||
_AD_FIELDS = (
|
||||
"id,name,status,effective_status,adset_id,campaign_id,creative,"
|
||||
"created_time,updated_time"
|
||||
)
|
||||
_CREATIVE_FIELDS = (
|
||||
"id,name,status,object_story_spec,asset_feed_spec,thumbnail_url,image_url,"
|
||||
"body,title,call_to_action_type,effective_object_story_id"
|
||||
)
|
||||
|
||||
|
||||
# Shared so every list endpoint documents pagination identically.
|
||||
_Q_ALL_PAGES = Query(
|
||||
True,
|
||||
description="Follow paging.next and return every page (default). The result "
|
||||
"carries pages_read and truncated; truncated:true means the META_MAX_PAGES "
|
||||
"cap was hit. Set false to get a single raw page with upstream cursors.",
|
||||
)
|
||||
|
||||
|
||||
def _client(creds: MetaCredentials) -> MetaGraphClient:
|
||||
return MetaGraphClient(creds, service_name="Meta Marketing API")
|
||||
|
||||
|
||||
def _account(account_id: str) -> str:
|
||||
"""Normalize an ad account id: both '123' and 'act_123' are accepted."""
|
||||
value = account_id.strip()
|
||||
return value if value.startswith("act_") else f"act_{value}"
|
||||
|
||||
|
||||
async def _read(
|
||||
creds: MetaCredentials,
|
||||
path: str,
|
||||
*,
|
||||
fields: str | None,
|
||||
limit: int | None = None,
|
||||
after: str | None = None,
|
||||
all_pages: bool = False,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
params: dict[str, Any] = {"fields": fields, "limit": limit, "after": after}
|
||||
if extra:
|
||||
params.update(extra)
|
||||
client = _client(creds)
|
||||
if all_pages:
|
||||
return await client.get_all_pages(path, params)
|
||||
return await client.get(path, params)
|
||||
|
||||
|
||||
# --- Ad accounts --------------------------------------------------------------
|
||||
@router.get("/me/adaccounts", summary="Ad accounts the token can access")
|
||||
async def my_ad_accounts(
|
||||
fields: str = Query(_ACCOUNT_FIELDS, description="Comma-separated Graph fields."),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None, description="Paging cursor from paging.cursors.after."),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds, "me/adaccounts", fields=fields, limit=limit, after=after, all_pages=all_pages
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/businesses", summary="Business Manager accounts the token can access")
|
||||
async def my_businesses(
|
||||
fields: str = Query("id,name,created_time", description="Comma-separated Graph fields."),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds, "me/businesses", fields=fields, limit=limit, after=after, all_pages=all_pages
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/businesses/{business_id}/adaccounts",
|
||||
summary="Ad accounts owned by (or shared with) a business",
|
||||
)
|
||||
async def business_ad_accounts(
|
||||
business_id: str = Path(..., description="Business Manager id."),
|
||||
owned: bool = Query(
|
||||
True,
|
||||
description="True = owned_ad_accounts (accounts the business owns); "
|
||||
"False = client_ad_accounts (accounts shared with it, agency case).",
|
||||
),
|
||||
fields: str = Query(_ACCOUNT_FIELDS),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
edge = "owned_ad_accounts" if owned else "client_ad_accounts"
|
||||
return await _read(
|
||||
creds,
|
||||
f"{business_id.strip()}/{edge}",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", summary="Ad account detail")
|
||||
async def ad_account(
|
||||
account_id: str = Path(..., description="Ad account id, with or without the act_ prefix."),
|
||||
fields: str = Query(_ACCOUNT_FIELDS),
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(creds, _account(account_id), fields=fields)
|
||||
|
||||
|
||||
# --- Campaigns ----------------------------------------------------------------
|
||||
@router.get("/accounts/{account_id}/campaigns", summary="Campaigns in an ad account")
|
||||
async def account_campaigns(
|
||||
account_id: str = Path(..., description="Ad account id, with or without act_."),
|
||||
fields: str = Query(_CAMPAIGN_FIELDS),
|
||||
effective_status: str | None = Query(
|
||||
None,
|
||||
description='Optional JSON array of statuses to keep, e.g. ["ACTIVE","PAUSED"].',
|
||||
),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{_account(account_id)}/campaigns",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
extra={"effective_status": effective_status},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}", summary="Campaign detail")
|
||||
async def campaign(
|
||||
campaign_id: str = Path(...),
|
||||
fields: str = Query(_CAMPAIGN_FIELDS),
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(creds, campaign_id.strip(), fields=fields)
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}/adsets", summary="Ad sets in a campaign")
|
||||
async def campaign_adsets(
|
||||
campaign_id: str = Path(...),
|
||||
fields: str = Query(_ADSET_FIELDS),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{campaign_id.strip()}/adsets",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}/ads", summary="Ads in a campaign")
|
||||
async def campaign_ads(
|
||||
campaign_id: str = Path(...),
|
||||
fields: str = Query(_AD_FIELDS),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{campaign_id.strip()}/ads",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
)
|
||||
|
||||
|
||||
# --- Ad sets ------------------------------------------------------------------
|
||||
@router.get("/accounts/{account_id}/adsets", summary="Ad sets in an ad account")
|
||||
async def account_adsets(
|
||||
account_id: str = Path(...),
|
||||
fields: str = Query(_ADSET_FIELDS),
|
||||
effective_status: str | None = Query(
|
||||
None, description='Optional JSON array, e.g. ["ACTIVE"].'
|
||||
),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{_account(account_id)}/adsets",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
extra={"effective_status": effective_status},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/adsets/{adset_id}", summary="Ad set detail")
|
||||
async def adset(
|
||||
adset_id: str = Path(...),
|
||||
fields: str = Query(_ADSET_FIELDS),
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(creds, adset_id.strip(), fields=fields)
|
||||
|
||||
|
||||
@router.get("/adsets/{adset_id}/ads", summary="Ads in an ad set")
|
||||
async def adset_ads(
|
||||
adset_id: str = Path(...),
|
||||
fields: str = Query(_AD_FIELDS),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{adset_id.strip()}/ads",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
)
|
||||
|
||||
|
||||
# --- Ads and creatives --------------------------------------------------------
|
||||
@router.get("/accounts/{account_id}/ads", summary="Ads in an ad account")
|
||||
async def account_ads(
|
||||
account_id: str = Path(...),
|
||||
fields: str = Query(_AD_FIELDS),
|
||||
effective_status: str | None = Query(
|
||||
None, description='Optional JSON array, e.g. ["ACTIVE"].'
|
||||
),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{_account(account_id)}/ads",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
extra={"effective_status": effective_status},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ads/{ad_id}", summary="Ad detail")
|
||||
async def ad(
|
||||
ad_id: str = Path(...),
|
||||
fields: str = Query(_AD_FIELDS),
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(creds, ad_id.strip(), fields=fields)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/accounts/{account_id}/adcreatives", summary="Ad creatives in an ad account"
|
||||
)
|
||||
async def account_creatives(
|
||||
account_id: str = Path(...),
|
||||
fields: str = Query(_CREATIVE_FIELDS),
|
||||
limit: int | None = Query(None, ge=1, le=500),
|
||||
after: str | None = Query(None),
|
||||
all_pages: bool = _Q_ALL_PAGES,
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(
|
||||
creds,
|
||||
f"{_account(account_id)}/adcreatives",
|
||||
fields=fields,
|
||||
limit=limit,
|
||||
after=after,
|
||||
all_pages=all_pages,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/adcreatives/{creative_id}", summary="Ad creative detail")
|
||||
async def creative(
|
||||
creative_id: str = Path(...),
|
||||
fields: str = Query(_CREATIVE_FIELDS),
|
||||
creds: MetaCredentials = Depends(get_meta_credentials),
|
||||
) -> Any:
|
||||
return await _read(creds, creative_id.strip(), fields=fields)
|
||||
Reference in New Issue
Block a user