interloper-api 0.5.0__tar.gz → 0.6.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.6.0}/PKG-INFO +1 -1
  2. {interloper_api-0.5.0 → interloper_api-0.6.0}/pyproject.toml +1 -1
  3. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/app.py +2 -0
  4. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/dependencies.py +19 -0
  5. interloper_api-0.6.0/src/interloper_api/routes/admin.py +305 -0
  6. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/auth.py +2 -0
  7. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/runs.py +9 -2
  8. {interloper_api-0.5.0 → interloper_api-0.6.0}/README.md +0 -0
  9. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/email.py +0 -0
  11. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/__init__.py +0 -0
  12. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/agent.py +0 -0
  13. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/assets.py +0 -0
  14. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/backfills.py +0 -0
  15. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/destinations.py +0 -0
  17. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/__init__.py +0 -0
  18. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
  19. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
  20. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/google_ads.py +0 -0
  21. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
  22. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
  23. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/jobs.py +0 -0
  24. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/oauth.py +0 -0
  25. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/organisations.py +0 -0
  26. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/resources.py +0 -0
  27. {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/sources.py +0 -0
  28. {interloper_api-0.5.0 → interloper_api-0.6.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.6.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.6.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
  )
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import datetime as dt
6
6
  from uuid import UUID
7
7
 
8
- from fastapi import APIRouter, Depends, HTTPException
8
+ from fastapi import APIRouter, Depends, HTTPException, Response
9
9
  from interloper.errors import NotFoundError
10
10
  from interloper_db import Profile, Store
11
11
  from interloper_db.models import Event, Run
@@ -107,6 +107,7 @@ def _event_to_response(event: Event) -> EventResponse:
107
107
 
108
108
  @router.get("/")
109
109
  def list_runs(
110
+ response: Response,
110
111
  job_id: UUID | None = None,
111
112
  backfill_id: UUID | None = None,
112
113
  status: str | None = None,
@@ -116,7 +117,13 @@ def list_runs(
116
117
  org_id: UUID = Depends(get_org_id),
117
118
  store: Store = Depends(get_store),
118
119
  ) -> list[RunResponse]:
119
- """List runs with optional filters."""
120
+ """List runs with optional filters.
121
+
122
+ The total number of matching runs (ignoring ``limit``/``offset``) is
123
+ returned in the ``X-Total-Count`` response header so clients can paginate.
124
+ """
125
+ total = store.count_runs(org_id, job_id=job_id, backfill_id=backfill_id, status=status)
126
+ response.headers["X-Total-Count"] = str(total)
120
127
  runs = store.list_runs(
121
128
  org_id,
122
129
  job_id=job_id,
File without changes