interloper-api 0.5.0__tar.gz → 0.7.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.5.0 → interloper_api-0.7.0}/PKG-INFO +1 -1
- {interloper_api-0.5.0 → interloper_api-0.7.0}/pyproject.toml +1 -1
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/app.py +2 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/dependencies.py +19 -0
- interloper_api-0.7.0/src/interloper_api/routes/admin.py +305 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/auth.py +2 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/runs.py +66 -5
- {interloper_api-0.5.0 → interloper_api-0.7.0}/README.md +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/assets.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/destinations.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/__init__.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/google_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/jobs.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/resources.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/sources.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -11,6 +11,7 @@ from interloper_db import Store
|
|
|
11
11
|
|
|
12
12
|
from interloper_api.dependencies import set_auth_config, set_catalog, set_smtp_config, set_store
|
|
13
13
|
from interloper_api.routes import (
|
|
14
|
+
admin,
|
|
14
15
|
assets,
|
|
15
16
|
auth,
|
|
16
17
|
backfills,
|
|
@@ -72,6 +73,7 @@ def create_app(
|
|
|
72
73
|
api = APIRouter(prefix="/api")
|
|
73
74
|
api.include_router(auth.router, tags=["auth"])
|
|
74
75
|
api.include_router(organisations.router, tags=["organisations"])
|
|
76
|
+
api.include_router(admin.router, tags=["admin"])
|
|
75
77
|
api.include_router(catalog_routes.router, prefix="/catalog", tags=["catalog"])
|
|
76
78
|
api.include_router(resources.router, prefix="/resources", tags=["resources"])
|
|
77
79
|
api.include_router(sources.router, prefix="/sources", tags=["sources"])
|
|
@@ -291,3 +291,22 @@ def require_admin(
|
|
|
291
291
|
HTTPException: 401/403 on auth or role failure.
|
|
292
292
|
"""
|
|
293
293
|
return _check_role("admin", user, org_id, store)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def require_super_admin(
|
|
297
|
+
user: Profile = Depends(get_current_user),
|
|
298
|
+
) -> Profile:
|
|
299
|
+
"""Require platform-wide super-admin privileges.
|
|
300
|
+
|
|
301
|
+
Unlike the org-scoped role dependencies, this is not bound to the session's
|
|
302
|
+
active organisation — it gates the cross-org admin surface.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
The authenticated Profile.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
HTTPException: 401 if not authenticated, 403 if not a super-admin.
|
|
309
|
+
"""
|
|
310
|
+
if not user.is_super_admin:
|
|
311
|
+
raise HTTPException(status_code=403, detail="Requires super-admin privileges")
|
|
312
|
+
return user
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"""Super-admin routes — cross-organisation management.
|
|
2
|
+
|
|
3
|
+
These endpoints are gated by :func:`require_super_admin` and are NOT bound to
|
|
4
|
+
the session's active organisation. They let a platform super-admin manage every
|
|
5
|
+
organisation's metadata, membership, and invitations. They deliberately grant
|
|
6
|
+
no access to org-scoped *data* (sources, jobs, runs, …).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from uuid import UUID
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
|
16
|
+
from interloper_db import Profile, Store
|
|
17
|
+
from pydantic import BaseModel
|
|
18
|
+
|
|
19
|
+
from interloper_api.dependencies import get_store, require_super_admin
|
|
20
|
+
from interloper_api.email import send_invite_email
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
router = APIRouter(prefix="/admin", tags=["admin"])
|
|
25
|
+
|
|
26
|
+
_ROLES = {"viewer", "editor", "admin"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# -- Response / Request models ------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AdminOrganisationResponse(BaseModel):
|
|
33
|
+
"""Organisation summary with member count for the admin surface."""
|
|
34
|
+
|
|
35
|
+
id: UUID
|
|
36
|
+
name: str
|
|
37
|
+
member_count: int
|
|
38
|
+
created_at: datetime | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CreateOrganisationRequest(BaseModel):
|
|
42
|
+
"""Request body for creating an organisation."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UpdateOrganisationRequest(BaseModel):
|
|
48
|
+
"""Request body for renaming an organisation."""
|
|
49
|
+
|
|
50
|
+
name: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MemberResponse(BaseModel):
|
|
54
|
+
"""Organisation member."""
|
|
55
|
+
|
|
56
|
+
id: UUID
|
|
57
|
+
email: str
|
|
58
|
+
name: str | None = None
|
|
59
|
+
avatar_url: str | None = None
|
|
60
|
+
role: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class UpdateRoleRequest(BaseModel):
|
|
64
|
+
"""Request body for changing a member's role."""
|
|
65
|
+
|
|
66
|
+
role: str
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class InviteRequest(BaseModel):
|
|
70
|
+
"""Request body for inviting a user."""
|
|
71
|
+
|
|
72
|
+
email: str
|
|
73
|
+
role: str = "viewer"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InvitationResponse(BaseModel):
|
|
77
|
+
"""Pending invitation."""
|
|
78
|
+
|
|
79
|
+
id: UUID
|
|
80
|
+
email: str
|
|
81
|
+
role: str
|
|
82
|
+
created_at: datetime | None = None
|
|
83
|
+
expires_at: datetime
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# -- Helpers ------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _require_org(store: Store, org_id: UUID) -> object:
|
|
90
|
+
"""Fetch an organisation or raise 404."""
|
|
91
|
+
org = store.get_organisation(org_id)
|
|
92
|
+
if not org:
|
|
93
|
+
raise HTTPException(status_code=404, detail="Organisation not found")
|
|
94
|
+
return org
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _validate_role(role: str) -> str:
|
|
98
|
+
"""Validate a role string or raise 400."""
|
|
99
|
+
if role not in _ROLES:
|
|
100
|
+
raise HTTPException(status_code=400, detail=f"Invalid role: {role}")
|
|
101
|
+
return role
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _send_invitation_email(
|
|
105
|
+
request: Request,
|
|
106
|
+
invitation: object,
|
|
107
|
+
org_name: str,
|
|
108
|
+
inviter_name: str,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Send the invitation email if SMTP is configured, never failing the request."""
|
|
111
|
+
from interloper_api.dependencies import get_smtp_config
|
|
112
|
+
|
|
113
|
+
smtp_config = get_smtp_config()
|
|
114
|
+
if not smtp_config or not smtp_config.enabled: # type: ignore[attr-defined]
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
token = invitation.token # type: ignore[attr-defined]
|
|
118
|
+
email = invitation.email # type: ignore[attr-defined]
|
|
119
|
+
base_url = str(request.base_url).rstrip("/")
|
|
120
|
+
invite_url = f"{base_url}/invite/{token}"
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
send_invite_email(
|
|
124
|
+
smtp_config=smtp_config,
|
|
125
|
+
to=email,
|
|
126
|
+
org_name=org_name,
|
|
127
|
+
inviter_name=inviter_name,
|
|
128
|
+
invite_url=invite_url,
|
|
129
|
+
)
|
|
130
|
+
except Exception:
|
|
131
|
+
logger.exception("Failed to send invitation email to %s", email)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# -- Organisations ------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@router.get("/organisations")
|
|
138
|
+
def list_all_organisations(
|
|
139
|
+
user: Profile = Depends(require_super_admin),
|
|
140
|
+
store: Store = Depends(get_store),
|
|
141
|
+
) -> list[AdminOrganisationResponse]:
|
|
142
|
+
"""List every organisation with its member count."""
|
|
143
|
+
return [
|
|
144
|
+
AdminOrganisationResponse(
|
|
145
|
+
id=org.id, # type: ignore[arg-type]
|
|
146
|
+
name=org.name,
|
|
147
|
+
member_count=count,
|
|
148
|
+
created_at=org.created_at,
|
|
149
|
+
)
|
|
150
|
+
for org, count in store.list_all_organisations()
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@router.post("/organisations", status_code=201)
|
|
155
|
+
def create_organisation(
|
|
156
|
+
body: CreateOrganisationRequest,
|
|
157
|
+
user: Profile = Depends(require_super_admin),
|
|
158
|
+
store: Store = Depends(get_store),
|
|
159
|
+
) -> AdminOrganisationResponse:
|
|
160
|
+
"""Create an organisation. The super-admin is not added as a member."""
|
|
161
|
+
org = store.create_organisation(name=body.name)
|
|
162
|
+
return AdminOrganisationResponse(
|
|
163
|
+
id=org.id, # type: ignore[arg-type]
|
|
164
|
+
name=org.name,
|
|
165
|
+
member_count=0,
|
|
166
|
+
created_at=org.created_at,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.patch("/organisations/{org_id}")
|
|
171
|
+
def update_organisation(
|
|
172
|
+
org_id: UUID,
|
|
173
|
+
body: UpdateOrganisationRequest,
|
|
174
|
+
user: Profile = Depends(require_super_admin),
|
|
175
|
+
store: Store = Depends(get_store),
|
|
176
|
+
) -> AdminOrganisationResponse:
|
|
177
|
+
"""Rename an organisation."""
|
|
178
|
+
org = store.update_organisation(org_id, body.name)
|
|
179
|
+
if not org:
|
|
180
|
+
raise HTTPException(status_code=404, detail="Organisation not found")
|
|
181
|
+
members = store.list_org_members(org_id)
|
|
182
|
+
return AdminOrganisationResponse(
|
|
183
|
+
id=org.id, # type: ignore[arg-type]
|
|
184
|
+
name=org.name,
|
|
185
|
+
member_count=len(members),
|
|
186
|
+
created_at=org.created_at,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -- Members ------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@router.get("/organisations/{org_id}/members")
|
|
194
|
+
def list_members(
|
|
195
|
+
org_id: UUID,
|
|
196
|
+
user: Profile = Depends(require_super_admin),
|
|
197
|
+
store: Store = Depends(get_store),
|
|
198
|
+
) -> list[MemberResponse]:
|
|
199
|
+
"""List all members of any organisation."""
|
|
200
|
+
_require_org(store, org_id)
|
|
201
|
+
members = store.list_org_members(org_id)
|
|
202
|
+
return [
|
|
203
|
+
MemberResponse(
|
|
204
|
+
id=profile.id, # type: ignore[arg-type]
|
|
205
|
+
email=profile.email,
|
|
206
|
+
name=profile.name,
|
|
207
|
+
avatar_url=profile.avatar_url,
|
|
208
|
+
role=role,
|
|
209
|
+
)
|
|
210
|
+
for profile, role in members
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@router.patch("/organisations/{org_id}/members/{user_id}")
|
|
215
|
+
def update_member_role(
|
|
216
|
+
org_id: UUID,
|
|
217
|
+
user_id: UUID,
|
|
218
|
+
body: UpdateRoleRequest,
|
|
219
|
+
user: Profile = Depends(require_super_admin),
|
|
220
|
+
store: Store = Depends(get_store),
|
|
221
|
+
) -> dict[str, str]:
|
|
222
|
+
"""Change a member's role in any organisation."""
|
|
223
|
+
_validate_role(body.role)
|
|
224
|
+
if not store.update_member_role(org_id, user_id, body.role):
|
|
225
|
+
raise HTTPException(status_code=404, detail="Member not found")
|
|
226
|
+
return {"status": "ok"}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@router.delete("/organisations/{org_id}/members/{user_id}")
|
|
230
|
+
def remove_member(
|
|
231
|
+
org_id: UUID,
|
|
232
|
+
user_id: UUID,
|
|
233
|
+
user: Profile = Depends(require_super_admin),
|
|
234
|
+
store: Store = Depends(get_store),
|
|
235
|
+
) -> dict[str, str]:
|
|
236
|
+
"""Remove a member from any organisation."""
|
|
237
|
+
if not store.remove_org_member(org_id, user_id):
|
|
238
|
+
raise HTTPException(status_code=404, detail="Member not found")
|
|
239
|
+
return {"status": "ok"}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# -- Invitations --------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@router.get("/organisations/{org_id}/invitations")
|
|
246
|
+
def list_invitations(
|
|
247
|
+
org_id: UUID,
|
|
248
|
+
user: Profile = Depends(require_super_admin),
|
|
249
|
+
store: Store = Depends(get_store),
|
|
250
|
+
) -> list[InvitationResponse]:
|
|
251
|
+
"""List pending invitations for any organisation."""
|
|
252
|
+
_require_org(store, org_id)
|
|
253
|
+
return [
|
|
254
|
+
InvitationResponse(
|
|
255
|
+
id=inv.id, # type: ignore[arg-type]
|
|
256
|
+
email=inv.email,
|
|
257
|
+
role=inv.role,
|
|
258
|
+
created_at=inv.created_at,
|
|
259
|
+
expires_at=inv.expires_at,
|
|
260
|
+
)
|
|
261
|
+
for inv in store.list_invitations(org_id)
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@router.post("/organisations/{org_id}/invitations", status_code=201)
|
|
266
|
+
def invite_member(
|
|
267
|
+
org_id: UUID,
|
|
268
|
+
body: InviteRequest,
|
|
269
|
+
request: Request,
|
|
270
|
+
user: Profile = Depends(require_super_admin),
|
|
271
|
+
store: Store = Depends(get_store),
|
|
272
|
+
) -> InvitationResponse:
|
|
273
|
+
"""Invite a user to any organisation by email."""
|
|
274
|
+
org = _require_org(store, org_id)
|
|
275
|
+
_validate_role(body.role)
|
|
276
|
+
invitation = store.create_invitation(
|
|
277
|
+
org_id=org_id,
|
|
278
|
+
email=body.email.strip(),
|
|
279
|
+
role=body.role,
|
|
280
|
+
invited_by=user.id, # type: ignore[arg-type]
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
inviter_name = user.name or user.email
|
|
284
|
+
_send_invitation_email(request, invitation, org.name, inviter_name) # type: ignore[attr-defined]
|
|
285
|
+
|
|
286
|
+
return InvitationResponse(
|
|
287
|
+
id=invitation.id, # type: ignore[arg-type]
|
|
288
|
+
email=invitation.email,
|
|
289
|
+
role=invitation.role,
|
|
290
|
+
created_at=invitation.created_at,
|
|
291
|
+
expires_at=invitation.expires_at,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@router.delete("/organisations/{org_id}/invitations/{invitation_id}")
|
|
296
|
+
def cancel_invitation(
|
|
297
|
+
org_id: UUID,
|
|
298
|
+
invitation_id: UUID,
|
|
299
|
+
user: Profile = Depends(require_super_admin),
|
|
300
|
+
store: Store = Depends(get_store),
|
|
301
|
+
) -> dict[str, str]:
|
|
302
|
+
"""Cancel a pending invitation in any organisation."""
|
|
303
|
+
if not store.delete_invitation(invitation_id):
|
|
304
|
+
raise HTTPException(status_code=404, detail="Invitation not found")
|
|
305
|
+
return {"status": "ok"}
|
|
@@ -37,6 +37,7 @@ class AuthUserResponse(BaseModel):
|
|
|
37
37
|
name: str | None = None
|
|
38
38
|
avatar_url: str | None = None
|
|
39
39
|
role: str
|
|
40
|
+
is_super_admin: bool = False
|
|
40
41
|
organisation: OrganisationResponse | None = None
|
|
41
42
|
last_organisation_id: UUID | None = None
|
|
42
43
|
|
|
@@ -188,6 +189,7 @@ def get_me(
|
|
|
188
189
|
name=profile.name,
|
|
189
190
|
avatar_url=profile.avatar_url,
|
|
190
191
|
role=role,
|
|
192
|
+
is_super_admin=profile.is_super_admin,
|
|
191
193
|
organisation=OrganisationResponse.model_validate(org, from_attributes=True) if org else None,
|
|
192
194
|
last_organisation_id=profile.last_organisation_id,
|
|
193
195
|
)
|
|
@@ -3,18 +3,24 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import datetime as dt
|
|
6
|
+
from typing import Literal
|
|
6
7
|
from uuid import UUID
|
|
7
8
|
|
|
8
|
-
from fastapi import APIRouter, Depends, HTTPException
|
|
9
|
+
from fastapi import APIRouter, Depends, HTTPException, Response
|
|
9
10
|
from interloper.errors import NotFoundError
|
|
10
11
|
from interloper_db import Profile, Store
|
|
11
12
|
from interloper_db.models import Event, Run
|
|
12
13
|
from pydantic import BaseModel
|
|
13
14
|
|
|
14
|
-
from interloper_api.dependencies import get_org_id, get_store, require_viewer
|
|
15
|
+
from interloper_api.dependencies import get_org_id, get_store, require_editor, require_viewer
|
|
15
16
|
|
|
16
17
|
router = APIRouter()
|
|
17
18
|
|
|
19
|
+
#: Hard cap on the number of events returned in a single page, regardless of
|
|
20
|
+
#: the requested ``limit``. Keeps a pathological ``?limit=1000000`` from loading
|
|
21
|
+
#: an entire run's history into memory at once.
|
|
22
|
+
MAX_EVENTS_PAGE_SIZE = 1000
|
|
23
|
+
|
|
18
24
|
|
|
19
25
|
class RunResponse(BaseModel):
|
|
20
26
|
"""Response body for a run."""
|
|
@@ -25,11 +31,20 @@ class RunResponse(BaseModel):
|
|
|
25
31
|
backfill_id: UUID | None
|
|
26
32
|
partition_date: dt.date | None
|
|
27
33
|
status: str
|
|
34
|
+
retry_of: UUID | None = None
|
|
35
|
+
attempt: int = 1
|
|
36
|
+
retry_scope: str | None = None
|
|
28
37
|
started_at: str | None = None
|
|
29
38
|
completed_at: str | None = None
|
|
30
39
|
created_at: str | None = None
|
|
31
40
|
|
|
32
41
|
|
|
42
|
+
class RetryRequest(BaseModel):
|
|
43
|
+
"""Request body for retrying a failed run."""
|
|
44
|
+
|
|
45
|
+
scope: Literal["all", "failed"] = "all"
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
class AssetExecutionResponse(BaseModel):
|
|
34
49
|
"""Response body for an asset execution (from the asset_executions view)."""
|
|
35
50
|
|
|
@@ -75,6 +90,9 @@ def _run_to_response(run: Run) -> RunResponse:
|
|
|
75
90
|
backfill_id=run.backfill_id,
|
|
76
91
|
partition_date=run.partition_date,
|
|
77
92
|
status=run.status,
|
|
93
|
+
retry_of=run.retry_of,
|
|
94
|
+
attempt=run.attempt,
|
|
95
|
+
retry_scope=run.retry_scope,
|
|
78
96
|
started_at=str(run.started_at) if run.started_at else None,
|
|
79
97
|
completed_at=str(run.completed_at) if run.completed_at else None,
|
|
80
98
|
created_at=str(run.created_at) if run.created_at else None,
|
|
@@ -107,6 +125,7 @@ def _event_to_response(event: Event) -> EventResponse:
|
|
|
107
125
|
|
|
108
126
|
@router.get("/")
|
|
109
127
|
def list_runs(
|
|
128
|
+
response: Response,
|
|
110
129
|
job_id: UUID | None = None,
|
|
111
130
|
backfill_id: UUID | None = None,
|
|
112
131
|
status: str | None = None,
|
|
@@ -116,7 +135,13 @@ def list_runs(
|
|
|
116
135
|
org_id: UUID = Depends(get_org_id),
|
|
117
136
|
store: Store = Depends(get_store),
|
|
118
137
|
) -> list[RunResponse]:
|
|
119
|
-
"""List runs with optional filters.
|
|
138
|
+
"""List runs with optional filters.
|
|
139
|
+
|
|
140
|
+
The total number of matching runs (ignoring ``limit``/``offset``) is
|
|
141
|
+
returned in the ``X-Total-Count`` response header so clients can paginate.
|
|
142
|
+
"""
|
|
143
|
+
total = store.count_runs(org_id, job_id=job_id, backfill_id=backfill_id, status=status)
|
|
144
|
+
response.headers["X-Total-Count"] = str(total)
|
|
120
145
|
runs = store.list_runs(
|
|
121
146
|
org_id,
|
|
122
147
|
job_id=job_id,
|
|
@@ -165,13 +190,49 @@ def list_asset_executions(
|
|
|
165
190
|
]
|
|
166
191
|
|
|
167
192
|
|
|
193
|
+
@router.post("/{run_id}/retry")
|
|
194
|
+
def retry_run(
|
|
195
|
+
run_id: UUID,
|
|
196
|
+
body: RetryRequest | None = None,
|
|
197
|
+
user: Profile = Depends(require_editor),
|
|
198
|
+
store: Store = Depends(get_store),
|
|
199
|
+
) -> dict[str, str]:
|
|
200
|
+
"""Queue a retry of a failed run.
|
|
201
|
+
|
|
202
|
+
Creates a new run linked to the original via ``retry_of``. With
|
|
203
|
+
``scope="all"`` the whole DAG re-runs; with ``scope="failed"`` only the
|
|
204
|
+
previously failed/cancelled assets re-run.
|
|
205
|
+
"""
|
|
206
|
+
scope = body.scope if body else "all"
|
|
207
|
+
try:
|
|
208
|
+
run = store.retry_run(run_id, scope=scope)
|
|
209
|
+
except NotFoundError:
|
|
210
|
+
raise HTTPException(status_code=404, detail=f"Run {run_id} not found")
|
|
211
|
+
except ValueError as e:
|
|
212
|
+
raise HTTPException(status_code=409, detail=str(e))
|
|
213
|
+
return {"status": "queued", "run_id": str(run.id)}
|
|
214
|
+
|
|
215
|
+
|
|
168
216
|
@router.get("/{run_id}/events")
|
|
169
217
|
def list_run_events(
|
|
170
218
|
run_id: UUID,
|
|
219
|
+
response: Response,
|
|
171
220
|
limit: int = 100,
|
|
221
|
+
offset: int = 0,
|
|
172
222
|
user: Profile = Depends(require_viewer),
|
|
173
223
|
store: Store = Depends(get_store),
|
|
174
224
|
) -> list[EventResponse]:
|
|
175
|
-
"""List events for a run.
|
|
176
|
-
|
|
225
|
+
"""List events for a run, oldest first.
|
|
226
|
+
|
|
227
|
+
Events are ordered ``timestamp ASC`` and paged with ``limit``/``offset``.
|
|
228
|
+
The total number of events for the run (ignoring ``limit``/``offset``) is
|
|
229
|
+
returned in the ``X-Total-Count`` response header so clients can page
|
|
230
|
+
through every event — including the terminal/outcome events
|
|
231
|
+
(``asset_completed``, ``asset_failed``, ``run_failed``, …) that sort last.
|
|
232
|
+
"""
|
|
233
|
+
limit = max(1, min(limit, MAX_EVENTS_PAGE_SIZE))
|
|
234
|
+
offset = max(0, offset)
|
|
235
|
+
total = store.count_events(run_id=run_id)
|
|
236
|
+
response.headers["X-Total-Count"] = str(total)
|
|
237
|
+
events = store.list_events(run_id=run_id, limit=limit, offset=offset)
|
|
177
238
|
return [_event_to_response(e) for e in events]
|
|
File without changes
|
|
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.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/__init__.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/amazon_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/facebook_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/google_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/pinterest_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/snapchat_ads.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|