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.
- {interloper_api-0.5.0 → interloper_api-0.6.0}/PKG-INFO +1 -1
- {interloper_api-0.5.0 → interloper_api-0.6.0}/pyproject.toml +1 -1
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/app.py +2 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/dependencies.py +19 -0
- interloper_api-0.6.0/src/interloper_api/routes/admin.py +305 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/auth.py +2 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/runs.py +9 -2
- {interloper_api-0.5.0 → interloper_api-0.6.0}/README.md +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/assets.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/destinations.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/__init__.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/amazon_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/facebook_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/google_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/pinterest_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/snapchat_ads.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/jobs.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/resources.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/sources.py +0 -0
- {interloper_api-0.5.0 → interloper_api-0.6.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
|
)
|
|
@@ -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
|
|
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.6.0}/src/interloper_api/routes/external/__init__.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/amazon_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/facebook_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/google_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.6.0}/src/interloper_api/routes/external/pinterest_ads.py
RENAMED
|
File without changes
|
{interloper_api-0.5.0 → interloper_api-0.6.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
|