This commit is contained in:
debu-2 2026-10-12 23:19:09 +05:30
parent 8726c931fa
commit 8c46d091ff
49 changed files with 1607 additions and 712 deletions

View file

@ -7,6 +7,7 @@ needs to call a follow-up action.
from __future__ import annotations
import json
import re
import uuid
from datetime import datetime, timedelta, timezone
@ -120,6 +121,72 @@ def act_id(value: str) -> str:
return raw if raw.startswith("act_") else f"act_{raw}"
def _equal_filter(field: str, value: str) -> str:
return json.dumps([{"field": field, "operator": "EQUAL", "value": value}])
def _in_filter(field: str, values: list[str]) -> str:
return json.dumps([{"field": field, "operator": "IN", "value": values}])
def _is_ads_api_path(path: str) -> bool:
"""ads_read / ads_management live on Marketing API paths, not Page/Business Graph."""
cleaned = path.lstrip("/")
if cleaned == "me/adaccounts" or cleaned.startswith("me/adaccounts?"):
return True
if cleaned.startswith("act_"):
return True
return bool(re.fullmatch(r"\d+(?:/[A-Za-z_]+)?", cleaned))
async def ads_api(
db: Session,
conn: MetaConnection,
method: str,
path: str,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Marketing API for ads_read and ads_management (campaigns, ad sets, ads, insights)."""
if not _is_ads_api_path(path):
raise MetaApiError(500, "graph_error", "Ads operations must use the Marketing API on an ad account.")
return await graph_as_user(db, conn, method, path.lstrip("/"), params, body)
async def marketing_get(
db: Session,
conn: MetaConnection,
account_id: str,
edge: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""GET /act_{id}/{edge} — Ads Insights / Ads Management read."""
return await ads_api(db, conn, "GET", f"{act_id(account_id)}/{edge.lstrip('/')}", params)
async def marketing_post(
db: Session,
conn: MetaConnection,
account_id: str,
edge: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""POST /act_{id}/{edge} — Ads Management write."""
return await ads_api(db, conn, "POST", f"{act_id(account_id)}/{edge.lstrip('/')}", body=body)
async def ads_object_post(
db: Session,
conn: MetaConnection,
object_id: str,
body: dict[str, Any],
) -> dict[str, Any]:
"""POST /{campaign|adset|ad id} status and field updates (Marketing API object)."""
if not re.fullmatch(r"\d+", str(object_id)):
raise MetaApiError(422, "validation", "That Ads object is not valid for this ad account.")
return await ads_api(db, conn, "POST", str(object_id), body=body)
def currency_offset(currency: str) -> int:
return 1 if (currency or "").upper() in ZERO_DECIMAL else 100
@ -178,7 +245,6 @@ def ensure_configured() -> None:
def require_connection(db: Session, user: UserAccount) -> MetaConnection:
ensure_configured()
conn = load_connection(db, user)
if not conn or conn.status == "disconnected" or not conn.access_token:
raise MetaApiError(409, "not_connected", "Connect Meta before using this screen.")
@ -235,6 +301,7 @@ def audit(
def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
redirect_uri = settings.meta_oauth_redirect_uri_resolved
if not conn or conn.status == "disconnected":
return {
"configured": settings.meta_oauth_configured,
@ -248,6 +315,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
"selected_page_name": "",
"selected_ad_account_name": "",
"last_error": "",
"oauth_redirect_uri": redirect_uri,
}
return {
"configured": settings.meta_oauth_configured,
@ -265,6 +333,7 @@ def public_connection(conn: MetaConnection | None) -> dict[str, Any]:
"selected_page_name": conn.selected_page_name,
"selected_ad_account_name": conn.selected_ad_account_name,
"last_error": conn.last_error,
"oauth_redirect_uri": redirect_uri,
}
@ -323,6 +392,52 @@ async def finish_oauth(db: Session, code: str, state: str) -> None:
audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Meta Login completed")
async def connect_with_user_token(db: Session, user: UserAccount, access_token: str) -> dict[str, Any]:
"""Store a customer-supplied user token without the Facebook login redirect."""
token = "".join(str(access_token or "").split())
if token.lower().startswith("bearer"):
token = token[6:]
if len(token) < 20:
raise MetaApiError(400, "validation", "Paste the access token from Meta.")
profile = await graph("GET", "me", token, {"fields": "id,name"})
permission_rows = await graph("GET", "me/permissions", token)
granted = [
row.get("permission")
for row in permission_rows.get("data") or []
if isinstance(row, dict) and row.get("status") == "granted" and row.get("permission")
]
scopes = [scope for scope in META_SCOPES if scope in set(granted)]
if not any(scope != "public_profile" for scope in scopes):
raise MetaApiError(
403,
"permission_denied",
"This token cannot see Pages or ad accounts. Use a token that includes those permissions.",
)
stored = token
expires_at = None
if settings.meta_app_id and settings.meta_graph_app_secret:
long_token = await _exchange_long_lived(token)
if long_token.get("access_token"):
stored = str(long_token["access_token"])
expires_in = long_token.get("expires_in")
if expires_in:
expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
conn = load_connection(db, user) or MetaConnection(user_id=user.id)
conn.meta_user_id = str(profile.get("id") or "")
conn.meta_user_name = str(profile.get("name") or "")
conn.granted_scopes = ",".join(scopes)
conn.access_token = stored
conn.status = "connected"
conn.last_error = ""
conn.last_graph_code = None
conn.connected_at = datetime.now(timezone.utc)
conn.token_expires_at = expires_at
db.add(conn)
db.commit()
audit(db, user, "connection.authorized", asset_type="connection", asset_label=conn.meta_user_name, detail="Access token saved")
return public_connection(conn)
async def _exchange_code(code: str) -> dict[str, Any]:
return await graph(
"GET",
@ -557,48 +672,27 @@ async def list_businesses(db: Session, user: UserAccount) -> dict[str, Any]:
async def list_ad_accounts(db: Session, user: UserAccount) -> dict[str, Any]:
conn = require_connection(db, user)
if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management") or has_scope(conn, "business_management")):
if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")):
raise MetaApiError(
403,
"permission_denied",
"Meta has not granted Ads reporting or Business portfolio for this connection.",
"Meta has not granted Ads reporting or Ads management for this connection.",
permission="ads_read",
)
data = await ads_api(
db,
conn,
"GET",
"me/adaccounts",
{"fields": "id,name,account_status,currency,amount_spent,business{name}", "limit": "50"},
)
rows: dict[str, dict[str, Any]] = {}
if has_scope(conn, "ads_read"):
data = await graph_as_user(
db,
conn,
"GET",
"me/adaccounts",
{"fields": "id,name,account_status,currency,amount_spent", "limit": "50"},
)
for row in data.get("data") or []:
if isinstance(row, dict) and row.get("id"):
rows[act_id(str(row["id"]))] = row
if has_scope(conn, "business_management"):
businesses = await graph_as_user(db, conn, "GET", "me/businesses", {"fields": "id,name", "limit": "25"})
for business in businesses.get("data") or []:
if not isinstance(business, dict) or not business.get("id"):
continue
business_name = str(business.get("name") or "")
for edge in ("owned_ad_accounts", "client_ad_accounts"):
try:
owned = await graph_as_user(
db,
conn,
"GET",
f"{business['id']}/{edge}",
{"fields": "id,name,account_status,currency,amount_spent", "limit": "50"},
)
except MetaApiError as exc:
if exc.code == "permission_denied":
continue
raise
for row in owned.get("data") or []:
if isinstance(row, dict) and row.get("id"):
current = rows.setdefault(act_id(str(row["id"])), row)
current["business_name"] = business_name
for row in data.get("data") or []:
if not isinstance(row, dict) or not row.get("id"):
continue
business = row.get("business") if isinstance(row.get("business"), dict) else {}
row["business_name"] = business.get("name") or ""
rows[act_id(str(row["id"]))] = row
accounts = [
_account_summary(row, selected=act_id(str(row.get("id"))) == act_id(conn.selected_ad_account_id))
for row in rows.values()
@ -625,7 +719,7 @@ def _account_summary(row: dict[str, Any], selected: bool) -> dict[str, Any]:
async def select_ad_account(db: Session, user: UserAccount, account_id: str) -> dict[str, Any]:
conn = require_connection(db, user)
if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management") or has_scope(conn, "business_management")):
if not (has_scope(conn, "ads_read") or has_scope(conn, "ads_management")):
raise MetaApiError(403, "permission_denied", "Meta has not granted Ads reporting for this connection.", permission="ads_read")
accounts = await list_ad_accounts(db, user)
match = next((row for row in accounts["accounts"] if row["id"] == act_id(account_id)), None)
@ -646,20 +740,142 @@ async def _selected_account(db: Session, user: UserAccount, manage: bool) -> tup
require_scope(conn, "ads_read")
if not conn.selected_ad_account_id:
raise MetaApiError(409, "no_ad_account", "Select an ad account before opening campaigns.")
accounts = await list_ad_accounts(db, user)
match = next((row for row in accounts["accounts"] if row["id"] == act_id(conn.selected_ad_account_id)), None)
if not match:
raise MetaApiError(409, "no_ad_account", "The selected ad account is no longer available to this connection.")
return conn, match["id"]
return conn, act_id(conn.selected_ad_account_id)
async def _account_object(
db: Session,
conn: MetaConnection,
account_id: str,
edge: str,
object_id: str,
fields: str,
) -> dict[str, Any]:
data = await marketing_get(
db,
conn,
account_id,
edge,
{"fields": fields, "filtering": _equal_filter("id", object_id), "limit": "1"},
)
rows = [row for row in data.get("data") or [] if isinstance(row, dict) and str(row.get("id")) == object_id]
if not rows:
raise MetaApiError(404, "not_found", "That object is not in the selected ad account.")
return rows[0]
def _adset_summary(row: dict[str, Any], currency: str) -> dict[str, Any]:
campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {}
daily = minor_to_major(row.get("daily_budget"), currency)
lifetime = minor_to_major(row.get("lifetime_budget"), currency)
budget = money(daily, currency) if daily is not None else money(lifetime, currency) if lifetime is not None else None
goal = str(row.get("optimization_goal") or "").replace("_", " ").title()
return {
"id": str(row.get("id")),
"name": row.get("name") or "Ad set",
"campaign_id": str(row.get("campaign_id") or campaign.get("id") or ""),
"campaign_name": campaign.get("name") or "",
"status": row.get("effective_status") or row.get("status") or "",
"optimization_goal": goal or "—",
"billing_event": str(row.get("billing_event") or "").replace("_", " ").title() or "—",
"budget": budget,
"start_time": row.get("start_time"),
"end_time": row.get("end_time"),
}
async def list_adsets(db: Session, user: UserAccount, campaign_id: str | None = None) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
currency = await _account_currency(db, conn, account_id)
params: dict[str, Any] = {
"fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time,campaign{name}",
"limit": "50",
}
if campaign_id:
params["filtering"] = _equal_filter("campaign.id", campaign_id)
data = await marketing_get(db, conn, account_id, "adsets", params)
adsets = [_adset_summary(row, currency) for row in data.get("data") or [] if isinstance(row, dict) and row.get("id")]
return {
"account_name": conn.selected_ad_account_name,
"currency": currency,
"adsets": adsets,
}
async def campaign_detail(db: Session, user: UserAccount, campaign_id: str) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
current = await _account_object(
db,
conn,
account_id,
"campaigns",
campaign_id,
"id,name,status,effective_status,objective,created_time",
)
currency = await _account_currency(db, conn, account_id)
adsets_payload = await marketing_get(
db,
conn,
account_id,
"adsets",
{
"fields": "id,name,status,effective_status,daily_budget,lifetime_budget,optimization_goal,billing_event,campaign_id,start_time,end_time",
"filtering": _equal_filter("campaign.id", campaign_id),
"limit": "50",
},
)
adsets = [_adset_summary(row, currency) for row in adsets_payload.get("data") or [] if isinstance(row, dict) and row.get("id")]
insights_error = None
metrics: dict[str, Any] = {}
try:
report = await marketing_get(
db,
conn,
account_id,
"insights",
{
"fields": "campaign_id,spend,impressions,clicks,reach,ctr",
"date_preset": "last_30d",
"level": "campaign",
"filtering": _in_filter("campaign.id", [campaign_id]),
},
)
row = (report.get("data") or [{}])[0] if isinstance(report.get("data"), list) else {}
if isinstance(row, dict):
spend = _float(row.get("spend"))
metrics = {
"spend": money(spend, currency) if row else None,
"impressions": _int(row.get("impressions")),
"clicks": _int(row.get("clicks")),
"reach": _int(row.get("reach")),
"ctr": _float(row.get("ctr")),
}
except MetaApiError as exc:
insights_error = {"code": exc.code, "message": exc.message}
return {
"account_name": conn.selected_ad_account_name,
"currency": currency,
"can_manage": has_scope(conn, "ads_management"),
"campaign": {
"id": str(current.get("id")),
"name": current.get("name") or "Campaign",
"status": current.get("effective_status") or current.get("status") or "",
"objective": OBJECTIVE_LABELS.get(str(current.get("objective")), str(current.get("objective") or "").replace("OUTCOME_", "").title()),
"created_time": current.get("created_time"),
},
"adsets": adsets,
"metrics": metrics,
"insights_error": insights_error,
}
async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
data = await graph_as_user(
data = await marketing_get(
db,
conn,
"GET",
f"{account_id}/campaigns",
account_id,
"campaigns",
{
"fields": "id,name,status,effective_status,objective,created_time",
"limit": "50",
@ -668,11 +884,11 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
insights: dict[str, dict[str, Any]] = {}
insights_error = None
try:
report = await graph_as_user(
report = await marketing_get(
db,
conn,
"GET",
f"{account_id}/insights",
account_id,
"insights",
{
"level": "campaign",
"fields": "campaign_id,spend,impressions,clicks,ctr,reach",
@ -717,7 +933,7 @@ async def list_campaigns(db: Session, user: UserAccount) -> dict[str, Any]:
async def _account_currency(db: Session, conn: MetaConnection, account_id: str) -> str:
try:
data = await graph_as_user(db, conn, "GET", account_id, {"fields": "currency"})
data = await ads_api(db, conn, "GET", account_id, {"fields": "currency,account_status,name"})
return str(data.get("currency") or "")
except MetaApiError:
return ""
@ -741,13 +957,11 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str,
if status not in {"ACTIVE", "PAUSED"}:
raise MetaApiError(422, "validation", "Choose pause or resume.")
conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(db, conn, "GET", campaign_id, {"fields": "id,name,account_id"})
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.")
current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name")
name = str(current.get("name") or "Campaign")
action = "campaign.resume" if status == "ACTIVE" else "campaign.pause"
try:
await graph_as_user(db, conn, "POST", campaign_id, body={"status": status})
await ads_object_post(db, conn, campaign_id, {"status": status})
except MetaApiError as exc:
audit(db, user, action, asset_type="campaign", asset_label=name, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@ -757,23 +971,27 @@ async def set_campaign_status(db: Session, user: UserAccount, campaign_id: str,
async def update_campaign(db: Session, user: UserAccount, campaign_id: str, name: str | None, daily_budget: float | None) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(db, conn, "GET", campaign_id, {"fields": "id,name,account_id"})
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That campaign is not in the selected ad account.")
current = await _account_object(db, conn, account_id, "campaigns", campaign_id, "id,name")
if not name and daily_budget is None:
raise MetaApiError(422, "validation", "Add a name or daily budget to update.")
label = name or str(current.get("name") or "Campaign")
try:
if name:
await graph_as_user(db, conn, "POST", campaign_id, body={"name": name[:120]})
await ads_object_post(db, conn, campaign_id, {"name": name[:120]})
if daily_budget is not None:
currency = await _account_currency(db, conn, account_id)
adsets = await graph_as_user(db, conn, "GET", f"{campaign_id}/adsets", {"fields": "id,name", "limit": "5"})
adsets = await marketing_get(
db,
conn,
account_id,
"adsets",
{"fields": "id,name", "filtering": _equal_filter("campaign.id", campaign_id), "limit": "5"},
)
rows = [row for row in adsets.get("data") or [] if isinstance(row, dict) and row.get("id")]
if len(rows) != 1:
raise MetaApiError(422, "feature_unavailable", "Budget can be edited here only when the campaign has one ad set.")
minor = str(int(round(daily_budget * currency_offset(currency))))
await graph_as_user(db, conn, "POST", str(rows[0]["id"]), body={"daily_budget": minor})
await ads_object_post(db, conn, str(rows[0]["id"]), {"daily_budget": minor})
except MetaApiError as exc:
audit(db, user, "campaign.edit", asset_type="campaign", asset_label=label, asset_id=campaign_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@ -820,12 +1038,12 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
status = "ACTIVE" if activate else "PAUSED"
created_campaign = ""
try:
campaign = await graph_as_user(
campaign = await marketing_post(
db,
conn,
"POST",
f"{account_id}/campaigns",
body={
account_id,
"campaigns",
{
"name": name,
"objective": objective,
"status": "PAUSED",
@ -855,21 +1073,21 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
adset_body["destination_type"] = "WHATSAPP"
elif destination == "website":
adset_body["destination_type"] = "WEBSITE"
adset = await graph_as_user(db, conn, "POST", f"{account_id}/adsets", body=adset_body)
adset = await marketing_post(db, conn, account_id, "adsets", adset_body)
story = _story_spec(page_id, destination, message, headline, link, cta, page_link)
creative = await graph_as_user(
creative = await marketing_post(
db,
conn,
"POST",
f"{account_id}/adcreatives",
body={"name": f"{name} creative", "object_story_spec": story},
account_id,
"adcreatives",
{"name": f"{name} creative", "object_story_spec": story},
)
await graph_as_user(
await marketing_post(
db,
conn,
"POST",
f"{account_id}/ads",
body={
account_id,
"ads",
{
"name": f"{name} ad",
"adset_id": adset.get("id"),
"creative": {"creative_id": creative.get("id")},
@ -877,8 +1095,8 @@ async def publish_campaign(db: Session, user: UserAccount, payload: dict[str, An
},
)
if activate and created_campaign:
await graph_as_user(db, conn, "POST", created_campaign, body={"status": "ACTIVE"})
await graph_as_user(db, conn, "POST", str(adset.get("id")), body={"status": "ACTIVE"})
await ads_object_post(db, conn, created_campaign, {"status": "ACTIVE"})
await ads_object_post(db, conn, str(adset.get("id")), {"status": "ACTIVE"})
except MetaApiError as exc:
detail = exc.message
if created_campaign:
@ -928,13 +1146,13 @@ def _story_spec(page_id: str, destination: str, message: str, headline: str, lin
async def list_ads(db: Session, user: UserAccount) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=False)
data = await graph_as_user(
data = await marketing_get(
db,
conn,
"GET",
f"{account_id}/ads",
account_id,
"ads",
{
"fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name}",
"fields": "id,name,status,effective_status,creative{title,body,image_url,thumbnail_url,call_to_action_type,object_story_spec},campaign{name},adset{name}",
"limit": "50",
},
)
@ -952,10 +1170,12 @@ def _ad_summary(row: dict[str, Any]) -> dict[str, Any]:
video = story.get("video_data") if isinstance(story.get("video_data"), dict) else {}
cta = link_data.get("call_to_action") if isinstance(link_data.get("call_to_action"), dict) else {}
campaign = row.get("campaign") if isinstance(row.get("campaign"), dict) else {}
adset = row.get("adset") if isinstance(row.get("adset"), dict) else {}
return {
"id": str(row.get("id")),
"name": row.get("name") or "Ad",
"campaign_name": campaign.get("name") or "",
"adset_name": adset.get("name") or "",
"status": row.get("effective_status") or row.get("status") or "",
"headline": link_data.get("name") or video.get("title") or creative.get("title") or "",
"message": link_data.get("message") or video.get("message") or creative.get("body") or "",
@ -968,19 +1188,18 @@ def _ad_summary(row: dict[str, Any]) -> dict[str, Any]:
async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[str, Any]) -> dict[str, Any]:
conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(
current = await _account_object(
db,
conn,
"GET",
account_id,
"ads",
ad_id,
{"fields": "id,name,account_id,creative{object_story_spec}"},
"id,name,creative{object_story_spec}",
)
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.")
label = str(payload.get("name") or current.get("name") or "Ad")
try:
if payload.get("name"):
await graph_as_user(db, conn, "POST", ad_id, body={"name": str(payload["name"])[:120]})
await ads_object_post(db, conn, ad_id, {"name": str(payload["name"])[:120]})
creative_fields = ("message", "headline", "call_to_action", "link")
if any(payload.get(field) for field in creative_fields):
creative = current.get("creative") if isinstance(current.get("creative"), dict) else {}
@ -995,11 +1214,11 @@ async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[st
link = str(payload.get("link") or "")
if link and not link.startswith(("http://", "https://")):
raise MetaApiError(422, "validation", "Creative links need to start with http or https.")
replacement = await graph_as_user(
replacement = await marketing_post(
db,
conn,
"POST",
f"{account_id}/adcreatives",
account_id,
"adcreatives",
body={
"name": f"{label} creative",
"object_story_spec": _story_spec(
@ -1013,7 +1232,7 @@ async def update_ad(db: Session, user: UserAccount, ad_id: str, payload: dict[st
),
},
)
await graph_as_user(db, conn, "POST", ad_id, body={"creative": {"creative_id": replacement.get("id")}})
await ads_object_post(db, conn, ad_id, {"creative": {"creative_id": replacement.get("id")}})
except MetaApiError as exc:
audit(db, user, "ad.edit", asset_type="ad", asset_label=label, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@ -1025,13 +1244,11 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str)
if status not in {"ACTIVE", "PAUSED"}:
raise MetaApiError(422, "validation", "Choose pause or resume.")
conn, account_id = await _selected_account(db, user, manage=True)
current = await graph_as_user(db, conn, "GET", ad_id, {"fields": "id,name,account_id"})
if not _same_account(current.get("account_id"), account_id):
raise MetaApiError(404, "not_found", "That ad is not in the selected ad account.")
current = await _account_object(db, conn, account_id, "ads", ad_id, "id,name")
name = str(current.get("name") or "Ad")
action = "ad.resume" if status == "ACTIVE" else "ad.pause"
try:
await graph_as_user(db, conn, "POST", ad_id, body={"status": status})
await ads_object_post(db, conn, ad_id, {"status": status})
except MetaApiError as exc:
audit(db, user, action, asset_type="ad", asset_label=name, asset_id=ad_id, status="failed", error_message=exc.message, graph_code=exc.graph_code)
raise
@ -1039,23 +1256,23 @@ async def set_ad_status(db: Session, user: UserAccount, ad_id: str, status: str)
return {"name": name, "status": status}
async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[str, Any]:
async def analytics(db: Session, user: UserAccount, date_preset: str, breakdowns: bool = True) -> dict[str, Any]:
if date_preset not in {"last_7d", "last_14d", "last_30d", "last_90d", "maximum"}:
raise MetaApiError(422, "validation", "Choose a supported date range.")
conn, account_id = await _selected_account(db, user, manage=False)
currency = await _account_currency(db, conn, account_id)
summary = await graph_as_user(
summary = await marketing_get(
db,
conn,
"GET",
f"{account_id}/insights",
account_id,
"insights",
{"fields": "spend,impressions,reach,clicks,ctr,cpc,cpm", "date_preset": date_preset},
)
daily = await graph_as_user(
daily = await marketing_get(
db,
conn,
"GET",
f"{account_id}/insights",
account_id,
"insights",
{
"fields": "spend,impressions,clicks,date_start",
"date_preset": date_preset,
@ -1063,6 +1280,34 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st
"limit": "100",
},
)
campaign_rows: list[dict[str, Any]] = []
adset_rows: list[dict[str, Any]] = []
ad_rows: list[dict[str, Any]] = []
if breakdowns:
campaign_rows = await _insights_rows(
db,
conn,
account_id,
date_preset,
"campaign",
"campaign_id,campaign_name,spend,impressions,clicks,ctr,reach",
)
adset_rows = await _insights_rows(
db,
conn,
account_id,
date_preset,
"adset",
"adset_id,adset_name,spend,impressions,clicks,ctr",
)
ad_rows = await _insights_rows(
db,
conn,
account_id,
date_preset,
"ad",
"ad_id,ad_name,spend,impressions,clicks,ctr",
)
row = (summary.get("data") or [{}])[0] if isinstance(summary.get("data"), list) else {}
if not isinstance(row, dict):
row = {}
@ -1094,6 +1339,43 @@ async def analytics(db: Session, user: UserAccount, date_preset: str) -> dict[st
"cpm": money(_float(row.get("cpm")), currency) if row.get("cpm") is not None else None,
},
"series": series,
"campaigns": [_insight_line(item, "campaign", currency) for item in campaign_rows],
"adsets": [_insight_line(item, "adset", currency) for item in adset_rows],
"ads": [_insight_line(item, "ad", currency) for item in ad_rows],
}
async def _insights_rows(
db: Session,
conn: MetaConnection,
account_id: str,
date_preset: str,
level: str,
fields: str,
) -> list[dict[str, Any]]:
try:
payload = await marketing_get(
db,
conn,
account_id,
"insights",
{"fields": fields, "date_preset": date_preset, "level": level, "limit": "50"},
)
except MetaApiError:
return []
return [row for row in payload.get("data") or [] if isinstance(row, dict)]
def _insight_line(row: dict[str, Any], level: str, currency: str) -> dict[str, Any]:
name = row.get(f"{level}_name") or row.get("campaign_name") or "Untitled"
spend = _float(row.get("spend"))
return {
"name": name,
"spend": money(spend, currency),
"impressions": _int(row.get("impressions")),
"clicks": _int(row.get("clicks")),
"ctr": _float(row.get("ctr")),
"reach": _int(row.get("reach")),
}
@ -1134,7 +1416,7 @@ async def overview(db: Session, user: UserAccount) -> dict[str, Any]:
return result
result["businesses"] = await _section(lambda: list_businesses(db, user), "businesses")
result["pages"] = await _section(lambda: list_pages(db, user), "pages")
if has_scope(conn, "ads_read") or has_scope(conn, "business_management"):
if has_scope(conn, "ads_read") or has_scope(conn, "ads_management"):
result["ad_accounts"] = await _section(lambda: list_ad_accounts(db, user), "accounts")
else:
result["ad_accounts"] = {
@ -1145,7 +1427,7 @@ async def overview(db: Session, user: UserAccount) -> dict[str, Any]:
}
if has_scope(conn, "ads_read") and conn.selected_ad_account_id:
try:
report = await analytics(db, user, "last_30d")
report = await analytics(db, user, "last_30d", breakdowns=False)
result["performance"] = {"state": "ok", "message": "", "report": report}
except MetaApiError as exc:
result["performance"] = {"state": exc.code, "message": exc.message, "permission": exc.permission, "report": None}
@ -1211,6 +1493,7 @@ def settings_view(db: Session, user: UserAccount) -> dict[str, Any]:
debug = {
"app_configured": settings.meta_oauth_configured,
"redirect_configured": bool(settings.meta_oauth_redirect_uri_resolved),
"oauth_redirect_uri": settings.meta_oauth_redirect_uri_resolved,
"login_config_set": bool(settings.meta_login_config_id),
"granted_scopes": sorted(scopes_of(conn)) if conn else [],
"last_error": conn.last_error if conn else "",