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.
Files changed (28) hide show
  1. {interloper_api-0.5.0 → interloper_api-0.7.0}/PKG-INFO +1 -1
  2. {interloper_api-0.5.0 → interloper_api-0.7.0}/pyproject.toml +1 -1
  3. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/app.py +2 -0
  4. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/dependencies.py +19 -0
  5. interloper_api-0.7.0/src/interloper_api/routes/admin.py +305 -0
  6. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/auth.py +2 -0
  7. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/runs.py +66 -5
  8. {interloper_api-0.5.0 → interloper_api-0.7.0}/README.md +0 -0
  9. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/email.py +0 -0
  11. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/__init__.py +0 -0
  12. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/agent.py +0 -0
  13. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/assets.py +0 -0
  14. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/backfills.py +0 -0
  15. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/destinations.py +0 -0
  17. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/__init__.py +0 -0
  18. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
  19. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
  20. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/google_ads.py +0 -0
  21. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
  22. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
  23. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/jobs.py +0 -0
  24. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/oauth.py +0 -0
  25. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/organisations.py +0 -0
  26. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/resources.py +0 -0
  27. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/sources.py +0 -0
  28. {interloper_api-0.5.0 → interloper_api-0.7.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.5.0"
6
+ version = "0.7.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -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
- events = store.list_events(run_id=run_id, limit=limit)
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