interloper-api 0.16.0__tar.gz → 0.18.0__tar.gz
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-0.16.0 → interloper_api-0.18.0}/PKG-INFO +1 -1
- {interloper_api-0.16.0 → interloper_api-0.18.0}/pyproject.toml +1 -1
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/app.py +7 -1
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/dependencies.py +34 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/assets.py +49 -14
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/backfills.py +4 -3
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/destinations.py +27 -4
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/__init__.py +2 -0
- interloper_api-0.18.0/src/interloper_api/routes/external/criteo.py +68 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/jobs.py +43 -19
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/oauth.py +18 -11
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/organisations.py +3 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/resources.py +44 -9
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/runs.py +34 -10
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/sources.py +26 -4
- {interloper_api-0.16.0 → interloper_api-0.18.0}/README.md +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/google_ads.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/google_cloud.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/impact.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/tiktok_ads.py +0 -0
- {interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import logging
|
|
5
6
|
from typing import Any
|
|
6
7
|
|
|
7
8
|
from fastapi import APIRouter, FastAPI
|
|
@@ -27,6 +28,8 @@ from interloper_api.routes import (
|
|
|
27
28
|
)
|
|
28
29
|
from interloper_api.routes import catalog as catalog_routes
|
|
29
30
|
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
30
33
|
|
|
31
34
|
def create_app(
|
|
32
35
|
store: Store | None = None,
|
|
@@ -91,7 +94,10 @@ def create_app(
|
|
|
91
94
|
|
|
92
95
|
api.include_router(agent_routes.router, prefix="/agent", tags=["agent"])
|
|
93
96
|
except ImportError:
|
|
94
|
-
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Agent routes not mounted: the 'agent' extra is not installed "
|
|
99
|
+
"(install interloper-api[agent]); /agent endpoints will return 404."
|
|
100
|
+
)
|
|
95
101
|
|
|
96
102
|
@api.get("/health")
|
|
97
103
|
def health() -> dict[str, str]:
|
|
@@ -217,6 +217,40 @@ def get_org_id(
|
|
|
217
217
|
# -- RBAC dependencies -------------------------------------------------------
|
|
218
218
|
|
|
219
219
|
|
|
220
|
+
def authorize_org_member(
|
|
221
|
+
user: Profile,
|
|
222
|
+
org_id: UUID,
|
|
223
|
+
store: Store,
|
|
224
|
+
*,
|
|
225
|
+
minimum: str = "viewer",
|
|
226
|
+
detail: str = "Not found",
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Authorize access to a resource owned by ``org_id`` by membership.
|
|
229
|
+
|
|
230
|
+
Unlike the ``require_*`` dependencies, which bind to the session's *active*
|
|
231
|
+
organisation, this checks the user's role in the resource's organisation —
|
|
232
|
+
so ID-addressed endpoints work for members of the owning org regardless of
|
|
233
|
+
which org is currently selected. Non-members get a 404 carrying the same
|
|
234
|
+
``detail`` as a missing resource, so IDs don't act as an existence oracle;
|
|
235
|
+
members with an insufficient role get a 403.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
user: The authenticated user.
|
|
239
|
+
org_id: The organisation that owns the resource.
|
|
240
|
+
store: The Store instance.
|
|
241
|
+
minimum: Minimum role required (``viewer``, ``editor``, ``admin``).
|
|
242
|
+
detail: 404 detail, matching the route's missing-resource message.
|
|
243
|
+
|
|
244
|
+
Raises:
|
|
245
|
+
HTTPException: 404 if not a member, 403 if the role is insufficient.
|
|
246
|
+
"""
|
|
247
|
+
role = store.get_user_role(user.id, org_id)
|
|
248
|
+
if role is None:
|
|
249
|
+
raise HTTPException(status_code=404, detail=detail)
|
|
250
|
+
if _ROLE_RANK.get(role, -1) < _ROLE_RANK[minimum]:
|
|
251
|
+
raise HTTPException(status_code=403, detail=f"Requires {minimum} role or higher")
|
|
252
|
+
|
|
253
|
+
|
|
220
254
|
def _check_role(
|
|
221
255
|
minimum: str,
|
|
222
256
|
user: Profile,
|
|
@@ -12,7 +12,14 @@ from interloper_db.models import Asset, AssetResource, Destination, DestinationR
|
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
from sqlmodel import Session, select
|
|
14
14
|
|
|
15
|
-
from interloper_api.dependencies import
|
|
15
|
+
from interloper_api.dependencies import (
|
|
16
|
+
authorize_org_member,
|
|
17
|
+
get_current_user,
|
|
18
|
+
get_org_id,
|
|
19
|
+
get_store,
|
|
20
|
+
require_editor,
|
|
21
|
+
require_viewer,
|
|
22
|
+
)
|
|
16
23
|
|
|
17
24
|
router = APIRouter()
|
|
18
25
|
|
|
@@ -116,6 +123,30 @@ def _dest_to_response(dest: Destination) -> DestinationResponse:
|
|
|
116
123
|
)
|
|
117
124
|
|
|
118
125
|
|
|
126
|
+
def _load_authorized_asset(asset_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Asset:
|
|
127
|
+
"""Load an asset and authorize the user by membership in its org.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
asset_id: The asset UUID.
|
|
131
|
+
user: The authenticated user.
|
|
132
|
+
store: The Store instance.
|
|
133
|
+
minimum: Minimum role required in the asset's organisation.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
The Asset row.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
HTTPException: 404 if missing or the user is not a member of the
|
|
140
|
+
owning org, 403 if the role is insufficient.
|
|
141
|
+
"""
|
|
142
|
+
try:
|
|
143
|
+
asset = store.get_asset(asset_id)
|
|
144
|
+
except NotFoundError:
|
|
145
|
+
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
|
146
|
+
authorize_org_member(user, asset.org_id, store, minimum=minimum, detail=f"Asset {asset_id} not found")
|
|
147
|
+
return asset
|
|
148
|
+
|
|
149
|
+
|
|
119
150
|
def _asset_to_response(asset: Asset) -> AssetResponse:
|
|
120
151
|
"""Convert a DB Asset to an AssetResponse."""
|
|
121
152
|
return AssetResponse(
|
|
@@ -183,14 +214,11 @@ def list_dependencies(
|
|
|
183
214
|
@router.get("/{asset_id}")
|
|
184
215
|
def get_asset(
|
|
185
216
|
asset_id: UUID,
|
|
186
|
-
user: Profile = Depends(
|
|
217
|
+
user: Profile = Depends(get_current_user),
|
|
187
218
|
store: Store = Depends(get_store),
|
|
188
219
|
) -> AssetResponse:
|
|
189
|
-
"""Get a single asset by ID."""
|
|
190
|
-
|
|
191
|
-
asset = store.get_asset(asset_id)
|
|
192
|
-
except NotFoundError:
|
|
193
|
-
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
|
220
|
+
"""Get a single asset by ID. Authorized by membership in the asset's org."""
|
|
221
|
+
asset = _load_authorized_asset(asset_id, user, store)
|
|
194
222
|
return _asset_to_response(asset)
|
|
195
223
|
|
|
196
224
|
|
|
@@ -198,10 +226,11 @@ def get_asset(
|
|
|
198
226
|
def update_asset(
|
|
199
227
|
asset_id: UUID,
|
|
200
228
|
body: AssetUpdateRequest,
|
|
201
|
-
user: Profile = Depends(
|
|
229
|
+
user: Profile = Depends(get_current_user),
|
|
202
230
|
store: Store = Depends(get_store),
|
|
203
231
|
) -> AssetResponse:
|
|
204
232
|
"""Update an asset's configuration, resources, or destinations."""
|
|
233
|
+
_load_authorized_asset(asset_id, user, store, minimum="editor")
|
|
205
234
|
try:
|
|
206
235
|
asset = store.update_asset(
|
|
207
236
|
asset_id,
|
|
@@ -218,10 +247,11 @@ def update_asset(
|
|
|
218
247
|
@router.delete("/{asset_id}")
|
|
219
248
|
def delete_asset(
|
|
220
249
|
asset_id: UUID,
|
|
221
|
-
user: Profile = Depends(
|
|
250
|
+
user: Profile = Depends(get_current_user),
|
|
222
251
|
store: Store = Depends(get_store),
|
|
223
252
|
) -> dict[str, str]:
|
|
224
253
|
"""Delete a standalone asset."""
|
|
254
|
+
_load_authorized_asset(asset_id, user, store, minimum="editor")
|
|
225
255
|
try:
|
|
226
256
|
store.delete_asset(asset_id)
|
|
227
257
|
except NotFoundError:
|
|
@@ -238,10 +268,14 @@ def delete_asset(
|
|
|
238
268
|
def add_dependency(
|
|
239
269
|
asset_id: UUID,
|
|
240
270
|
body: AddDependencyRequest,
|
|
241
|
-
user: Profile = Depends(
|
|
271
|
+
user: Profile = Depends(get_current_user),
|
|
242
272
|
store: Store = Depends(get_store),
|
|
243
273
|
) -> DependencyResponse:
|
|
244
|
-
"""Add a dependency between two assets."""
|
|
274
|
+
"""Add a dependency between two assets. Both must belong to the same org."""
|
|
275
|
+
asset = _load_authorized_asset(asset_id, user, store, minimum="editor")
|
|
276
|
+
upstream = _load_authorized_asset(body.upstream_asset_id, user, store, minimum="editor")
|
|
277
|
+
if upstream.org_id != asset.org_id:
|
|
278
|
+
raise HTTPException(status_code=404, detail=f"Asset {body.upstream_asset_id} not found")
|
|
245
279
|
try:
|
|
246
280
|
dep = store.add_dependency(asset_id, body.upstream_asset_id, body.param_name)
|
|
247
281
|
except NotFoundError as e:
|
|
@@ -256,10 +290,11 @@ def add_dependency(
|
|
|
256
290
|
def remove_dependency(
|
|
257
291
|
asset_id: UUID,
|
|
258
292
|
upstream_asset_id: UUID,
|
|
259
|
-
user: Profile = Depends(
|
|
293
|
+
user: Profile = Depends(get_current_user),
|
|
260
294
|
store: Store = Depends(get_store),
|
|
261
295
|
) -> None:
|
|
262
296
|
"""Remove an asset dependency."""
|
|
297
|
+
_load_authorized_asset(asset_id, user, store, minimum="editor")
|
|
263
298
|
try:
|
|
264
299
|
store.remove_dependency(asset_id, upstream_asset_id)
|
|
265
300
|
except NotFoundError as e:
|
|
@@ -272,11 +307,11 @@ def remove_dependency(
|
|
|
272
307
|
@router.get("/{asset_id}/partition-row-counts")
|
|
273
308
|
def get_partition_row_counts(
|
|
274
309
|
asset_id: UUID,
|
|
275
|
-
user: Profile = Depends(
|
|
276
|
-
org_id: UUID = Depends(get_org_id),
|
|
310
|
+
user: Profile = Depends(get_current_user),
|
|
277
311
|
store: Store = Depends(get_store),
|
|
278
312
|
) -> PartitionRowCountsResponse:
|
|
279
313
|
"""Get row counts grouped by partition for an asset."""
|
|
314
|
+
_load_authorized_asset(asset_id, user, store)
|
|
280
315
|
try:
|
|
281
316
|
il_asset = store.load_asset(asset_id)
|
|
282
317
|
except NotFoundError as e:
|
|
@@ -11,7 +11,7 @@ from interloper_db import Profile, Store
|
|
|
11
11
|
from interloper_db.models import Backfill
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
|
|
14
|
-
from interloper_api.dependencies import get_org_id, get_store, require_viewer
|
|
14
|
+
from interloper_api.dependencies import authorize_org_member, get_current_user, get_org_id, get_store, require_viewer
|
|
15
15
|
|
|
16
16
|
router = APIRouter()
|
|
17
17
|
|
|
@@ -76,12 +76,13 @@ def list_backfills(
|
|
|
76
76
|
@router.get("/{backfill_id}")
|
|
77
77
|
def get_backfill(
|
|
78
78
|
backfill_id: UUID,
|
|
79
|
-
user: Profile = Depends(
|
|
79
|
+
user: Profile = Depends(get_current_user),
|
|
80
80
|
store: Store = Depends(get_store),
|
|
81
81
|
) -> BackfillResponse:
|
|
82
|
-
"""Get a single backfill by ID."""
|
|
82
|
+
"""Get a single backfill by ID. Authorized by membership in the backfill's org."""
|
|
83
83
|
try:
|
|
84
84
|
backfill = store.get_backfill(backfill_id)
|
|
85
85
|
except NotFoundError:
|
|
86
86
|
raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
|
|
87
|
+
authorize_org_member(user, backfill.org_id, store, detail=f"Backfill {backfill_id} not found")
|
|
87
88
|
return _backfill_to_response(backfill)
|
|
@@ -7,11 +7,18 @@ from uuid import UUID
|
|
|
7
7
|
|
|
8
8
|
from fastapi import APIRouter, Depends, HTTPException
|
|
9
9
|
from interloper.errors import NotFoundError
|
|
10
|
-
from interloper_db import Store
|
|
10
|
+
from interloper_db import Profile, Store
|
|
11
11
|
from interloper_db.models import Destination, DestinationResource
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
|
|
14
|
-
from interloper_api.dependencies import
|
|
14
|
+
from interloper_api.dependencies import (
|
|
15
|
+
authorize_org_member,
|
|
16
|
+
get_current_user,
|
|
17
|
+
get_org_id,
|
|
18
|
+
get_store,
|
|
19
|
+
require_editor,
|
|
20
|
+
require_viewer,
|
|
21
|
+
)
|
|
15
22
|
|
|
16
23
|
router = APIRouter()
|
|
17
24
|
|
|
@@ -87,14 +94,29 @@ def create_destination(
|
|
|
87
94
|
return _to_response(dest)
|
|
88
95
|
|
|
89
96
|
|
|
97
|
+
def _authorize_destination(destination_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> None:
|
|
98
|
+
"""Authorize the user by membership in the destination's org.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
HTTPException: 404 if missing or the user is not a member of the
|
|
102
|
+
owning org, 403 if the role is insufficient.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
dest = store.get_destination(destination_id)
|
|
106
|
+
except NotFoundError:
|
|
107
|
+
raise HTTPException(status_code=404, detail=f"Destination {destination_id} not found")
|
|
108
|
+
authorize_org_member(user, dest.org_id, store, minimum=minimum, detail=f"Destination {destination_id} not found")
|
|
109
|
+
|
|
110
|
+
|
|
90
111
|
@router.put("/{destination_id}")
|
|
91
112
|
def update_destination(
|
|
92
113
|
destination_id: UUID,
|
|
93
114
|
body: DestinationCreateRequest,
|
|
94
|
-
user:
|
|
115
|
+
user: Profile = Depends(get_current_user),
|
|
95
116
|
store: Store = Depends(get_store),
|
|
96
117
|
) -> DestinationResponse:
|
|
97
118
|
"""Update a destination."""
|
|
119
|
+
_authorize_destination(destination_id, user, store, minimum="editor")
|
|
98
120
|
try:
|
|
99
121
|
dest = store.update_destination(
|
|
100
122
|
destination_id,
|
|
@@ -110,9 +132,10 @@ def update_destination(
|
|
|
110
132
|
@router.delete("/{destination_id}")
|
|
111
133
|
def delete_destination(
|
|
112
134
|
destination_id: UUID,
|
|
113
|
-
user:
|
|
135
|
+
user: Profile = Depends(get_current_user),
|
|
114
136
|
store: Store = Depends(get_store),
|
|
115
137
|
) -> dict[str, str]:
|
|
116
138
|
"""Delete a destination."""
|
|
139
|
+
_authorize_destination(destination_id, user, store, minimum="editor")
|
|
117
140
|
store.delete_destination(destination_id)
|
|
118
141
|
return {"status": "deleted"}
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/__init__.py
RENAMED
|
@@ -36,6 +36,7 @@ def handle_error(error: Exception, context: str) -> None:
|
|
|
36
36
|
|
|
37
37
|
# Import and register sub-routers after helpers are defined.
|
|
38
38
|
from interloper_api.routes.external.amazon_ads import sub_router as amazon_ads_router # noqa: E402
|
|
39
|
+
from interloper_api.routes.external.criteo import sub_router as criteo_router # noqa: E402
|
|
39
40
|
from interloper_api.routes.external.facebook_ads import sub_router as facebook_ads_router # noqa: E402
|
|
40
41
|
from interloper_api.routes.external.google_ads import sub_router as google_ads_router # noqa: E402
|
|
41
42
|
from interloper_api.routes.external.google_cloud import sub_router as google_cloud_router # noqa: E402
|
|
@@ -45,6 +46,7 @@ from interloper_api.routes.external.snapchat_ads import sub_router as snapchat_a
|
|
|
45
46
|
from interloper_api.routes.external.tiktok_ads import sub_router as tiktok_ads_router # noqa: E402
|
|
46
47
|
|
|
47
48
|
router.include_router(amazon_ads_router)
|
|
49
|
+
router.include_router(criteo_router)
|
|
48
50
|
router.include_router(facebook_ads_router)
|
|
49
51
|
router.include_router(google_ads_router)
|
|
50
52
|
router.include_router(google_cloud_router)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Criteo 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.criteo.com"
|
|
16
|
+
_TOKEN_URL = f"{_BASE_URL}/oauth2/token"
|
|
17
|
+
_ADVERTISERS_URL = f"{_BASE_URL}/2025-04/advertisers/me"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CriteoConnectionRequest(BaseModel):
|
|
21
|
+
"""Criteo connection credentials (matches CriteoConnection fields)."""
|
|
22
|
+
|
|
23
|
+
client_id: str
|
|
24
|
+
client_secret: str
|
|
25
|
+
refresh_token: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
async def _get_access_token(client: httpx.AsyncClient, body: CriteoConnectionRequest) -> 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
|
+
"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
|
+
async def _list_advertisers(client: httpx.AsyncClient, body: CriteoConnectionRequest) -> list[dict[str, str]]:
|
|
44
|
+
"""List the advertisers the connection can access, sorted by name."""
|
|
45
|
+
token = await _get_access_token(client, body)
|
|
46
|
+
resp = await client.get(_ADVERTISERS_URL, headers={"Authorization": f"Bearer {token}"})
|
|
47
|
+
resp.raise_for_status()
|
|
48
|
+
|
|
49
|
+
advertisers = resp.json().get("data", [])
|
|
50
|
+
results = [
|
|
51
|
+
{"id": a["id"], "name": a.get("attributes", {}).get("advertiserName") or a["id"]}
|
|
52
|
+
for a in advertisers
|
|
53
|
+
]
|
|
54
|
+
return sorted(results, key=lambda a: a["name"].lower())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@sub_router.post("/criteo/advertisers")
|
|
58
|
+
async def criteo_advertisers(
|
|
59
|
+
body: CriteoConnectionRequest,
|
|
60
|
+
_user: Profile = Depends(require_viewer),
|
|
61
|
+
) -> list[dict[str, str]]:
|
|
62
|
+
"""Fetch the Criteo advertisers accessible by the connection."""
|
|
63
|
+
try:
|
|
64
|
+
async with httpx.AsyncClient(timeout=30) as client:
|
|
65
|
+
return await _list_advertisers(client, body)
|
|
66
|
+
except Exception as exc:
|
|
67
|
+
handle_error(exc, "fetching Criteo advertisers")
|
|
68
|
+
return []
|
|
@@ -11,7 +11,14 @@ from interloper_db import Profile, Store
|
|
|
11
11
|
from interloper_db.models import Job
|
|
12
12
|
from pydantic import BaseModel
|
|
13
13
|
|
|
14
|
-
from interloper_api.dependencies import
|
|
14
|
+
from interloper_api.dependencies import (
|
|
15
|
+
authorize_org_member,
|
|
16
|
+
get_current_user,
|
|
17
|
+
get_org_id,
|
|
18
|
+
get_store,
|
|
19
|
+
require_editor,
|
|
20
|
+
require_viewer,
|
|
21
|
+
)
|
|
15
22
|
|
|
16
23
|
router = APIRouter()
|
|
17
24
|
|
|
@@ -62,6 +69,30 @@ class BackfillRequest(BaseModel):
|
|
|
62
69
|
fail_fast: bool = False
|
|
63
70
|
|
|
64
71
|
|
|
72
|
+
def _load_authorized_job(job_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Job:
|
|
73
|
+
"""Load a job and authorize the user by membership in its org.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
job_id: The job UUID.
|
|
77
|
+
user: The authenticated user.
|
|
78
|
+
store: The Store instance.
|
|
79
|
+
minimum: Minimum role required in the job's organisation.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The Job row.
|
|
83
|
+
|
|
84
|
+
Raises:
|
|
85
|
+
HTTPException: 404 if missing or the user is not a member of the
|
|
86
|
+
owning org, 403 if the role is insufficient.
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
job = store.get_job(job_id)
|
|
90
|
+
except NotFoundError:
|
|
91
|
+
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
92
|
+
authorize_org_member(user, job.org_id, store, minimum=minimum, detail=f"Job {job_id} not found")
|
|
93
|
+
return job
|
|
94
|
+
|
|
95
|
+
|
|
65
96
|
def _job_to_response(job: Job) -> JobResponse:
|
|
66
97
|
"""Convert a DB Job to a JobResponse.
|
|
67
98
|
|
|
@@ -102,14 +133,11 @@ def list_jobs(
|
|
|
102
133
|
@router.get("/{job_id}")
|
|
103
134
|
def get_job(
|
|
104
135
|
job_id: UUID,
|
|
105
|
-
user: Profile = Depends(
|
|
136
|
+
user: Profile = Depends(get_current_user),
|
|
106
137
|
store: Store = Depends(get_store),
|
|
107
138
|
) -> JobResponse:
|
|
108
|
-
"""Get a single job by ID."""
|
|
109
|
-
|
|
110
|
-
job = store.get_job(job_id)
|
|
111
|
-
except NotFoundError:
|
|
112
|
-
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
139
|
+
"""Get a single job by ID. Authorized by membership in the job's org."""
|
|
140
|
+
job = _load_authorized_job(job_id, user, store)
|
|
113
141
|
return _job_to_response(job)
|
|
114
142
|
|
|
115
143
|
|
|
@@ -139,10 +167,11 @@ def create_job(
|
|
|
139
167
|
def update_job(
|
|
140
168
|
job_id: UUID,
|
|
141
169
|
body: JobCreateRequest,
|
|
142
|
-
user: Profile = Depends(
|
|
170
|
+
user: Profile = Depends(get_current_user),
|
|
143
171
|
store: Store = Depends(get_store),
|
|
144
172
|
) -> JobResponse:
|
|
145
173
|
"""Update an existing job."""
|
|
174
|
+
_load_authorized_job(job_id, user, store, minimum="editor")
|
|
146
175
|
try:
|
|
147
176
|
job = store.update_job(
|
|
148
177
|
job_id,
|
|
@@ -163,10 +192,11 @@ def update_job(
|
|
|
163
192
|
@router.delete("/{job_id}")
|
|
164
193
|
def delete_job(
|
|
165
194
|
job_id: UUID,
|
|
166
|
-
user: Profile = Depends(
|
|
195
|
+
user: Profile = Depends(get_current_user),
|
|
167
196
|
store: Store = Depends(get_store),
|
|
168
197
|
) -> dict[str, str]:
|
|
169
198
|
"""Delete a job."""
|
|
199
|
+
_load_authorized_job(job_id, user, store, minimum="editor")
|
|
170
200
|
store.delete_job(job_id)
|
|
171
201
|
return {"status": "deleted"}
|
|
172
202
|
|
|
@@ -175,14 +205,11 @@ def delete_job(
|
|
|
175
205
|
def queue_run(
|
|
176
206
|
job_id: UUID,
|
|
177
207
|
body: RunRequest | None = None,
|
|
178
|
-
user: Profile = Depends(
|
|
208
|
+
user: Profile = Depends(get_current_user),
|
|
179
209
|
store: Store = Depends(get_store),
|
|
180
210
|
) -> dict[str, str]:
|
|
181
211
|
"""Queue a single run for a job."""
|
|
182
|
-
|
|
183
|
-
job = store.get_job(job_id)
|
|
184
|
-
except NotFoundError:
|
|
185
|
-
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
212
|
+
job = _load_authorized_job(job_id, user, store, minimum="editor")
|
|
186
213
|
|
|
187
214
|
run = store.create_run(
|
|
188
215
|
job.org_id,
|
|
@@ -196,14 +223,11 @@ def queue_run(
|
|
|
196
223
|
def queue_backfill(
|
|
197
224
|
job_id: UUID,
|
|
198
225
|
body: BackfillRequest,
|
|
199
|
-
user: Profile = Depends(
|
|
226
|
+
user: Profile = Depends(get_current_user),
|
|
200
227
|
store: Store = Depends(get_store),
|
|
201
228
|
) -> dict[str, str]:
|
|
202
229
|
"""Queue a backfill for a job."""
|
|
203
|
-
|
|
204
|
-
job = store.get_job(job_id)
|
|
205
|
-
except NotFoundError:
|
|
206
|
-
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
230
|
+
job = _load_authorized_job(job_id, user, store, minimum="editor")
|
|
207
231
|
|
|
208
232
|
backfill = store.create_backfill(
|
|
209
233
|
job.org_id,
|
|
@@ -24,10 +24,13 @@ import os
|
|
|
24
24
|
from typing import Any
|
|
25
25
|
|
|
26
26
|
import httpx
|
|
27
|
-
from fastapi import APIRouter, HTTPException
|
|
27
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
28
28
|
from interloper.oauth import OAuthProvider, providers
|
|
29
|
+
from interloper_db import Profile
|
|
29
30
|
from pydantic import BaseModel
|
|
30
31
|
|
|
32
|
+
from interloper_api.dependencies import get_current_user
|
|
33
|
+
|
|
31
34
|
logger = logging.getLogger(__name__)
|
|
32
35
|
|
|
33
36
|
router = APIRouter(prefix="/oauth", tags=["oauth"])
|
|
@@ -77,8 +80,10 @@ async def _exchange(
|
|
|
77
80
|
code: The authorization code.
|
|
78
81
|
|
|
79
82
|
Returns:
|
|
80
|
-
The token response
|
|
81
|
-
|
|
83
|
+
The provider's raw token response (e.g. ``refresh_token``). The app
|
|
84
|
+
credentials are *not* injected: they are the in-house per-provider
|
|
85
|
+
values resolved from env at runtime, so the secret never leaves the
|
|
86
|
+
server.
|
|
82
87
|
"""
|
|
83
88
|
logical_values = {
|
|
84
89
|
"grant_type": "authorization_code",
|
|
@@ -103,10 +108,7 @@ async def _exchange(
|
|
|
103
108
|
resp = await client.post(spec.token_url, json=params, headers=headers)
|
|
104
109
|
resp.raise_for_status()
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
result["client_id"] = cfg.client_id
|
|
108
|
-
result["client_secret"] = cfg.client_secret
|
|
109
|
-
return result
|
|
111
|
+
return resp.json()
|
|
110
112
|
|
|
111
113
|
|
|
112
114
|
# ---------------------------------------------------------------------------
|
|
@@ -155,11 +157,16 @@ def list_providers() -> list[ProviderInfo]:
|
|
|
155
157
|
|
|
156
158
|
|
|
157
159
|
@router.post("/{provider}")
|
|
158
|
-
async def exchange_token(
|
|
159
|
-
|
|
160
|
+
async def exchange_token(
|
|
161
|
+
provider: str,
|
|
162
|
+
body: TokenExchangeRequest,
|
|
163
|
+
_user: Profile = Depends(get_current_user),
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""Exchange an authorization code for tokens. Requires authentication.
|
|
160
166
|
|
|
161
|
-
|
|
162
|
-
|
|
167
|
+
Returns only the provider's token response (e.g. ``refresh_token``); the
|
|
168
|
+
in-house app credentials are never included — connections resolve them
|
|
169
|
+
from env at runtime.
|
|
163
170
|
"""
|
|
164
171
|
spec = providers().get(provider)
|
|
165
172
|
if spec is None:
|
|
@@ -240,6 +240,9 @@ def cancel_invitation(
|
|
|
240
240
|
store: Store = Depends(get_store),
|
|
241
241
|
) -> dict[str, str]:
|
|
242
242
|
"""Cancel a pending invitation. Requires admin role."""
|
|
243
|
+
invitations = store.list_invitations(org_id)
|
|
244
|
+
if not any(inv.id == invitation_id for inv in invitations):
|
|
245
|
+
raise HTTPException(status_code=404, detail="Invitation not found")
|
|
243
246
|
if not store.delete_invitation(invitation_id):
|
|
244
247
|
raise HTTPException(status_code=404, detail="Invitation not found")
|
|
245
248
|
|
|
@@ -8,9 +8,18 @@ from uuid import UUID
|
|
|
8
8
|
from fastapi import APIRouter, Depends, HTTPException
|
|
9
9
|
from interloper.errors import NotFoundError
|
|
10
10
|
from interloper_db import Profile, Store
|
|
11
|
+
from interloper_db.models import Resource
|
|
11
12
|
from pydantic import BaseModel
|
|
12
13
|
|
|
13
|
-
from interloper_api.dependencies import
|
|
14
|
+
from interloper_api.dependencies import (
|
|
15
|
+
authorize_org_member,
|
|
16
|
+
get_catalog,
|
|
17
|
+
get_current_user,
|
|
18
|
+
get_org_id,
|
|
19
|
+
get_store,
|
|
20
|
+
require_editor,
|
|
21
|
+
require_viewer,
|
|
22
|
+
)
|
|
14
23
|
|
|
15
24
|
router = APIRouter()
|
|
16
25
|
|
|
@@ -49,6 +58,30 @@ class ResourceDetailResponse(ResourceResponse):
|
|
|
49
58
|
data: dict[str, Any] = {}
|
|
50
59
|
|
|
51
60
|
|
|
61
|
+
def _load_authorized_resource(resource_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Resource:
|
|
62
|
+
"""Load a resource and authorize the user by membership in its org.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
resource_id: The resource UUID.
|
|
66
|
+
user: The authenticated user.
|
|
67
|
+
store: The Store instance.
|
|
68
|
+
minimum: Minimum role required in the resource's organisation.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The Resource row.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
HTTPException: 404 if missing or the user is not a member of the
|
|
75
|
+
owning org, 403 if the role is insufficient.
|
|
76
|
+
"""
|
|
77
|
+
try:
|
|
78
|
+
resource = store.load_resource(resource_id)
|
|
79
|
+
except NotFoundError:
|
|
80
|
+
raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found")
|
|
81
|
+
authorize_org_member(user, resource.org_id, store, minimum=minimum, detail=f"Resource {resource_id} not found")
|
|
82
|
+
return resource
|
|
83
|
+
|
|
84
|
+
|
|
52
85
|
@router.get("/kinds")
|
|
53
86
|
def list_resource_kinds(
|
|
54
87
|
catalog: dict[str, Any] = Depends(get_catalog),
|
|
@@ -90,14 +123,14 @@ def list_resources(
|
|
|
90
123
|
@router.get("/{resource_id}")
|
|
91
124
|
def get_resource(
|
|
92
125
|
resource_id: UUID,
|
|
93
|
-
user: Profile = Depends(
|
|
126
|
+
user: Profile = Depends(get_current_user),
|
|
94
127
|
store: Store = Depends(get_store),
|
|
95
128
|
) -> ResourceDetailResponse:
|
|
96
|
-
"""Get a single resource by ID, including its data payload.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
129
|
+
"""Get a single resource by ID, including its data payload.
|
|
130
|
+
|
|
131
|
+
Authorized by membership in the resource's org.
|
|
132
|
+
"""
|
|
133
|
+
r = _load_authorized_resource(resource_id, user, store)
|
|
101
134
|
|
|
102
135
|
return ResourceDetailResponse(
|
|
103
136
|
id=r.id,
|
|
@@ -144,10 +177,11 @@ def create_resource(
|
|
|
144
177
|
def update_resource(
|
|
145
178
|
resource_id: UUID,
|
|
146
179
|
body: ResourceCreateRequest,
|
|
147
|
-
user: Profile = Depends(
|
|
180
|
+
user: Profile = Depends(get_current_user),
|
|
148
181
|
store: Store = Depends(get_store),
|
|
149
182
|
) -> ResourceResponse:
|
|
150
183
|
"""Update an existing resource."""
|
|
184
|
+
_load_authorized_resource(resource_id, user, store, minimum="editor")
|
|
151
185
|
try:
|
|
152
186
|
resource = store.update_resource(
|
|
153
187
|
resource_id,
|
|
@@ -174,9 +208,10 @@ def update_resource(
|
|
|
174
208
|
@router.delete("/{resource_id}")
|
|
175
209
|
def delete_resource(
|
|
176
210
|
resource_id: UUID,
|
|
177
|
-
user: Profile = Depends(
|
|
211
|
+
user: Profile = Depends(get_current_user),
|
|
178
212
|
store: Store = Depends(get_store),
|
|
179
213
|
) -> dict[str, str]:
|
|
180
214
|
"""Delete a resource."""
|
|
215
|
+
_load_authorized_resource(resource_id, user, store, minimum="editor")
|
|
181
216
|
store.delete_resource(resource_id)
|
|
182
217
|
return {"status": "deleted"}
|
|
@@ -12,7 +12,7 @@ from interloper_db import Profile, Store
|
|
|
12
12
|
from interloper_db.models import Event, Run
|
|
13
13
|
from pydantic import BaseModel
|
|
14
14
|
|
|
15
|
-
from interloper_api.dependencies import get_org_id, get_store,
|
|
15
|
+
from interloper_api.dependencies import authorize_org_member, get_current_user, get_org_id, get_store, require_viewer
|
|
16
16
|
|
|
17
17
|
router = APIRouter()
|
|
18
18
|
|
|
@@ -100,6 +100,30 @@ def _run_to_response(run: Run) -> RunResponse:
|
|
|
100
100
|
)
|
|
101
101
|
|
|
102
102
|
|
|
103
|
+
def _load_authorized_run(run_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> Run:
|
|
104
|
+
"""Load a run and authorize the user by membership in its org.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
run_id: The run UUID.
|
|
108
|
+
user: The authenticated user.
|
|
109
|
+
store: The Store instance.
|
|
110
|
+
minimum: Minimum role required in the run's organisation.
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
The Run row.
|
|
114
|
+
|
|
115
|
+
Raises:
|
|
116
|
+
HTTPException: 404 if missing or the user is not a member of the
|
|
117
|
+
owning org, 403 if the role is insufficient.
|
|
118
|
+
"""
|
|
119
|
+
try:
|
|
120
|
+
run = store.get_run(run_id)
|
|
121
|
+
except NotFoundError:
|
|
122
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
123
|
+
authorize_org_member(user, run.org_id, store, minimum=minimum, detail=f"Run {run_id} not found")
|
|
124
|
+
return run
|
|
125
|
+
|
|
126
|
+
|
|
103
127
|
def _event_to_response(event: Event) -> EventResponse:
|
|
104
128
|
"""Convert a DB Event to an EventResponse.
|
|
105
129
|
|
|
@@ -158,24 +182,22 @@ def list_runs(
|
|
|
158
182
|
@router.get("/{run_id}")
|
|
159
183
|
def get_run(
|
|
160
184
|
run_id: UUID,
|
|
161
|
-
user: Profile = Depends(
|
|
185
|
+
user: Profile = Depends(get_current_user),
|
|
162
186
|
store: Store = Depends(get_store),
|
|
163
187
|
) -> RunResponse:
|
|
164
|
-
"""Get a single run by ID."""
|
|
165
|
-
|
|
166
|
-
run = store.get_run(run_id)
|
|
167
|
-
except NotFoundError:
|
|
168
|
-
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
188
|
+
"""Get a single run by ID. Authorized by membership in the run's org."""
|
|
189
|
+
run = _load_authorized_run(run_id, user, store)
|
|
169
190
|
return _run_to_response(run)
|
|
170
191
|
|
|
171
192
|
|
|
172
193
|
@router.get("/{run_id}/asset-executions")
|
|
173
194
|
def list_asset_executions(
|
|
174
195
|
run_id: UUID,
|
|
175
|
-
user: Profile = Depends(
|
|
196
|
+
user: Profile = Depends(get_current_user),
|
|
176
197
|
store: Store = Depends(get_store),
|
|
177
198
|
) -> list[AssetExecutionResponse]:
|
|
178
199
|
"""List asset executions for a run."""
|
|
200
|
+
_load_authorized_run(run_id, user, store)
|
|
179
201
|
rows = store.list_asset_executions(run_id)
|
|
180
202
|
return [
|
|
181
203
|
AssetExecutionResponse(
|
|
@@ -196,7 +218,7 @@ def list_asset_executions(
|
|
|
196
218
|
def retry_run(
|
|
197
219
|
run_id: UUID,
|
|
198
220
|
body: RetryRequest | None = None,
|
|
199
|
-
user: Profile = Depends(
|
|
221
|
+
user: Profile = Depends(get_current_user),
|
|
200
222
|
store: Store = Depends(get_store),
|
|
201
223
|
) -> dict[str, str]:
|
|
202
224
|
"""Queue a retry of a failed run.
|
|
@@ -205,6 +227,7 @@ def retry_run(
|
|
|
205
227
|
``scope="all"`` the whole DAG re-runs; with ``scope="failed"`` only the
|
|
206
228
|
previously failed/cancelled assets re-run.
|
|
207
229
|
"""
|
|
230
|
+
_load_authorized_run(run_id, user, store, minimum="editor")
|
|
208
231
|
scope = body.scope if body else "all"
|
|
209
232
|
try:
|
|
210
233
|
run = store.retry_run(run_id, scope=scope)
|
|
@@ -222,7 +245,7 @@ def list_run_events(
|
|
|
222
245
|
limit: int = 100,
|
|
223
246
|
offset: int = 0,
|
|
224
247
|
asset_id: UUID | None = None,
|
|
225
|
-
user: Profile = Depends(
|
|
248
|
+
user: Profile = Depends(get_current_user),
|
|
226
249
|
store: Store = Depends(get_store),
|
|
227
250
|
) -> list[EventResponse]:
|
|
228
251
|
"""List events for a run, oldest first.
|
|
@@ -234,6 +257,7 @@ def list_run_events(
|
|
|
234
257
|
through every event — including the terminal/outcome events
|
|
235
258
|
(``asset_completed``, ``asset_failed``, ``run_failed``, …) that sort last.
|
|
236
259
|
"""
|
|
260
|
+
_load_authorized_run(run_id, user, store)
|
|
237
261
|
limit = max(1, min(limit, MAX_EVENTS_PAGE_SIZE))
|
|
238
262
|
offset = max(0, offset)
|
|
239
263
|
total = store.count_events(run_id=run_id, asset_id=asset_id)
|
|
@@ -13,7 +13,14 @@ from pydantic import BaseModel
|
|
|
13
13
|
from sqlalchemy.orm import selectinload
|
|
14
14
|
from sqlmodel import Session, select
|
|
15
15
|
|
|
16
|
-
from interloper_api.dependencies import
|
|
16
|
+
from interloper_api.dependencies import (
|
|
17
|
+
authorize_org_member,
|
|
18
|
+
get_current_user,
|
|
19
|
+
get_org_id,
|
|
20
|
+
get_store,
|
|
21
|
+
require_editor,
|
|
22
|
+
require_viewer,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
router = APIRouter()
|
|
19
26
|
|
|
@@ -152,15 +159,29 @@ def create_source(
|
|
|
152
159
|
return _load_source_for_response(source.id)
|
|
153
160
|
|
|
154
161
|
|
|
162
|
+
def _authorize_source(source_id: UUID, user: Profile, store: Store, *, minimum: str = "viewer") -> None:
|
|
163
|
+
"""Authorize the user by membership in the source's org.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
HTTPException: 404 if missing or the user is not a member of the
|
|
167
|
+
owning org, 403 if the role is insufficient.
|
|
168
|
+
"""
|
|
169
|
+
try:
|
|
170
|
+
source = store.get_source(source_id)
|
|
171
|
+
except NotFoundError:
|
|
172
|
+
raise HTTPException(status_code=404, detail=f"Source {source_id} not found")
|
|
173
|
+
authorize_org_member(user, source.org_id, store, minimum=minimum, detail=f"Source {source_id} not found")
|
|
174
|
+
|
|
175
|
+
|
|
155
176
|
@router.put("/{source_id}")
|
|
156
177
|
def update_source(
|
|
157
178
|
source_id: UUID,
|
|
158
179
|
body: SourceCreateRequest,
|
|
159
|
-
user: Profile = Depends(
|
|
160
|
-
org_id: UUID = Depends(get_org_id),
|
|
180
|
+
user: Profile = Depends(get_current_user),
|
|
161
181
|
store: Store = Depends(get_store),
|
|
162
182
|
) -> SourceResponse:
|
|
163
183
|
"""Update a source."""
|
|
184
|
+
_authorize_source(source_id, user, store, minimum="editor")
|
|
164
185
|
try:
|
|
165
186
|
store.update_source(
|
|
166
187
|
source_id,
|
|
@@ -179,9 +200,10 @@ def update_source(
|
|
|
179
200
|
@router.delete("/{source_id}")
|
|
180
201
|
def delete_source(
|
|
181
202
|
source_id: UUID,
|
|
182
|
-
user: Profile = Depends(
|
|
203
|
+
user: Profile = Depends(get_current_user),
|
|
183
204
|
store: Store = Depends(get_store),
|
|
184
205
|
) -> dict[str, str]:
|
|
185
206
|
"""Delete a source. Assets cascade via FK."""
|
|
207
|
+
_authorize_source(source_id, user, store, minimum="editor")
|
|
186
208
|
store.delete_source(source_id)
|
|
187
209
|
return {"status": "deleted"}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/amazon_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/facebook_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/google_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/google_cloud.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/impact.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/pinterest_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/snapchat_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.16.0 → interloper_api-0.18.0}/src/interloper_api/routes/external/tiktok_ads.py
RENAMED
|
File without changes
|
|
File without changes
|