"""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)