interloper-api 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- interloper_api/__init__.py +3 -0
- interloper_api/app.py +100 -0
- interloper_api/dependencies.py +293 -0
- interloper_api/email.py +79 -0
- interloper_api/routes/__init__.py +0 -0
- interloper_api/routes/agent.py +229 -0
- interloper_api/routes/assets.py +304 -0
- interloper_api/routes/auth.py +241 -0
- interloper_api/routes/backfills.py +87 -0
- interloper_api/routes/catalog.py +46 -0
- interloper_api/routes/destinations.py +118 -0
- interloper_api/routes/external/__init__.py +48 -0
- interloper_api/routes/external/amazon_ads.py +82 -0
- interloper_api/routes/external/facebook_ads.py +55 -0
- interloper_api/routes/external/google_ads.py +104 -0
- interloper_api/routes/external/pinterest_ads.py +77 -0
- interloper_api/routes/external/snapchat_ads.py +86 -0
- interloper_api/routes/jobs.py +216 -0
- interloper_api/routes/oauth.py +345 -0
- interloper_api/routes/organisations.py +278 -0
- interloper_api/routes/resources.py +180 -0
- interloper_api/routes/runs.py +177 -0
- interloper_api/routes/sources.py +187 -0
- interloper_api/routes/ws.py +164 -0
- interloper_api-0.2.0.dist-info/METADATA +18 -0
- interloper_api-0.2.0.dist-info/RECORD +27 -0
- interloper_api-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Destinations API: CRUD for standalone destination instances."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from interloper.errors import NotFoundError
|
|
10
|
+
from interloper_db import Store
|
|
11
|
+
from interloper_db.models import Destination, DestinationResource
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class DestinationCreateRequest(BaseModel):
|
|
20
|
+
"""Request body for creating/updating a destination."""
|
|
21
|
+
|
|
22
|
+
key: str
|
|
23
|
+
name: str | None = None
|
|
24
|
+
config: dict[str, Any] | None = None
|
|
25
|
+
resources: dict[str, str] | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DestinationResponse(BaseModel):
|
|
29
|
+
"""Response body for a destination."""
|
|
30
|
+
|
|
31
|
+
id: UUID
|
|
32
|
+
key: str
|
|
33
|
+
name: str | None = None
|
|
34
|
+
config: dict[str, Any] | None = None
|
|
35
|
+
resources: dict[str, str] = {}
|
|
36
|
+
created_at: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _resource_map(dest: Destination) -> dict[str, str]:
|
|
40
|
+
"""Build {slot_key: resource_id} from junction rows."""
|
|
41
|
+
from interloper_db.engine import get_engine
|
|
42
|
+
from sqlmodel import Session as _S
|
|
43
|
+
from sqlmodel import select as _sel
|
|
44
|
+
|
|
45
|
+
with _S(get_engine()) as s:
|
|
46
|
+
rows = s.exec(_sel(DestinationResource).where(DestinationResource.destination_id == dest.id)).all()
|
|
47
|
+
return {r.key: str(r.resource_id) for r in rows}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _to_response(dest: Destination) -> DestinationResponse:
|
|
51
|
+
return DestinationResponse(
|
|
52
|
+
id=dest.id, # type: ignore[arg-type]
|
|
53
|
+
key=dest.key,
|
|
54
|
+
name=dest.name,
|
|
55
|
+
config=dest.config,
|
|
56
|
+
resources=_resource_map(dest),
|
|
57
|
+
created_at=str(dest.created_at) if dest.created_at else None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/")
|
|
62
|
+
def list_destinations(
|
|
63
|
+
user: object = Depends(require_viewer),
|
|
64
|
+
org_id: UUID = Depends(get_org_id),
|
|
65
|
+
store: Store = Depends(get_store),
|
|
66
|
+
) -> list[DestinationResponse]:
|
|
67
|
+
"""List all destinations for the current organisation."""
|
|
68
|
+
destinations = store.list_destinations(org_id)
|
|
69
|
+
return [_to_response(d) for d in destinations]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@router.post("/")
|
|
73
|
+
def create_destination(
|
|
74
|
+
body: DestinationCreateRequest,
|
|
75
|
+
user: object = Depends(require_editor),
|
|
76
|
+
org_id: UUID = Depends(get_org_id),
|
|
77
|
+
store: Store = Depends(get_store),
|
|
78
|
+
) -> DestinationResponse:
|
|
79
|
+
"""Create a new destination."""
|
|
80
|
+
dest = store.create_destination(
|
|
81
|
+
org_id,
|
|
82
|
+
key=body.key,
|
|
83
|
+
name=body.name,
|
|
84
|
+
config=body.config,
|
|
85
|
+
resources=body.resources,
|
|
86
|
+
)
|
|
87
|
+
return _to_response(dest)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@router.put("/{destination_id}")
|
|
91
|
+
def update_destination(
|
|
92
|
+
destination_id: UUID,
|
|
93
|
+
body: DestinationCreateRequest,
|
|
94
|
+
user: object = Depends(require_editor),
|
|
95
|
+
store: Store = Depends(get_store),
|
|
96
|
+
) -> DestinationResponse:
|
|
97
|
+
"""Update a destination."""
|
|
98
|
+
try:
|
|
99
|
+
dest = store.update_destination(
|
|
100
|
+
destination_id,
|
|
101
|
+
name=body.name,
|
|
102
|
+
config=body.config,
|
|
103
|
+
resources=body.resources,
|
|
104
|
+
)
|
|
105
|
+
except NotFoundError as e:
|
|
106
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
107
|
+
return _to_response(dest)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@router.delete("/{destination_id}")
|
|
111
|
+
def delete_destination(
|
|
112
|
+
destination_id: UUID,
|
|
113
|
+
user: object = Depends(require_editor),
|
|
114
|
+
store: Store = Depends(get_store),
|
|
115
|
+
) -> dict[str, str]:
|
|
116
|
+
"""Delete a destination."""
|
|
117
|
+
store.delete_destination(destination_id)
|
|
118
|
+
return {"status": "deleted"}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""External provider API routes for FetchField resolution.
|
|
2
|
+
|
|
3
|
+
Each submodule contains routes for a specific external provider.
|
|
4
|
+
The combined ``router`` is re-exported here so ``app.py`` can
|
|
5
|
+
import it unchanged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from fastapi import APIRouter, HTTPException
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def handle_error(error: Exception, context: str) -> None:
|
|
21
|
+
"""Map external API errors to appropriate HTTP responses."""
|
|
22
|
+
logger.error("Error %s: %s", context, error)
|
|
23
|
+
|
|
24
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
25
|
+
status = error.response.status_code
|
|
26
|
+
if status in (401, 403):
|
|
27
|
+
raise HTTPException(status_code=status, detail=f"Authorization failed while {context}.")
|
|
28
|
+
if status == 404:
|
|
29
|
+
raise HTTPException(status_code=404, detail=f"Resource not found while {context}.")
|
|
30
|
+
|
|
31
|
+
if isinstance(error, HTTPException):
|
|
32
|
+
raise error
|
|
33
|
+
|
|
34
|
+
raise HTTPException(status_code=500, detail=f"Failed {context}.")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Import and register sub-routers after helpers are defined.
|
|
38
|
+
from interloper_api.routes.external.amazon_ads import sub_router as amazon_ads_router # noqa: E402
|
|
39
|
+
from interloper_api.routes.external.facebook_ads import sub_router as facebook_ads_router # noqa: E402
|
|
40
|
+
from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
|
|
41
|
+
from interloper_api.routes.external.pinterest_ads import sub_router as pinterest_ads_router # noqa: E402
|
|
42
|
+
from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_ads_router # noqa: E402
|
|
43
|
+
|
|
44
|
+
router.include_router(amazon_ads_router)
|
|
45
|
+
router.include_router(facebook_ads_router)
|
|
46
|
+
router.include_router(google_ads_router)
|
|
47
|
+
router.include_router(pinterest_ads_router)
|
|
48
|
+
router.include_router(snapchat_ads_router)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Amazon Ads external API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
from interloper_db import Profile
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from interloper_api.dependencies import require_viewer
|
|
11
|
+
from interloper_api.routes.external import handle_error
|
|
12
|
+
|
|
13
|
+
sub_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
_API_URLS: dict[str, str] = {
|
|
16
|
+
"EU": "https://advertising-api-eu.amazon.com",
|
|
17
|
+
"FE": "https://advertising-api-fe.amazon.com",
|
|
18
|
+
"NA": "https://advertising-api.amazon.com",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
_AUTH_URLS: dict[str, str] = {
|
|
22
|
+
"EU": "https://api.amazon.co.uk",
|
|
23
|
+
"FE": "https://api.amazon.co.jp",
|
|
24
|
+
"NA": "https://api.amazon.com",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AmazonAdsConnectionRequest(BaseModel):
|
|
29
|
+
"""Amazon Ads connection credentials (matches AmazonAdsConnection fields)."""
|
|
30
|
+
|
|
31
|
+
location: str = "NA"
|
|
32
|
+
client_id: str
|
|
33
|
+
client_secret: str
|
|
34
|
+
refresh_token: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@sub_router.post("/amazon-ads/profiles")
|
|
38
|
+
async def amazon_ads_profiles(
|
|
39
|
+
body: AmazonAdsConnectionRequest,
|
|
40
|
+
_user: Profile = Depends(require_viewer),
|
|
41
|
+
) -> list[dict[str, str]]:
|
|
42
|
+
"""Fetch Amazon Ads advertising profiles for a connection."""
|
|
43
|
+
api_url = _API_URLS.get(body.location, _API_URLS["NA"])
|
|
44
|
+
auth_url = _AUTH_URLS.get(body.location, _AUTH_URLS["NA"])
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
48
|
+
token_resp = await client.post(
|
|
49
|
+
f"{auth_url}/auth/o2/token",
|
|
50
|
+
data={
|
|
51
|
+
"grant_type": "refresh_token",
|
|
52
|
+
"refresh_token": body.refresh_token,
|
|
53
|
+
"client_id": body.client_id,
|
|
54
|
+
"client_secret": body.client_secret,
|
|
55
|
+
},
|
|
56
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
57
|
+
)
|
|
58
|
+
token_resp.raise_for_status()
|
|
59
|
+
access_token = token_resp.json()["access_token"]
|
|
60
|
+
|
|
61
|
+
profiles_resp = await client.get(
|
|
62
|
+
f"{api_url}/v2/profiles",
|
|
63
|
+
headers={
|
|
64
|
+
"Authorization": f"Bearer {access_token}",
|
|
65
|
+
"Amazon-Advertising-API-ClientId": body.client_id,
|
|
66
|
+
},
|
|
67
|
+
)
|
|
68
|
+
profiles_resp.raise_for_status()
|
|
69
|
+
profiles = profiles_resp.json()
|
|
70
|
+
|
|
71
|
+
return [
|
|
72
|
+
{
|
|
73
|
+
"profile_id": str(p["profileId"]),
|
|
74
|
+
"name": f"{p['accountInfo']['name']} ({p['countryCode']})",
|
|
75
|
+
"account_id": p["accountInfo"]["id"],
|
|
76
|
+
"country_code": p["countryCode"],
|
|
77
|
+
}
|
|
78
|
+
for p in profiles
|
|
79
|
+
]
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
handle_error(exc, "fetching Amazon Ads profiles")
|
|
82
|
+
return [] # unreachable, but satisfies type checker
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Facebook Ads external API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
from interloper_db import Profile
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from interloper_api.dependencies import require_viewer
|
|
11
|
+
from interloper_api.routes.external import handle_error
|
|
12
|
+
|
|
13
|
+
sub_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
_BASE_URL = "https://graph.facebook.com/v21.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FacebookAdsConnectionRequest(BaseModel):
|
|
19
|
+
"""Facebook Ads connection credentials (matches FacebookAdsConnection fields)."""
|
|
20
|
+
|
|
21
|
+
access_token: str
|
|
22
|
+
app_id: str = ""
|
|
23
|
+
app_secret: str = ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@sub_router.post("/facebook-ads/accounts")
|
|
27
|
+
async def facebook_ads_accounts(
|
|
28
|
+
body: FacebookAdsConnectionRequest,
|
|
29
|
+
_user: Profile = Depends(require_viewer),
|
|
30
|
+
) -> list[dict[str, str]]:
|
|
31
|
+
"""Fetch Facebook Ads ad accounts accessible by the connection."""
|
|
32
|
+
try:
|
|
33
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
34
|
+
resp = await client.get(
|
|
35
|
+
f"{_BASE_URL}/me/adaccounts",
|
|
36
|
+
params={
|
|
37
|
+
"access_token": body.access_token,
|
|
38
|
+
"fields": "account_id,name,account_status",
|
|
39
|
+
"limit": "500",
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
resp.raise_for_status()
|
|
43
|
+
data = resp.json().get("data", [])
|
|
44
|
+
|
|
45
|
+
return [
|
|
46
|
+
{
|
|
47
|
+
"account_id": acct["account_id"],
|
|
48
|
+
"name": f"{acct.get('name', acct['account_id'])}",
|
|
49
|
+
}
|
|
50
|
+
for acct in data
|
|
51
|
+
if acct.get("account_status") == 1 # ACTIVE only
|
|
52
|
+
]
|
|
53
|
+
except Exception as exc:
|
|
54
|
+
handle_error(exc, "fetching Facebook Ads accounts")
|
|
55
|
+
return []
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Google Ads external API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
from interloper_db import Profile
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from interloper_api.dependencies import require_viewer
|
|
11
|
+
from interloper_api.routes.external import handle_error
|
|
12
|
+
|
|
13
|
+
sub_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
16
|
+
_BASE_URL = "https://googleads.googleapis.com/v20"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GoogleAdsConnectionRequest(BaseModel):
|
|
20
|
+
"""Google Ads connection credentials (matches GoogleAdsConnection fields)."""
|
|
21
|
+
|
|
22
|
+
client_id: str
|
|
23
|
+
client_secret: str
|
|
24
|
+
developer_token: str
|
|
25
|
+
refresh_token: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _get_access_token(client: httpx.AsyncClient, body: GoogleAdsConnectionRequest) -> str:
|
|
29
|
+
"""Exchange the refresh token for an access token."""
|
|
30
|
+
resp = await client.post(
|
|
31
|
+
_TOKEN_URL,
|
|
32
|
+
data={
|
|
33
|
+
"grant_type": "refresh_token",
|
|
34
|
+
"refresh_token": body.refresh_token,
|
|
35
|
+
"client_id": body.client_id,
|
|
36
|
+
"client_secret": body.client_secret,
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp.json()["access_token"] # type: ignore[no-any-return]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _auth_headers(access_token: str, developer_token: str) -> dict[str, str]:
|
|
44
|
+
return {
|
|
45
|
+
"Authorization": f"Bearer {access_token}",
|
|
46
|
+
"developer-token": developer_token,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@sub_router.post("/google-ads/customers")
|
|
51
|
+
async def google_ads_customers(
|
|
52
|
+
body: GoogleAdsConnectionRequest,
|
|
53
|
+
_user: Profile = Depends(require_viewer),
|
|
54
|
+
) -> list[dict[str, str]]:
|
|
55
|
+
"""Fetch Google Ads customer accounts accessible by the connection."""
|
|
56
|
+
try:
|
|
57
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
58
|
+
access_token = await _get_access_token(client, body)
|
|
59
|
+
headers = _auth_headers(access_token, body.developer_token)
|
|
60
|
+
|
|
61
|
+
# Step 1: List accessible customer resource names.
|
|
62
|
+
list_resp = await client.get(
|
|
63
|
+
f"{_BASE_URL}/customers:listAccessibleCustomers",
|
|
64
|
+
headers=headers,
|
|
65
|
+
)
|
|
66
|
+
list_resp.raise_for_status()
|
|
67
|
+
resource_names: list[str] = list_resp.json().get("resourceNames", [])
|
|
68
|
+
|
|
69
|
+
# Step 2: Fetch descriptive name for each customer.
|
|
70
|
+
results: list[dict[str, str]] = []
|
|
71
|
+
for rn in resource_names:
|
|
72
|
+
# rn is like "customers/1234567890"
|
|
73
|
+
customer_id = rn.split("/")[-1]
|
|
74
|
+
query = (
|
|
75
|
+
"SELECT customer.id, customer.descriptive_name, customer.status "
|
|
76
|
+
"FROM customer LIMIT 1"
|
|
77
|
+
)
|
|
78
|
+
try:
|
|
79
|
+
search_resp = await client.post(
|
|
80
|
+
f"{_BASE_URL}/{rn}/googleAds:searchStream",
|
|
81
|
+
headers=headers,
|
|
82
|
+
json={"query": query},
|
|
83
|
+
)
|
|
84
|
+
search_resp.raise_for_status()
|
|
85
|
+
batches = search_resp.json()
|
|
86
|
+
for batch in batches:
|
|
87
|
+
for row in batch.get("results", []):
|
|
88
|
+
customer = row.get("customer", {})
|
|
89
|
+
name = customer.get("descriptiveName", customer_id)
|
|
90
|
+
results.append({
|
|
91
|
+
"customer_id": customer_id,
|
|
92
|
+
"name": f"{name} ({customer_id})",
|
|
93
|
+
})
|
|
94
|
+
except httpx.HTTPStatusError:
|
|
95
|
+
# Some customers may not be queryable (suspended, etc.)
|
|
96
|
+
results.append({
|
|
97
|
+
"customer_id": customer_id,
|
|
98
|
+
"name": customer_id,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return results
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
handle_error(exc, "fetching Google Ads customers")
|
|
104
|
+
return []
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Pinterest Ads external API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
from interloper_db import Profile
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from interloper_api.dependencies import require_viewer
|
|
11
|
+
from interloper_api.routes.external import handle_error
|
|
12
|
+
|
|
13
|
+
sub_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
_BASE_URL = "https://api.pinterest.com/v5"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PinterestAdsConnectionRequest(BaseModel):
|
|
19
|
+
"""Pinterest Ads connection credentials (matches PinterestAdsConnection fields)."""
|
|
20
|
+
|
|
21
|
+
client_id: str
|
|
22
|
+
client_secret: str
|
|
23
|
+
refresh_token: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def _get_access_token(body: PinterestAdsConnectionRequest) -> str:
|
|
27
|
+
"""Exchange refresh token for an access token using HTTP Basic auth."""
|
|
28
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
29
|
+
resp = await client.post(
|
|
30
|
+
f"{_BASE_URL}/oauth/token",
|
|
31
|
+
auth=(body.client_id, body.client_secret),
|
|
32
|
+
data={
|
|
33
|
+
"grant_type": "refresh_token",
|
|
34
|
+
"refresh_token": body.refresh_token,
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
resp.raise_for_status()
|
|
38
|
+
return resp.json()["access_token"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@sub_router.post("/pinterest-ads/accounts")
|
|
42
|
+
async def pinterest_ads_accounts(
|
|
43
|
+
body: PinterestAdsConnectionRequest,
|
|
44
|
+
_user: Profile = Depends(require_viewer),
|
|
45
|
+
) -> list[dict[str, str]]:
|
|
46
|
+
"""Fetch Pinterest Ads ad accounts accessible by the connection."""
|
|
47
|
+
try:
|
|
48
|
+
token = await _get_access_token(body)
|
|
49
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
50
|
+
|
|
51
|
+
accounts: list[dict[str, str]] = []
|
|
52
|
+
bookmark: str | None = None
|
|
53
|
+
|
|
54
|
+
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
|
|
55
|
+
while True:
|
|
56
|
+
params: dict[str, str] = {"page_size": "100"}
|
|
57
|
+
if bookmark:
|
|
58
|
+
params["bookmark"] = bookmark
|
|
59
|
+
|
|
60
|
+
resp = await client.get(f"{_BASE_URL}/ad_accounts", params=params)
|
|
61
|
+
resp.raise_for_status()
|
|
62
|
+
data = resp.json()
|
|
63
|
+
|
|
64
|
+
for acct in data.get("items", []):
|
|
65
|
+
accounts.append({
|
|
66
|
+
"id": acct["id"],
|
|
67
|
+
"name": acct.get("name", acct["id"]),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
bookmark = data.get("bookmark")
|
|
71
|
+
if not bookmark:
|
|
72
|
+
break
|
|
73
|
+
|
|
74
|
+
return accounts
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
handle_error(exc, "fetching Pinterest Ads accounts")
|
|
77
|
+
return []
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Snapchat Ads external API routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from fastapi import APIRouter, Depends
|
|
7
|
+
from interloper_db import Profile
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from interloper_api.dependencies import require_viewer
|
|
11
|
+
from interloper_api.routes.external import handle_error
|
|
12
|
+
|
|
13
|
+
sub_router = APIRouter()
|
|
14
|
+
|
|
15
|
+
_BASE_URL = "https://adsapi.snapchat.com/v1"
|
|
16
|
+
_TOKEN_URL = "https://accounts.snapchat.com/login/oauth2/access_token"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SnapchatAdsConnectionRequest(BaseModel):
|
|
20
|
+
"""Snapchat Ads connection credentials (matches SnapchatAdsConnection fields)."""
|
|
21
|
+
|
|
22
|
+
client_id: str
|
|
23
|
+
client_secret: str
|
|
24
|
+
refresh_token: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _get_access_token(body: SnapchatAdsConnectionRequest) -> str:
|
|
28
|
+
"""Exchange refresh token for an access token."""
|
|
29
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
30
|
+
resp = await client.post(
|
|
31
|
+
_TOKEN_URL,
|
|
32
|
+
data={
|
|
33
|
+
"grant_type": "refresh_token",
|
|
34
|
+
"client_id": body.client_id,
|
|
35
|
+
"client_secret": body.client_secret,
|
|
36
|
+
"refresh_token": body.refresh_token,
|
|
37
|
+
},
|
|
38
|
+
)
|
|
39
|
+
resp.raise_for_status()
|
|
40
|
+
return resp.json()["access_token"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@sub_router.post("/snapchat-ads/ad-accounts")
|
|
44
|
+
async def snapchat_ads_accounts(
|
|
45
|
+
body: SnapchatAdsConnectionRequest,
|
|
46
|
+
_user: Profile = Depends(require_viewer),
|
|
47
|
+
) -> list[dict[str, str]]:
|
|
48
|
+
"""Fetch Snapchat Ads ad accounts accessible by the connection.
|
|
49
|
+
|
|
50
|
+
Flow: authenticate → list organizations → list ad accounts per org.
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
token = await _get_access_token(body)
|
|
54
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
55
|
+
|
|
56
|
+
async with httpx.AsyncClient(timeout=30, headers=headers) as client:
|
|
57
|
+
# 1. Get organizations
|
|
58
|
+
org_resp = await client.get(f"{_BASE_URL}/me/organizations")
|
|
59
|
+
org_resp.raise_for_status()
|
|
60
|
+
orgs = org_resp.json().get("organizations", [])
|
|
61
|
+
|
|
62
|
+
# 2. Get ad accounts for each organization
|
|
63
|
+
accounts: list[dict[str, str]] = []
|
|
64
|
+
for org_wrapper in orgs:
|
|
65
|
+
org = org_wrapper.get("organization", {})
|
|
66
|
+
org_id = org.get("id")
|
|
67
|
+
if not org_id:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
acct_resp = await client.get(f"{_BASE_URL}/organizations/{org_id}/adaccounts")
|
|
71
|
+
acct_resp.raise_for_status()
|
|
72
|
+
ad_accounts = acct_resp.json().get("adaccounts", [])
|
|
73
|
+
|
|
74
|
+
for acct_wrapper in ad_accounts:
|
|
75
|
+
acct = acct_wrapper.get("adaccount", {})
|
|
76
|
+
if acct.get("status") != "ACTIVE":
|
|
77
|
+
continue
|
|
78
|
+
accounts.append({
|
|
79
|
+
"id": acct["id"],
|
|
80
|
+
"name": acct.get("name", acct["id"]),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return accounts
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
handle_error(exc, "fetching Snapchat Ads ad accounts")
|
|
86
|
+
return []
|