This commit is contained in:
parent
8726c931fa
commit
8c46d091ff
49 changed files with 1607 additions and 712 deletions
|
|
@ -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 "",
|
||||
|
|
|
|||
|
|
@ -13,11 +13,14 @@ from .meta_service import (
|
|||
analytics,
|
||||
authorization_url,
|
||||
disconnect,
|
||||
connect_with_user_token,
|
||||
finish_oauth,
|
||||
http_meta,
|
||||
list_ad_accounts,
|
||||
list_ads,
|
||||
list_adsets,
|
||||
list_businesses,
|
||||
campaign_detail,
|
||||
list_campaigns,
|
||||
list_pages,
|
||||
overview,
|
||||
|
|
@ -43,6 +46,10 @@ class Confirm(BaseModel):
|
|||
confirm: bool = False
|
||||
|
||||
|
||||
class ManualToken(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=4096)
|
||||
|
||||
|
||||
class IdBody(Confirm):
|
||||
id: str = Field(min_length=1, max_length=64)
|
||||
|
||||
|
|
@ -118,6 +125,14 @@ async def oauth_callback(code: str = "", state: str = "", error: str = "", db: S
|
|||
return _redirect("connected=1")
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def save_token(body: ManualToken, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return await connect_with_user_token(db, user, body.access_token)
|
||||
except MetaApiError as exc:
|
||||
raise http_meta(exc) from exc
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
def disconnect_meta(body: Confirm, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
_confirmed(body)
|
||||
|
|
@ -191,6 +206,26 @@ async def campaigns(user: UserAccount = Depends(current_user), db: Session = Dep
|
|||
raise http_meta(exc) from exc
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}")
|
||||
async def campaign(campaign_id: str, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
try:
|
||||
return await campaign_detail(db, user, campaign_id)
|
||||
except MetaApiError as exc:
|
||||
raise http_meta(exc) from exc
|
||||
|
||||
|
||||
@router.get("/adsets")
|
||||
async def adsets(
|
||||
campaign_id: str | None = None,
|
||||
user: UserAccount = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
try:
|
||||
return await list_adsets(db, user, campaign_id or None)
|
||||
except MetaApiError as exc:
|
||||
raise http_meta(exc) from exc
|
||||
|
||||
|
||||
@router.post("/campaigns")
|
||||
async def create_campaign(body: CampaignPublish, user: UserAccount = Depends(current_user), db: Session = Depends(get_db)) -> dict:
|
||||
_confirmed(body)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from .whatsapp_onboarding import (
|
|||
account_detail,
|
||||
account_logs,
|
||||
complete_signup,
|
||||
connect_with_token,
|
||||
customer_token_for_phone,
|
||||
developer_config,
|
||||
disconnect_account,
|
||||
|
|
@ -174,6 +175,12 @@ class RegisterPhoneRequest(BaseModel):
|
|||
pin: str | None = Field(default=None, max_length=6)
|
||||
|
||||
|
||||
class ManualTokenRequest(BaseModel):
|
||||
access_token: str = Field(min_length=1, max_length=4096)
|
||||
waba_id: str = Field(min_length=1, max_length=64)
|
||||
phone_number_id: str | None = Field(default=None, max_length=64)
|
||||
|
||||
|
||||
class SignupCompleteRequest(BaseModel):
|
||||
event: str = Field(min_length=1, max_length=80)
|
||||
code: str | None = Field(default=None, max_length=2048)
|
||||
|
|
@ -208,6 +215,21 @@ async def whatsapp_signup_complete(
|
|||
return await complete_signup(db, user, payload.model_dump())
|
||||
|
||||
|
||||
@router.post("/connections/token")
|
||||
async def whatsapp_connect_token(
|
||||
payload: ManualTokenRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: UserAccount = Depends(current_user),
|
||||
) -> dict:
|
||||
return await connect_with_token(
|
||||
db,
|
||||
user,
|
||||
payload.access_token,
|
||||
payload.waba_id,
|
||||
payload.phone_number_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}")
|
||||
def whatsapp_account_detail(
|
||||
account_id: str,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ addressed by another tenant.
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -101,11 +102,13 @@ def _quality_label(value: str | None) -> str | None:
|
|||
|
||||
|
||||
def _graph_error_message(data: Any, fallback: str) -> str:
|
||||
message = fallback
|
||||
if isinstance(data, dict):
|
||||
error = data.get("error")
|
||||
if isinstance(error, dict) and error.get("message"):
|
||||
return str(error["message"])[:500]
|
||||
return fallback
|
||||
message = str(error["message"])
|
||||
message = re.sub(r"(access_token|token|secret)=([^&\s]+)", r"\1=redacted", message, flags=re.I)
|
||||
return message[:500]
|
||||
|
||||
|
||||
async def _graph(
|
||||
|
|
@ -607,6 +610,78 @@ async def complete_signup(db: Session, user: UserAccount, payload: dict[str, Any
|
|||
return {"status": overall, "message": message, "accounts": [serialize_account(account) for account in accounts]}
|
||||
|
||||
|
||||
_META_ID = re.compile(r"^\d{6,32}$")
|
||||
|
||||
|
||||
def _clean_token(value: str) -> str:
|
||||
token = "".join(str(value or "").split())
|
||||
if token.lower().startswith("bearer"):
|
||||
token = token[6:]
|
||||
return token
|
||||
|
||||
|
||||
async def connect_with_token(
|
||||
db: Session,
|
||||
user: UserAccount,
|
||||
access_token: str,
|
||||
waba_id: str,
|
||||
phone_number_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Store a customer-supplied WhatsApp token and provision that account.
|
||||
|
||||
This is the non-embedded alternative to Embedded Signup. The token is
|
||||
kept on the connection row and is not returned to the browser.
|
||||
"""
|
||||
token = _clean_token(access_token)
|
||||
account_id = "".join(str(waba_id or "").split())
|
||||
phone_id = "".join(str(phone_number_id or "").split()) or None
|
||||
if len(token) < 20:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Paste the access token from Meta.")
|
||||
if not _META_ID.match(account_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Enter the WhatsApp Business account ID from Meta. It is a number.",
|
||||
)
|
||||
if phone_id and not _META_ID.match(phone_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Phone number ID should be the number Meta shows for that phone.",
|
||||
)
|
||||
|
||||
connection = WhatsAppMetaConnection(
|
||||
user_id=user.id,
|
||||
status="pending",
|
||||
granted_scopes="manual",
|
||||
access_token=token,
|
||||
)
|
||||
db.add(connection)
|
||||
db.flush()
|
||||
_audit(
|
||||
db,
|
||||
user,
|
||||
"token_saved",
|
||||
"Customer saved a WhatsApp access token",
|
||||
connection_id=connection.id,
|
||||
waba_id=account_id,
|
||||
)
|
||||
account = await _provision_waba(db, user, connection, token, account_id, phone_id)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
if account.status == "connected":
|
||||
overall = "connected"
|
||||
message = "WhatsApp Business is connected."
|
||||
elif account.status == "incomplete":
|
||||
overall = "incomplete"
|
||||
message = account.last_error or "The account was saved, but the phone number still needs verification."
|
||||
elif account.status == "expired":
|
||||
overall = "expired"
|
||||
message = account.last_error or "Meta did not accept this access token."
|
||||
else:
|
||||
overall = "failed"
|
||||
message = account.last_error or "Urmi could not finish WhatsApp setup."
|
||||
return {"status": overall, "message": message, "accounts": [serialize_account(account)]}
|
||||
|
||||
|
||||
async def refresh_account(db: Session, user: UserAccount, account_id: Any) -> dict[str, Any]:
|
||||
account = _owned_account(db, user, account_id)
|
||||
connection = account.connection
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue