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.
@@ -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]