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,304 @@
|
|
|
1
|
+
"""Assets API: CRUD endpoints for assets and dependencies."""
|
|
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 DataNotFoundError, NotFoundError
|
|
10
|
+
from interloper_db import Profile, Store
|
|
11
|
+
from interloper_db.models import Asset, AssetResource, Destination, DestinationResource
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
from sqlmodel import Session, select
|
|
14
|
+
|
|
15
|
+
from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
|
|
16
|
+
|
|
17
|
+
router = APIRouter()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# -- Request/Response models --------------------------------------------------
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AssetCreateRequest(BaseModel):
|
|
24
|
+
"""Request body for creating a standalone asset."""
|
|
25
|
+
|
|
26
|
+
key: str
|
|
27
|
+
config: dict[str, Any] | None = None
|
|
28
|
+
resources: dict[str, str] | None = None
|
|
29
|
+
destination_ids: list[str] | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AssetUpdateRequest(BaseModel):
|
|
33
|
+
"""Request body for updating an asset."""
|
|
34
|
+
|
|
35
|
+
materializable: bool | None = None
|
|
36
|
+
config: dict[str, Any] | None = None
|
|
37
|
+
resources: dict[str, str] | None = None
|
|
38
|
+
destination_ids: list[str] | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DestinationResponse(BaseModel):
|
|
42
|
+
"""Nested destination in asset response."""
|
|
43
|
+
|
|
44
|
+
id: UUID
|
|
45
|
+
key: str
|
|
46
|
+
name: str | None = None
|
|
47
|
+
config: dict[str, Any] | None = None
|
|
48
|
+
resources: dict[str, str] = {}
|
|
49
|
+
created_at: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class AssetResponse(BaseModel):
|
|
53
|
+
"""Response body for an asset."""
|
|
54
|
+
|
|
55
|
+
id: UUID
|
|
56
|
+
source_id: UUID | None
|
|
57
|
+
org_id: UUID
|
|
58
|
+
key: str
|
|
59
|
+
materializable: bool
|
|
60
|
+
config: dict[str, Any] | None = None
|
|
61
|
+
resources: dict[str, str] = {}
|
|
62
|
+
destinations: list[DestinationResponse] = []
|
|
63
|
+
created_at: str | None = None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class DependencyResponse(BaseModel):
|
|
67
|
+
"""Response body for an asset dependency edge."""
|
|
68
|
+
|
|
69
|
+
asset_id: UUID
|
|
70
|
+
upstream_asset_id: UUID
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AddDependencyRequest(BaseModel):
|
|
74
|
+
"""Request body for adding an asset dependency."""
|
|
75
|
+
|
|
76
|
+
upstream_asset_id: UUID
|
|
77
|
+
param_name: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class PartitionRowCountItem(BaseModel):
|
|
81
|
+
"""A single partition row count entry."""
|
|
82
|
+
|
|
83
|
+
partition: str
|
|
84
|
+
row_count: int
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PartitionRowCountsResponse(BaseModel):
|
|
88
|
+
"""Response body for partition row counts."""
|
|
89
|
+
|
|
90
|
+
asset_key: str
|
|
91
|
+
partition_column: str
|
|
92
|
+
counts: list[PartitionRowCountItem]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# -- Helpers ------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _resource_map(junction_cls: type, fk_column: str, fk_value: UUID) -> dict[str, str]:
|
|
99
|
+
"""Build a {slot_key: resource_id} map from junction rows."""
|
|
100
|
+
from interloper_db.engine import get_engine
|
|
101
|
+
|
|
102
|
+
col = getattr(junction_cls, fk_column)
|
|
103
|
+
with Session(get_engine()) as s:
|
|
104
|
+
rows = s.exec(select(junction_cls).where(col == fk_value)).all()
|
|
105
|
+
return {r.key: str(r.resource_id) for r in rows}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _dest_to_response(dest: Destination) -> DestinationResponse:
|
|
109
|
+
return DestinationResponse(
|
|
110
|
+
id=dest.id, # type: ignore[arg-type]
|
|
111
|
+
key=dest.key,
|
|
112
|
+
name=dest.name,
|
|
113
|
+
config=dest.config,
|
|
114
|
+
resources=_resource_map(DestinationResource, "destination_id", dest.id), # type: ignore[arg-type]
|
|
115
|
+
created_at=str(dest.created_at) if dest.created_at else None,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _asset_to_response(asset: Asset) -> AssetResponse:
|
|
120
|
+
"""Convert a DB Asset to an AssetResponse."""
|
|
121
|
+
return AssetResponse(
|
|
122
|
+
id=asset.id, # type: ignore[arg-type]
|
|
123
|
+
source_id=asset.source_id,
|
|
124
|
+
org_id=asset.org_id,
|
|
125
|
+
key=asset.key,
|
|
126
|
+
materializable=asset.materializable,
|
|
127
|
+
config=asset.config,
|
|
128
|
+
resources=_resource_map(AssetResource, "asset_id", asset.id), # type: ignore[arg-type]
|
|
129
|
+
destinations=[_dest_to_response(d) for d in asset.destinations],
|
|
130
|
+
created_at=str(asset.created_at) if asset.created_at else None,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# -- CRUD endpoints -----------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/")
|
|
138
|
+
def list_assets(
|
|
139
|
+
user: Profile = Depends(require_viewer),
|
|
140
|
+
org_id: UUID = Depends(get_org_id),
|
|
141
|
+
store: Store = Depends(get_store),
|
|
142
|
+
) -> list[AssetResponse]:
|
|
143
|
+
"""List all assets for the current organisation."""
|
|
144
|
+
assets = store.list_assets(org_id)
|
|
145
|
+
return [_asset_to_response(a) for a in assets]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("/", status_code=201)
|
|
149
|
+
def create_asset(
|
|
150
|
+
body: AssetCreateRequest,
|
|
151
|
+
user: Profile = Depends(require_editor),
|
|
152
|
+
org_id: UUID = Depends(get_org_id),
|
|
153
|
+
store: Store = Depends(get_store),
|
|
154
|
+
) -> AssetResponse:
|
|
155
|
+
"""Create a standalone asset."""
|
|
156
|
+
asset = store.create_asset(
|
|
157
|
+
org_id,
|
|
158
|
+
key=body.key,
|
|
159
|
+
config=body.config,
|
|
160
|
+
resources=body.resources,
|
|
161
|
+
destination_ids=body.destination_ids,
|
|
162
|
+
)
|
|
163
|
+
return _asset_to_response(asset)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@router.get("/dependencies")
|
|
167
|
+
def list_dependencies(
|
|
168
|
+
user: Profile = Depends(require_viewer),
|
|
169
|
+
org_id: UUID = Depends(get_org_id),
|
|
170
|
+
store: Store = Depends(get_store),
|
|
171
|
+
) -> list[DependencyResponse]:
|
|
172
|
+
"""List all asset dependency edges for the current organisation."""
|
|
173
|
+
deps = store.list_dependencies(org_id)
|
|
174
|
+
return [
|
|
175
|
+
DependencyResponse(
|
|
176
|
+
asset_id=d.asset_id,
|
|
177
|
+
upstream_asset_id=d.upstream_asset_id,
|
|
178
|
+
)
|
|
179
|
+
for d in deps
|
|
180
|
+
]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.get("/{asset_id}")
|
|
184
|
+
def get_asset(
|
|
185
|
+
asset_id: UUID,
|
|
186
|
+
user: Profile = Depends(require_viewer),
|
|
187
|
+
store: Store = Depends(get_store),
|
|
188
|
+
) -> AssetResponse:
|
|
189
|
+
"""Get a single asset by ID."""
|
|
190
|
+
try:
|
|
191
|
+
asset = store.get_asset(asset_id)
|
|
192
|
+
except NotFoundError:
|
|
193
|
+
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
|
194
|
+
return _asset_to_response(asset)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@router.put("/{asset_id}")
|
|
198
|
+
def update_asset(
|
|
199
|
+
asset_id: UUID,
|
|
200
|
+
body: AssetUpdateRequest,
|
|
201
|
+
user: Profile = Depends(require_editor),
|
|
202
|
+
store: Store = Depends(get_store),
|
|
203
|
+
) -> AssetResponse:
|
|
204
|
+
"""Update an asset's configuration, resources, or destinations."""
|
|
205
|
+
try:
|
|
206
|
+
asset = store.update_asset(
|
|
207
|
+
asset_id,
|
|
208
|
+
materializable=body.materializable,
|
|
209
|
+
config=body.config,
|
|
210
|
+
resources=body.resources,
|
|
211
|
+
destination_ids=body.destination_ids,
|
|
212
|
+
)
|
|
213
|
+
except NotFoundError:
|
|
214
|
+
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
|
215
|
+
return _asset_to_response(asset)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.delete("/{asset_id}")
|
|
219
|
+
def delete_asset(
|
|
220
|
+
asset_id: UUID,
|
|
221
|
+
user: Profile = Depends(require_editor),
|
|
222
|
+
store: Store = Depends(get_store),
|
|
223
|
+
) -> dict[str, str]:
|
|
224
|
+
"""Delete a standalone asset."""
|
|
225
|
+
try:
|
|
226
|
+
store.delete_asset(asset_id)
|
|
227
|
+
except NotFoundError:
|
|
228
|
+
raise HTTPException(status_code=404, detail=f"Asset {asset_id} not found")
|
|
229
|
+
except ValueError as e:
|
|
230
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
231
|
+
return {"status": "deleted"}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# -- Dependency endpoints -----------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@router.post("/{asset_id}/dependencies", status_code=201)
|
|
238
|
+
def add_dependency(
|
|
239
|
+
asset_id: UUID,
|
|
240
|
+
body: AddDependencyRequest,
|
|
241
|
+
user: Profile = Depends(require_editor),
|
|
242
|
+
store: Store = Depends(get_store),
|
|
243
|
+
) -> DependencyResponse:
|
|
244
|
+
"""Add a dependency between two assets."""
|
|
245
|
+
try:
|
|
246
|
+
dep = store.add_dependency(asset_id, body.upstream_asset_id, body.param_name)
|
|
247
|
+
except NotFoundError as e:
|
|
248
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
249
|
+
return DependencyResponse(
|
|
250
|
+
asset_id=dep.asset_id,
|
|
251
|
+
upstream_asset_id=dep.upstream_asset_id,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@router.delete("/{asset_id}/dependencies/{upstream_asset_id}", status_code=204)
|
|
256
|
+
def remove_dependency(
|
|
257
|
+
asset_id: UUID,
|
|
258
|
+
upstream_asset_id: UUID,
|
|
259
|
+
user: Profile = Depends(require_editor),
|
|
260
|
+
store: Store = Depends(get_store),
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Remove an asset dependency."""
|
|
263
|
+
try:
|
|
264
|
+
store.remove_dependency(asset_id, upstream_asset_id)
|
|
265
|
+
except NotFoundError as e:
|
|
266
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# -- Partition endpoint -------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@router.get("/{asset_id}/partition-row-counts")
|
|
273
|
+
def get_partition_row_counts(
|
|
274
|
+
asset_id: UUID,
|
|
275
|
+
user: Profile = Depends(require_viewer),
|
|
276
|
+
org_id: UUID = Depends(get_org_id),
|
|
277
|
+
store: Store = Depends(get_store),
|
|
278
|
+
) -> PartitionRowCountsResponse:
|
|
279
|
+
"""Get row counts grouped by partition for an asset."""
|
|
280
|
+
try:
|
|
281
|
+
il_asset = store.load_asset(asset_id)
|
|
282
|
+
except NotFoundError as e:
|
|
283
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
284
|
+
|
|
285
|
+
if not il_asset.partitioning:
|
|
286
|
+
raise HTTPException(status_code=400, detail="Asset is not partitioned")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
counts = il_asset.partition_row_counts()
|
|
290
|
+
except NotImplementedError:
|
|
291
|
+
raise HTTPException(status_code=400, detail="Destination does not support partition row counts")
|
|
292
|
+
except DataNotFoundError as e:
|
|
293
|
+
raise HTTPException(status_code=404, detail=str(e))
|
|
294
|
+
except Exception as e:
|
|
295
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
296
|
+
|
|
297
|
+
return PartitionRowCountsResponse(
|
|
298
|
+
asset_key=il_asset.key,
|
|
299
|
+
partition_column=il_asset.partitioning.column,
|
|
300
|
+
counts=[
|
|
301
|
+
PartitionRowCountItem(partition=str(k), row_count=v)
|
|
302
|
+
for k, v in sorted(counts.items())
|
|
303
|
+
],
|
|
304
|
+
)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Authentication routes — Google OAuth, session management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from fastapi import APIRouter, Cookie, Depends, HTTPException, Response
|
|
11
|
+
from fastapi.responses import RedirectResponse
|
|
12
|
+
from interloper_db import Organisation, Profile, Store
|
|
13
|
+
from pydantic import BaseModel
|
|
14
|
+
|
|
15
|
+
from interloper_api.dependencies import get_auth_config, get_current_user, get_store
|
|
16
|
+
|
|
17
|
+
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
18
|
+
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
|
19
|
+
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
|
20
|
+
|
|
21
|
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrganisationResponse(BaseModel):
|
|
25
|
+
"""Organisation summary for auth responses."""
|
|
26
|
+
|
|
27
|
+
id: UUID
|
|
28
|
+
name: str
|
|
29
|
+
created_at: datetime | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AuthUserResponse(BaseModel):
|
|
33
|
+
"""Current user with active organisation context."""
|
|
34
|
+
|
|
35
|
+
id: UUID
|
|
36
|
+
email: str
|
|
37
|
+
name: str | None = None
|
|
38
|
+
avatar_url: str | None = None
|
|
39
|
+
role: str
|
|
40
|
+
organisation: OrganisationResponse | None = None
|
|
41
|
+
last_organisation_id: UUID | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@router.get("/google")
|
|
45
|
+
def google_login(
|
|
46
|
+
redirect: str | None = None,
|
|
47
|
+
auth_config: object = Depends(get_auth_config),
|
|
48
|
+
) -> RedirectResponse:
|
|
49
|
+
"""Redirect user to Google's OAuth consent screen."""
|
|
50
|
+
client_id = auth_config.google_client_id # type: ignore[attr-defined]
|
|
51
|
+
redirect_uri = auth_config.google_redirect_uri # type: ignore[attr-defined]
|
|
52
|
+
|
|
53
|
+
if not client_id:
|
|
54
|
+
raise HTTPException(status_code=500, detail="Google OAuth not configured")
|
|
55
|
+
|
|
56
|
+
params = urlencode(
|
|
57
|
+
{
|
|
58
|
+
"client_id": client_id,
|
|
59
|
+
"redirect_uri": redirect_uri,
|
|
60
|
+
"response_type": "code",
|
|
61
|
+
"scope": "openid email profile",
|
|
62
|
+
"access_type": "offline",
|
|
63
|
+
"prompt": "consent",
|
|
64
|
+
"state": redirect or "/",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
return RedirectResponse(url=f"{GOOGLE_AUTH_URL}?{params}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@router.get("/google/callback")
|
|
71
|
+
def google_callback(
|
|
72
|
+
code: str,
|
|
73
|
+
state: str | None = None,
|
|
74
|
+
store: Store = Depends(get_store),
|
|
75
|
+
auth_config: object = Depends(get_auth_config),
|
|
76
|
+
) -> RedirectResponse:
|
|
77
|
+
"""Exchange Google authorization code for tokens, upsert profile, create session."""
|
|
78
|
+
client_id = auth_config.google_client_id # type: ignore[attr-defined]
|
|
79
|
+
client_secret = auth_config.google_client_secret # type: ignore[attr-defined]
|
|
80
|
+
redirect_uri = auth_config.google_redirect_uri # type: ignore[attr-defined]
|
|
81
|
+
cookie_secure: bool = auth_config.cookie_secure # type: ignore[attr-defined]
|
|
82
|
+
session_expiry_days: int = auth_config.session_expiry_days # type: ignore[attr-defined]
|
|
83
|
+
|
|
84
|
+
if not client_id or not client_secret:
|
|
85
|
+
raise HTTPException(status_code=500, detail="Google OAuth not configured")
|
|
86
|
+
|
|
87
|
+
# Exchange code for tokens
|
|
88
|
+
token_resp = httpx.post(
|
|
89
|
+
GOOGLE_TOKEN_URL,
|
|
90
|
+
data={
|
|
91
|
+
"code": code,
|
|
92
|
+
"client_id": client_id,
|
|
93
|
+
"client_secret": client_secret,
|
|
94
|
+
"redirect_uri": redirect_uri,
|
|
95
|
+
"grant_type": "authorization_code",
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
if token_resp.status_code != 200:
|
|
99
|
+
raise HTTPException(status_code=401, detail="Failed to exchange authorization code")
|
|
100
|
+
|
|
101
|
+
tokens = token_resp.json()
|
|
102
|
+
access_token = tokens.get("access_token")
|
|
103
|
+
if not access_token:
|
|
104
|
+
raise HTTPException(status_code=401, detail="No access token in response")
|
|
105
|
+
|
|
106
|
+
# Fetch user info
|
|
107
|
+
userinfo_resp = httpx.get(
|
|
108
|
+
GOOGLE_USERINFO_URL,
|
|
109
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
110
|
+
)
|
|
111
|
+
if userinfo_resp.status_code != 200:
|
|
112
|
+
raise HTTPException(status_code=401, detail="Failed to fetch user info")
|
|
113
|
+
|
|
114
|
+
userinfo = userinfo_resp.json()
|
|
115
|
+
google_id = userinfo.get("id")
|
|
116
|
+
email = userinfo.get("email")
|
|
117
|
+
name = userinfo.get("name")
|
|
118
|
+
avatar_url = userinfo.get("picture")
|
|
119
|
+
|
|
120
|
+
if not google_id or not email:
|
|
121
|
+
raise HTTPException(status_code=401, detail="Incomplete user info from Google")
|
|
122
|
+
|
|
123
|
+
# Upsert profile
|
|
124
|
+
profile = store.upsert_profile(
|
|
125
|
+
google_id=google_id,
|
|
126
|
+
email=email,
|
|
127
|
+
name=name,
|
|
128
|
+
avatar_url=avatar_url,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Create session (no org context — frontend resolves org after login)
|
|
132
|
+
token = store.create_session(user_id=profile.id) # type: ignore[arg-type]
|
|
133
|
+
|
|
134
|
+
redirect_url = state if state and state.startswith("/") else "/"
|
|
135
|
+
response = RedirectResponse(url=redirect_url, status_code=302)
|
|
136
|
+
response.set_cookie(
|
|
137
|
+
key="session_token",
|
|
138
|
+
value=token,
|
|
139
|
+
httponly=True,
|
|
140
|
+
samesite="lax",
|
|
141
|
+
secure=cookie_secure,
|
|
142
|
+
max_age=session_expiry_days * 86400,
|
|
143
|
+
path="/",
|
|
144
|
+
)
|
|
145
|
+
return response
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@router.post("/logout")
|
|
149
|
+
def logout(
|
|
150
|
+
response: Response,
|
|
151
|
+
user: Profile = Depends(get_current_user),
|
|
152
|
+
store: Store = Depends(get_store),
|
|
153
|
+
) -> dict[str, str]:
|
|
154
|
+
"""Delete all sessions for the current user and clear the cookie."""
|
|
155
|
+
store.delete_user_sessions(user.id) # type: ignore[arg-type]
|
|
156
|
+
response.delete_cookie("session_token", path="/")
|
|
157
|
+
return {"status": "ok"}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@router.get("/me")
|
|
161
|
+
def get_me(
|
|
162
|
+
store: Store = Depends(get_store),
|
|
163
|
+
session_token: str | None = Cookie(default=None),
|
|
164
|
+
) -> AuthUserResponse:
|
|
165
|
+
"""Return the current user and their active organisation (if any)."""
|
|
166
|
+
if not session_token:
|
|
167
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
168
|
+
|
|
169
|
+
result = store.resolve_session(session_token)
|
|
170
|
+
if not result:
|
|
171
|
+
raise HTTPException(status_code=401, detail="Invalid or expired session")
|
|
172
|
+
|
|
173
|
+
profile, session_row = result
|
|
174
|
+
org: Organisation | None = None
|
|
175
|
+
role = "viewer"
|
|
176
|
+
|
|
177
|
+
if session_row.organisation_id:
|
|
178
|
+
org = store.get_organisation(session_row.organisation_id)
|
|
179
|
+
|
|
180
|
+
if org and profile.id:
|
|
181
|
+
user_role = store.get_user_role(profile.id, org.id) # type: ignore[arg-type]
|
|
182
|
+
if user_role:
|
|
183
|
+
role = user_role
|
|
184
|
+
|
|
185
|
+
return AuthUserResponse(
|
|
186
|
+
id=profile.id, # type: ignore[arg-type]
|
|
187
|
+
email=profile.email,
|
|
188
|
+
name=profile.name,
|
|
189
|
+
avatar_url=profile.avatar_url,
|
|
190
|
+
role=role,
|
|
191
|
+
organisation=OrganisationResponse.model_validate(org, from_attributes=True) if org else None,
|
|
192
|
+
last_organisation_id=profile.last_organisation_id,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class SwitchOrgRequest(BaseModel):
|
|
197
|
+
"""Request body for switching the active organisation."""
|
|
198
|
+
|
|
199
|
+
organisation_id: UUID
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@router.post("/switch-org")
|
|
203
|
+
def switch_org(
|
|
204
|
+
body: SwitchOrgRequest,
|
|
205
|
+
user: Profile = Depends(get_current_user),
|
|
206
|
+
store: Store = Depends(get_store),
|
|
207
|
+
session_token: str | None = Cookie(default=None),
|
|
208
|
+
) -> dict[str, str]:
|
|
209
|
+
"""Switch the session's active organisation. User must be a member."""
|
|
210
|
+
role = store.get_user_role(user.id, body.organisation_id) # type: ignore[arg-type]
|
|
211
|
+
if not role:
|
|
212
|
+
raise HTTPException(status_code=403, detail="Not a member of this organisation")
|
|
213
|
+
|
|
214
|
+
if session_token:
|
|
215
|
+
store.set_session_org(session_token, body.organisation_id, user_id=user.id) # type: ignore[arg-type]
|
|
216
|
+
return {"status": "ok"}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class AcceptInviteRequest(BaseModel):
|
|
220
|
+
"""Request body for accepting an invitation."""
|
|
221
|
+
|
|
222
|
+
token: str
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@router.post("/accept-invite")
|
|
226
|
+
def accept_invite(
|
|
227
|
+
body: AcceptInviteRequest,
|
|
228
|
+
user: Profile = Depends(get_current_user),
|
|
229
|
+
store: Store = Depends(get_store),
|
|
230
|
+
session_token: str | None = Cookie(default=None),
|
|
231
|
+
) -> dict[str, str]:
|
|
232
|
+
"""Accept an organisation invitation using its token."""
|
|
233
|
+
org = store.accept_invitation(body.token, user.id) # type: ignore[arg-type]
|
|
234
|
+
if not org:
|
|
235
|
+
raise HTTPException(status_code=400, detail="Invalid or expired invitation")
|
|
236
|
+
|
|
237
|
+
# Switch to the new org
|
|
238
|
+
if session_token:
|
|
239
|
+
store.set_session_org(session_token, org.id, user_id=user.id) # type: ignore[arg-type]
|
|
240
|
+
|
|
241
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Backfills API: read endpoints for backfill state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as dt
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from interloper.errors import NotFoundError
|
|
10
|
+
from interloper_db import Profile, Store
|
|
11
|
+
from interloper_db.models import Backfill
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from interloper_api.dependencies import get_org_id, get_store, require_viewer
|
|
15
|
+
|
|
16
|
+
router = APIRouter()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BackfillResponse(BaseModel):
|
|
20
|
+
"""Response body for a backfill."""
|
|
21
|
+
|
|
22
|
+
id: UUID
|
|
23
|
+
org_id: UUID
|
|
24
|
+
job_id: UUID | None
|
|
25
|
+
status: str
|
|
26
|
+
start_date: dt.date
|
|
27
|
+
end_date: dt.date
|
|
28
|
+
concurrency: int
|
|
29
|
+
fail_fast: bool
|
|
30
|
+
partitions: int
|
|
31
|
+
started_at: str | None = None
|
|
32
|
+
completed_at: str | None = None
|
|
33
|
+
created_at: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _backfill_to_response(backfill: Backfill) -> BackfillResponse:
|
|
37
|
+
"""Convert a DB Backfill to a BackfillResponse.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
backfill: The DB Backfill row.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
The response model.
|
|
44
|
+
"""
|
|
45
|
+
return BackfillResponse(
|
|
46
|
+
id=backfill.id, # type: ignore[arg-type]
|
|
47
|
+
org_id=backfill.org_id,
|
|
48
|
+
job_id=backfill.job_id,
|
|
49
|
+
status=backfill.status,
|
|
50
|
+
start_date=backfill.start_date,
|
|
51
|
+
end_date=backfill.end_date,
|
|
52
|
+
concurrency=backfill.concurrency,
|
|
53
|
+
fail_fast=backfill.fail_fast,
|
|
54
|
+
partitions=backfill.partitions,
|
|
55
|
+
started_at=str(backfill.started_at) if backfill.started_at else None,
|
|
56
|
+
completed_at=str(backfill.completed_at) if backfill.completed_at else None,
|
|
57
|
+
created_at=str(backfill.created_at) if backfill.created_at else None,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@router.get("/")
|
|
62
|
+
def list_backfills(
|
|
63
|
+
active_only: bool = False,
|
|
64
|
+
user: Profile = Depends(require_viewer),
|
|
65
|
+
org_id: UUID = Depends(get_org_id),
|
|
66
|
+
store: Store = Depends(get_store),
|
|
67
|
+
) -> list[BackfillResponse]:
|
|
68
|
+
"""List backfills for the current organisation."""
|
|
69
|
+
if active_only:
|
|
70
|
+
backfills = store.list_active_backfills(org_id)
|
|
71
|
+
else:
|
|
72
|
+
backfills = store.list_backfills(org_id)
|
|
73
|
+
return [_backfill_to_response(b) for b in backfills]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@router.get("/{backfill_id}")
|
|
77
|
+
def get_backfill(
|
|
78
|
+
backfill_id: UUID,
|
|
79
|
+
user: Profile = Depends(require_viewer),
|
|
80
|
+
store: Store = Depends(get_store),
|
|
81
|
+
) -> BackfillResponse:
|
|
82
|
+
"""Get a single backfill by ID."""
|
|
83
|
+
try:
|
|
84
|
+
backfill = store.get_backfill(backfill_id)
|
|
85
|
+
except NotFoundError:
|
|
86
|
+
raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
|
|
87
|
+
return _backfill_to_response(backfill)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Catalog API: serves component definitions for frontend consumption."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, Depends
|
|
8
|
+
|
|
9
|
+
from interloper_api.dependencies import get_catalog
|
|
10
|
+
|
|
11
|
+
router = APIRouter()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@router.get("/")
|
|
15
|
+
def list_catalog(catalog: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
|
|
16
|
+
"""Return the full catalog."""
|
|
17
|
+
return catalog
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@router.get("/{key}")
|
|
21
|
+
def get_definition(key: str, catalog: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
|
|
22
|
+
"""Return a single component definition by key.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
key: The component key.
|
|
26
|
+
catalog: Injected catalog.
|
|
27
|
+
|
|
28
|
+
Raises:
|
|
29
|
+
HTTPException: If the key is not found.
|
|
30
|
+
"""
|
|
31
|
+
from fastapi import HTTPException
|
|
32
|
+
|
|
33
|
+
if key not in catalog:
|
|
34
|
+
raise HTTPException(status_code=404, detail=f"Component '{key}' not found in catalog")
|
|
35
|
+
return catalog[key]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/kind/{kind}")
|
|
39
|
+
def list_by_kind(kind: str, catalog: dict[str, Any] = Depends(get_catalog)) -> dict[str, Any]:
|
|
40
|
+
"""Return all catalog entries matching a kind (source, asset, resource, destination).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
kind: The component kind to filter by.
|
|
44
|
+
catalog: Injected catalog.
|
|
45
|
+
"""
|
|
46
|
+
return {k: v for k, v in catalog.items() if v.get("kind") == kind}
|