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,278 @@
|
|
|
1
|
+
"""Organisation routes — CRUD, membership, and invitation management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, Cookie, Depends, HTTPException, Request
|
|
10
|
+
from interloper_db import Profile, Store
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from interloper_api.dependencies import get_current_user, get_org_id, get_store, require_admin, require_viewer
|
|
14
|
+
from interloper_api.email import send_invite_email
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/organisations", tags=["organisations"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# -- Response / Request models ------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OrganisationResponse(BaseModel):
|
|
25
|
+
"""Organisation summary."""
|
|
26
|
+
|
|
27
|
+
id: UUID
|
|
28
|
+
name: str
|
|
29
|
+
created_at: datetime | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CreateOrganisationRequest(BaseModel):
|
|
33
|
+
"""Request body for creating an organisation."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MemberResponse(BaseModel):
|
|
39
|
+
"""Organisation member."""
|
|
40
|
+
|
|
41
|
+
id: UUID
|
|
42
|
+
email: str
|
|
43
|
+
name: str | None = None
|
|
44
|
+
avatar_url: str | None = None
|
|
45
|
+
role: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InviteRequest(BaseModel):
|
|
49
|
+
"""Request body for inviting a user."""
|
|
50
|
+
|
|
51
|
+
email: str
|
|
52
|
+
role: str = "viewer"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class InvitationResponse(BaseModel):
|
|
56
|
+
"""Pending invitation."""
|
|
57
|
+
|
|
58
|
+
id: UUID
|
|
59
|
+
email: str
|
|
60
|
+
role: str
|
|
61
|
+
created_at: datetime | None = None
|
|
62
|
+
expires_at: datetime
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# -- Helpers ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _get_smtp_config() -> object | None:
|
|
69
|
+
"""Return the SMTP config if available, without raising."""
|
|
70
|
+
from interloper_api.dependencies import get_smtp_config
|
|
71
|
+
|
|
72
|
+
return get_smtp_config()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _send_invitation_email(
|
|
76
|
+
request: Request,
|
|
77
|
+
smtp_config: object,
|
|
78
|
+
invitation: object,
|
|
79
|
+
org_name: str,
|
|
80
|
+
inviter_name: str,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Send the invitation email, logging errors without failing the request.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
request: The FastAPI request (for building the invite URL).
|
|
86
|
+
smtp_config: SmtpConfig instance.
|
|
87
|
+
invitation: Invitation row with .token and .email.
|
|
88
|
+
org_name: Organisation name.
|
|
89
|
+
inviter_name: Inviter display name.
|
|
90
|
+
"""
|
|
91
|
+
token = invitation.token # type: ignore[attr-defined]
|
|
92
|
+
email = invitation.email # type: ignore[attr-defined]
|
|
93
|
+
base_url = str(request.base_url).rstrip("/")
|
|
94
|
+
invite_url = f"{base_url}/invite/{token}"
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
send_invite_email(
|
|
98
|
+
smtp_config=smtp_config,
|
|
99
|
+
to=email,
|
|
100
|
+
org_name=org_name,
|
|
101
|
+
inviter_name=inviter_name,
|
|
102
|
+
invite_url=invite_url,
|
|
103
|
+
)
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("Failed to send invitation email to %s", email)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# -- Organisation CRUD -------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.post("", status_code=201)
|
|
112
|
+
def create_organisation(
|
|
113
|
+
body: CreateOrganisationRequest,
|
|
114
|
+
user: Profile = Depends(get_current_user),
|
|
115
|
+
store: Store = Depends(get_store),
|
|
116
|
+
session_token: str | None = Cookie(default=None),
|
|
117
|
+
) -> OrganisationResponse:
|
|
118
|
+
"""Create a new organisation. The creating user becomes its admin."""
|
|
119
|
+
org = store.create_organisation(name=body.name, creator_id=user.id) # type: ignore[arg-type]
|
|
120
|
+
|
|
121
|
+
# Set as active org in session
|
|
122
|
+
if session_token:
|
|
123
|
+
store.set_session_org(session_token, org.id, user_id=user.id) # type: ignore[arg-type]
|
|
124
|
+
|
|
125
|
+
return OrganisationResponse.model_validate(org, from_attributes=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.get("")
|
|
129
|
+
def list_organisations(
|
|
130
|
+
user: Profile = Depends(get_current_user),
|
|
131
|
+
store: Store = Depends(get_store),
|
|
132
|
+
) -> list[OrganisationResponse]:
|
|
133
|
+
"""List all organisations the user belongs to."""
|
|
134
|
+
orgs = store.list_user_organisations(user.id) # type: ignore[arg-type]
|
|
135
|
+
return [OrganisationResponse.model_validate(o, from_attributes=True) for o in orgs]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# -- Members ------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.get("/members")
|
|
142
|
+
def list_members(
|
|
143
|
+
user: Profile = Depends(require_viewer),
|
|
144
|
+
org_id: UUID = Depends(get_org_id),
|
|
145
|
+
store: Store = Depends(get_store),
|
|
146
|
+
) -> list[MemberResponse]:
|
|
147
|
+
"""List all members of the current organisation."""
|
|
148
|
+
members = store.list_org_members(org_id)
|
|
149
|
+
return [
|
|
150
|
+
MemberResponse(
|
|
151
|
+
id=profile.id, # type: ignore[arg-type]
|
|
152
|
+
email=profile.email,
|
|
153
|
+
name=profile.name,
|
|
154
|
+
avatar_url=profile.avatar_url,
|
|
155
|
+
role=role,
|
|
156
|
+
)
|
|
157
|
+
for profile, role in members
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.delete("/members/{user_id}")
|
|
162
|
+
def remove_member(
|
|
163
|
+
user_id: UUID,
|
|
164
|
+
user: Profile = Depends(require_admin),
|
|
165
|
+
org_id: UUID = Depends(get_org_id),
|
|
166
|
+
store: Store = Depends(get_store),
|
|
167
|
+
) -> dict[str, str]:
|
|
168
|
+
"""Remove a member from the organisation. Requires admin role."""
|
|
169
|
+
if user_id == user.id:
|
|
170
|
+
raise HTTPException(status_code=400, detail="Cannot remove yourself")
|
|
171
|
+
|
|
172
|
+
if not store.remove_org_member(org_id, user_id):
|
|
173
|
+
raise HTTPException(status_code=404, detail="Member not found")
|
|
174
|
+
|
|
175
|
+
return {"status": "ok"}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# -- Invitations --------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@router.get("/invitations")
|
|
182
|
+
def list_invitations(
|
|
183
|
+
user: Profile = Depends(require_admin),
|
|
184
|
+
org_id: UUID = Depends(get_org_id),
|
|
185
|
+
store: Store = Depends(get_store),
|
|
186
|
+
) -> list[InvitationResponse]:
|
|
187
|
+
"""List pending invitations for the current organisation. Requires admin role."""
|
|
188
|
+
invitations = store.list_invitations(org_id)
|
|
189
|
+
return [
|
|
190
|
+
InvitationResponse(
|
|
191
|
+
id=inv.id, # type: ignore[arg-type]
|
|
192
|
+
email=inv.email,
|
|
193
|
+
role=inv.role,
|
|
194
|
+
created_at=inv.created_at,
|
|
195
|
+
expires_at=inv.expires_at,
|
|
196
|
+
)
|
|
197
|
+
for inv in invitations
|
|
198
|
+
]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@router.post("/invite", status_code=201)
|
|
202
|
+
def invite_member(
|
|
203
|
+
body: InviteRequest,
|
|
204
|
+
request: Request,
|
|
205
|
+
user: Profile = Depends(require_admin),
|
|
206
|
+
org_id: UUID = Depends(get_org_id),
|
|
207
|
+
store: Store = Depends(get_store),
|
|
208
|
+
) -> InvitationResponse:
|
|
209
|
+
"""Invite a user to the organisation by email. Requires admin role."""
|
|
210
|
+
invitation = store.create_invitation(
|
|
211
|
+
org_id=org_id,
|
|
212
|
+
email=body.email.strip(),
|
|
213
|
+
role=body.role,
|
|
214
|
+
invited_by=user.id, # type: ignore[arg-type]
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# Send invitation email if SMTP is configured
|
|
218
|
+
smtp_config = _get_smtp_config()
|
|
219
|
+
if smtp_config and smtp_config.enabled: # type: ignore[attr-defined]
|
|
220
|
+
org = store.get_organisation(org_id)
|
|
221
|
+
org_name = org.name if org else "Unknown"
|
|
222
|
+
inviter_name = user.name or user.email
|
|
223
|
+
_send_invitation_email(request, smtp_config, invitation, org_name, inviter_name)
|
|
224
|
+
|
|
225
|
+
return InvitationResponse(
|
|
226
|
+
id=invitation.id, # type: ignore[arg-type]
|
|
227
|
+
email=invitation.email,
|
|
228
|
+
role=invitation.role,
|
|
229
|
+
created_at=invitation.created_at,
|
|
230
|
+
expires_at=invitation.expires_at,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@router.delete("/invitations/{invitation_id}")
|
|
235
|
+
def cancel_invitation(
|
|
236
|
+
invitation_id: UUID,
|
|
237
|
+
user: Profile = Depends(require_admin),
|
|
238
|
+
org_id: UUID = Depends(get_org_id),
|
|
239
|
+
store: Store = Depends(get_store),
|
|
240
|
+
) -> dict[str, str]:
|
|
241
|
+
"""Cancel a pending invitation. Requires admin role."""
|
|
242
|
+
if not store.delete_invitation(invitation_id):
|
|
243
|
+
raise HTTPException(status_code=404, detail="Invitation not found")
|
|
244
|
+
|
|
245
|
+
return {"status": "ok"}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@router.post("/invitations/{invitation_id}/resend")
|
|
249
|
+
def resend_invitation(
|
|
250
|
+
invitation_id: UUID,
|
|
251
|
+
request: Request,
|
|
252
|
+
user: Profile = Depends(require_admin),
|
|
253
|
+
org_id: UUID = Depends(get_org_id),
|
|
254
|
+
store: Store = Depends(get_store),
|
|
255
|
+
) -> dict[str, str]:
|
|
256
|
+
"""Resend an invitation (recreates with fresh expiry). Requires admin role."""
|
|
257
|
+
invitations = store.list_invitations(org_id)
|
|
258
|
+
target = next((inv for inv in invitations if inv.id == invitation_id), None)
|
|
259
|
+
if not target:
|
|
260
|
+
raise HTTPException(status_code=404, detail="Invitation not found")
|
|
261
|
+
|
|
262
|
+
store.delete_invitation(invitation_id)
|
|
263
|
+
new_invitation = store.create_invitation(
|
|
264
|
+
org_id=org_id,
|
|
265
|
+
email=target.email,
|
|
266
|
+
role=target.role,
|
|
267
|
+
invited_by=user.id, # type: ignore[arg-type]
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Send invitation email if SMTP is configured
|
|
271
|
+
smtp_config = _get_smtp_config()
|
|
272
|
+
if smtp_config and smtp_config.enabled: # type: ignore[attr-defined]
|
|
273
|
+
org = store.get_organisation(org_id)
|
|
274
|
+
org_name = org.name if org else "Unknown"
|
|
275
|
+
inviter_name = user.name or user.email
|
|
276
|
+
_send_invitation_email(request, smtp_config, new_invitation, org_name, inviter_name)
|
|
277
|
+
|
|
278
|
+
return {"status": "ok"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Resources API: CRUD for typed, optionally encrypted resources."""
|
|
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 Profile, Store
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from interloper_api.dependencies import get_catalog, get_org_id, get_store, require_editor, require_viewer
|
|
14
|
+
|
|
15
|
+
router = APIRouter()
|
|
16
|
+
|
|
17
|
+
# Kinds that are not resource kinds — top-level component categories.
|
|
18
|
+
_NON_RESOURCE_KINDS = {"source", "asset", "destination"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ResourceCreateRequest(BaseModel):
|
|
22
|
+
"""Request body for creating or updating a resource."""
|
|
23
|
+
|
|
24
|
+
kind: str
|
|
25
|
+
key: str
|
|
26
|
+
name: str
|
|
27
|
+
data: dict[str, Any]
|
|
28
|
+
encrypted: bool = False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ResourceResponse(BaseModel):
|
|
32
|
+
"""Response body for a resource."""
|
|
33
|
+
|
|
34
|
+
id: UUID
|
|
35
|
+
org_id: UUID
|
|
36
|
+
kind: str
|
|
37
|
+
key: str
|
|
38
|
+
name: str
|
|
39
|
+
encrypted: bool
|
|
40
|
+
created_at: str | None = None
|
|
41
|
+
updated_at: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ResourceDetailResponse(ResourceResponse):
|
|
45
|
+
"""Response body for a single resource, including its data payload."""
|
|
46
|
+
|
|
47
|
+
data: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.get("/kinds")
|
|
51
|
+
def list_resource_kinds(
|
|
52
|
+
catalog: dict[str, Any] = Depends(get_catalog),
|
|
53
|
+
) -> list[str]:
|
|
54
|
+
"""Return distinct resource kinds from the catalog.
|
|
55
|
+
|
|
56
|
+
Resource kinds are all component kinds except the top-level categories
|
|
57
|
+
(source, asset, destination). Currently this yields kinds like
|
|
58
|
+
``connection`` and ``config``.
|
|
59
|
+
"""
|
|
60
|
+
kinds = {defn["kind"] for defn in catalog.values() if defn.get("kind") and defn["kind"] not in _NON_RESOURCE_KINDS}
|
|
61
|
+
return sorted(kinds)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@router.get("/")
|
|
65
|
+
def list_resources(
|
|
66
|
+
kind: str | None = None,
|
|
67
|
+
user: Profile = Depends(require_viewer),
|
|
68
|
+
org_id: UUID = Depends(get_org_id),
|
|
69
|
+
store: Store = Depends(get_store),
|
|
70
|
+
) -> list[ResourceResponse]:
|
|
71
|
+
"""List resources for the current organisation, optionally filtered by kind."""
|
|
72
|
+
resources = store.list_resources(org_id, kind=kind)
|
|
73
|
+
return [
|
|
74
|
+
ResourceResponse(
|
|
75
|
+
id=r.id, # type: ignore[arg-type]
|
|
76
|
+
org_id=r.org_id,
|
|
77
|
+
kind=r.kind,
|
|
78
|
+
key=r.key,
|
|
79
|
+
name=r.name,
|
|
80
|
+
encrypted=r.encrypted,
|
|
81
|
+
created_at=str(r.created_at) if r.created_at else None,
|
|
82
|
+
updated_at=str(r.updated_at) if r.updated_at else None,
|
|
83
|
+
)
|
|
84
|
+
for r in resources
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@router.get("/{resource_id}")
|
|
89
|
+
def get_resource(
|
|
90
|
+
resource_id: UUID,
|
|
91
|
+
user: Profile = Depends(require_viewer),
|
|
92
|
+
store: Store = Depends(get_store),
|
|
93
|
+
) -> ResourceDetailResponse:
|
|
94
|
+
"""Get a single resource by ID, including its data payload."""
|
|
95
|
+
try:
|
|
96
|
+
r = store.load_resource(resource_id)
|
|
97
|
+
except NotFoundError:
|
|
98
|
+
raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found")
|
|
99
|
+
|
|
100
|
+
return ResourceDetailResponse(
|
|
101
|
+
id=r.id, # type: ignore[arg-type]
|
|
102
|
+
org_id=r.org_id,
|
|
103
|
+
kind=r.kind,
|
|
104
|
+
key=r.key,
|
|
105
|
+
name=r.name,
|
|
106
|
+
encrypted=r.encrypted,
|
|
107
|
+
data=store.decode_resource_data(r),
|
|
108
|
+
created_at=str(r.created_at) if r.created_at else None,
|
|
109
|
+
updated_at=str(r.updated_at) if r.updated_at else None,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@router.post("/")
|
|
114
|
+
def create_resource(
|
|
115
|
+
body: ResourceCreateRequest,
|
|
116
|
+
user: Profile = Depends(require_editor),
|
|
117
|
+
org_id: UUID = Depends(get_org_id),
|
|
118
|
+
store: Store = Depends(get_store),
|
|
119
|
+
) -> ResourceResponse:
|
|
120
|
+
"""Create a new resource."""
|
|
121
|
+
resource = store.create_resource(
|
|
122
|
+
org_id,
|
|
123
|
+
kind=body.kind,
|
|
124
|
+
key=body.key,
|
|
125
|
+
name=body.name,
|
|
126
|
+
data=body.data,
|
|
127
|
+
encrypted=body.encrypted,
|
|
128
|
+
)
|
|
129
|
+
return ResourceResponse(
|
|
130
|
+
id=resource.id, # type: ignore[arg-type]
|
|
131
|
+
org_id=resource.org_id,
|
|
132
|
+
kind=resource.kind,
|
|
133
|
+
key=resource.key,
|
|
134
|
+
name=resource.name,
|
|
135
|
+
encrypted=resource.encrypted,
|
|
136
|
+
created_at=str(resource.created_at) if resource.created_at else None,
|
|
137
|
+
updated_at=str(resource.updated_at) if resource.updated_at else None,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@router.put("/{resource_id}")
|
|
142
|
+
def update_resource(
|
|
143
|
+
resource_id: UUID,
|
|
144
|
+
body: ResourceCreateRequest,
|
|
145
|
+
user: Profile = Depends(require_editor),
|
|
146
|
+
store: Store = Depends(get_store),
|
|
147
|
+
) -> ResourceResponse:
|
|
148
|
+
"""Update an existing resource."""
|
|
149
|
+
try:
|
|
150
|
+
resource = store.update_resource(
|
|
151
|
+
resource_id,
|
|
152
|
+
kind=body.kind,
|
|
153
|
+
key=body.key,
|
|
154
|
+
name=body.name,
|
|
155
|
+
data=body.data,
|
|
156
|
+
encrypted=body.encrypted,
|
|
157
|
+
)
|
|
158
|
+
except NotFoundError:
|
|
159
|
+
raise HTTPException(status_code=404, detail=f"Resource {resource_id} not found")
|
|
160
|
+
return ResourceResponse(
|
|
161
|
+
id=resource.id, # type: ignore[arg-type]
|
|
162
|
+
org_id=resource.org_id,
|
|
163
|
+
kind=resource.kind,
|
|
164
|
+
key=resource.key,
|
|
165
|
+
name=resource.name,
|
|
166
|
+
encrypted=resource.encrypted,
|
|
167
|
+
created_at=str(resource.created_at) if resource.created_at else None,
|
|
168
|
+
updated_at=str(resource.updated_at) if resource.updated_at else None,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@router.delete("/{resource_id}")
|
|
173
|
+
def delete_resource(
|
|
174
|
+
resource_id: UUID,
|
|
175
|
+
user: Profile = Depends(require_editor),
|
|
176
|
+
store: Store = Depends(get_store),
|
|
177
|
+
) -> dict[str, str]:
|
|
178
|
+
"""Delete a resource."""
|
|
179
|
+
store.delete_resource(resource_id)
|
|
180
|
+
return {"status": "deleted"}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Runs API: read endpoints for runs and their events."""
|
|
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 Event, Run
|
|
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 RunResponse(BaseModel):
|
|
20
|
+
"""Response body for a run."""
|
|
21
|
+
|
|
22
|
+
id: UUID
|
|
23
|
+
org_id: UUID
|
|
24
|
+
job_id: UUID | None
|
|
25
|
+
backfill_id: UUID | None
|
|
26
|
+
partition_date: dt.date | None
|
|
27
|
+
status: str
|
|
28
|
+
started_at: str | None = None
|
|
29
|
+
completed_at: str | None = None
|
|
30
|
+
created_at: str | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AssetExecutionResponse(BaseModel):
|
|
34
|
+
"""Response body for an asset execution (from the asset_executions view)."""
|
|
35
|
+
|
|
36
|
+
run_id: UUID
|
|
37
|
+
org_id: UUID
|
|
38
|
+
asset_id: UUID | None = None
|
|
39
|
+
asset_key: str
|
|
40
|
+
status: str
|
|
41
|
+
started_at: str | None = None
|
|
42
|
+
completed_at: str | None = None
|
|
43
|
+
created_at: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class EventResponse(BaseModel):
|
|
47
|
+
"""Response body for an event."""
|
|
48
|
+
|
|
49
|
+
id: UUID
|
|
50
|
+
org_id: UUID
|
|
51
|
+
run_id: UUID | None
|
|
52
|
+
event_type: str
|
|
53
|
+
asset_id: UUID | None = None
|
|
54
|
+
asset_key: str | None
|
|
55
|
+
partition_or_window: str | None
|
|
56
|
+
error: str | None
|
|
57
|
+
traceback: str | None
|
|
58
|
+
message: str | None
|
|
59
|
+
timestamp: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run_to_response(run: Run) -> RunResponse:
|
|
63
|
+
"""Convert a DB Run to a RunResponse.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
run: The DB Run row.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The response model.
|
|
70
|
+
"""
|
|
71
|
+
return RunResponse(
|
|
72
|
+
id=run.id, # type: ignore[arg-type]
|
|
73
|
+
org_id=run.org_id,
|
|
74
|
+
job_id=run.job_id,
|
|
75
|
+
backfill_id=run.backfill_id,
|
|
76
|
+
partition_date=run.partition_date,
|
|
77
|
+
status=run.status,
|
|
78
|
+
started_at=str(run.started_at) if run.started_at else None,
|
|
79
|
+
completed_at=str(run.completed_at) if run.completed_at else None,
|
|
80
|
+
created_at=str(run.created_at) if run.created_at else None,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _event_to_response(event: Event) -> EventResponse:
|
|
85
|
+
"""Convert a DB Event to an EventResponse.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
event: The DB Event row.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
The response model.
|
|
92
|
+
"""
|
|
93
|
+
return EventResponse(
|
|
94
|
+
id=event.id, # type: ignore[arg-type]
|
|
95
|
+
org_id=event.org_id,
|
|
96
|
+
run_id=event.run_id,
|
|
97
|
+
event_type=event.event_type,
|
|
98
|
+
asset_id=event.asset_id,
|
|
99
|
+
asset_key=event.asset_key,
|
|
100
|
+
partition_or_window=event.partition_or_window,
|
|
101
|
+
error=event.error,
|
|
102
|
+
traceback=event.traceback,
|
|
103
|
+
message=event.message,
|
|
104
|
+
timestamp=str(event.timestamp),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@router.get("/")
|
|
109
|
+
def list_runs(
|
|
110
|
+
job_id: UUID | None = None,
|
|
111
|
+
backfill_id: UUID | None = None,
|
|
112
|
+
status: str | None = None,
|
|
113
|
+
limit: int = 50,
|
|
114
|
+
offset: int = 0,
|
|
115
|
+
user: Profile = Depends(require_viewer),
|
|
116
|
+
org_id: UUID = Depends(get_org_id),
|
|
117
|
+
store: Store = Depends(get_store),
|
|
118
|
+
) -> list[RunResponse]:
|
|
119
|
+
"""List runs with optional filters."""
|
|
120
|
+
runs = store.list_runs(
|
|
121
|
+
org_id,
|
|
122
|
+
job_id=job_id,
|
|
123
|
+
backfill_id=backfill_id,
|
|
124
|
+
status=status,
|
|
125
|
+
limit=limit,
|
|
126
|
+
offset=offset,
|
|
127
|
+
)
|
|
128
|
+
return [_run_to_response(r) for r in runs]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@router.get("/{run_id}")
|
|
132
|
+
def get_run(
|
|
133
|
+
run_id: UUID,
|
|
134
|
+
user: Profile = Depends(require_viewer),
|
|
135
|
+
store: Store = Depends(get_store),
|
|
136
|
+
) -> RunResponse:
|
|
137
|
+
"""Get a single run by ID."""
|
|
138
|
+
try:
|
|
139
|
+
run = store.get_run(run_id)
|
|
140
|
+
except NotFoundError:
|
|
141
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
142
|
+
return _run_to_response(run)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get("/{run_id}/asset-executions")
|
|
146
|
+
def list_asset_executions(
|
|
147
|
+
run_id: UUID,
|
|
148
|
+
user: Profile = Depends(require_viewer),
|
|
149
|
+
store: Store = Depends(get_store),
|
|
150
|
+
) -> list[AssetExecutionResponse]:
|
|
151
|
+
"""List asset executions for a run."""
|
|
152
|
+
rows = store.list_asset_executions(run_id)
|
|
153
|
+
return [
|
|
154
|
+
AssetExecutionResponse(
|
|
155
|
+
run_id=row["run_id"],
|
|
156
|
+
org_id=row["org_id"],
|
|
157
|
+
asset_id=row.get("asset_id"),
|
|
158
|
+
asset_key=row["asset_key"],
|
|
159
|
+
status=row["status"],
|
|
160
|
+
started_at=str(row["started_at"]) if row.get("started_at") else None,
|
|
161
|
+
completed_at=str(row["completed_at"]) if row.get("completed_at") else None,
|
|
162
|
+
created_at=str(row["created_at"]) if row.get("created_at") else None,
|
|
163
|
+
)
|
|
164
|
+
for row in rows
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.get("/{run_id}/events")
|
|
169
|
+
def list_run_events(
|
|
170
|
+
run_id: UUID,
|
|
171
|
+
limit: int = 100,
|
|
172
|
+
user: Profile = Depends(require_viewer),
|
|
173
|
+
store: Store = Depends(get_store),
|
|
174
|
+
) -> list[EventResponse]:
|
|
175
|
+
"""List events for a run."""
|
|
176
|
+
events = store.list_events(run_id=run_id, limit=limit)
|
|
177
|
+
return [_event_to_response(e) for e in events]
|