interloper-api 0.57.0__tar.gz → 0.59.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 (21) hide show
  1. {interloper_api-0.57.0 → interloper_api-0.59.0}/PKG-INFO +1 -1
  2. {interloper_api-0.57.0 → interloper_api-0.59.0}/pyproject.toml +1 -1
  3. {interloper_api-0.57.0 → interloper_api-0.59.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/app.py +11 -1
  5. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/dependencies.py +20 -0
  6. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/admin.py +189 -4
  7. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/backfills.py +33 -9
  8. {interloper_api-0.57.0 → interloper_api-0.59.0}/README.md +0 -0
  9. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/components.py +0 -0
  11. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/email.py +0 -0
  12. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/__init__.py +0 -0
  13. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/agent.py +0 -0
  14. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/auth.py +0 -0
  15. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/components.py +0 -0
  17. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/oauth.py +0 -0
  18. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/organisations.py +0 -0
  19. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/runs.py +0 -0
  20. {interloper_api-0.57.0 → interloper_api-0.59.0}/src/interloper_api/routes/tokens.py +0 -0
  21. {interloper_api-0.57.0 → interloper_api-0.59.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.57.0
3
+ Version: 0.59.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "interloper-api"
3
- version = "0.57.0"
3
+ version = "0.59.0"
4
4
  description = "Interloper FastAPI routes"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.57.0"
6
+ version = "0.59.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI, Request
9
9
  from fastapi.middleware.cors import CORSMiddleware
10
10
  from fastapi.responses import JSONResponse
11
11
  from interloper.catalog.base import Catalog
12
- from interloper.errors import ComponentDriftError, NotFoundError
12
+ from interloper.errors import ComponentDriftError, NotFoundError, QuotaExceededError
13
13
  from interloper_db import Store
14
14
 
15
15
  from interloper_api.dependencies import (
@@ -17,6 +17,7 @@ from interloper_api.dependencies import (
17
17
  set_auth_config,
18
18
  set_catalog,
19
19
  set_features,
20
+ set_quota_defaults,
20
21
  set_smtp_config,
21
22
  set_store,
22
23
  )
@@ -76,6 +77,14 @@ def create_app(
76
77
  """
77
78
  return JSONResponse(status_code=409, content={"detail": str(exc)})
78
79
 
80
+ @app.exception_handler(QuotaExceededError)
81
+ async def _quota_exceeded_handler(_request: Request, exc: QuotaExceededError) -> JSONResponse:
82
+ """Store-level quota enforcement surfaces as 429 with structured context."""
83
+ return JSONResponse(
84
+ status_code=429,
85
+ content={"detail": {"message": str(exc), "quota": exc.quota, "limit": exc.limit, "used": exc.used}},
86
+ )
87
+
79
88
  if cors_origins:
80
89
  app.add_middleware(
81
90
  CORSMiddleware,
@@ -92,6 +101,7 @@ def create_app(
92
101
  if settings:
93
102
  set_auth_config(settings.auth)
94
103
  set_smtp_config(settings.smtp)
104
+ set_quota_defaults(settings.quota)
95
105
 
96
106
  api = APIRouter(prefix="/api")
97
107
  api.include_router(auth.router)
@@ -18,6 +18,7 @@ _auth_config: Any | None = None
18
18
  _smtp_config: Any | None = None
19
19
  _features: dict[str, bool] = {}
20
20
  _admin_config: Any | None = None
21
+ _quota_defaults: Any | None = None
21
22
 
22
23
  # Role hierarchy: admin > editor > viewer
23
24
  _ROLE_RANK = {"viewer": 0, "editor": 1, "admin": 2}
@@ -154,6 +155,25 @@ def get_admin_config() -> Any:
154
155
  return _admin_config
155
156
 
156
157
 
158
+ def set_quota_defaults(defaults: Any) -> None:
159
+ """Set the global default quota limits (from settings, at app creation).
160
+
161
+ Args:
162
+ defaults: The QuotaSettings instance.
163
+ """
164
+ global _quota_defaults # noqa: PLW0603
165
+ _quota_defaults = defaults
166
+
167
+
168
+ def get_quota_defaults() -> Any:
169
+ """Return the global default quota limits.
170
+
171
+ Returns:
172
+ The QuotaSettings instance, or None if not configured.
173
+ """
174
+ return _quota_defaults
175
+
176
+
157
177
  # -- Auth dependencies -------------------------------------------------------
158
178
 
159
179
 
@@ -8,6 +8,7 @@ no access to org-scoped *data* (sources, jobs, runs, …).
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import datetime as dt
11
12
  import inspect
12
13
  import logging
13
14
  from datetime import datetime
@@ -17,9 +18,10 @@ from uuid import UUID
17
18
 
18
19
  from fastapi import APIRouter, Depends, HTTPException, Request
19
20
  from interloper_db import Organisation, Profile, Store
20
- from pydantic import BaseModel
21
+ from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS
22
+ from pydantic import BaseModel, Field
21
23
 
22
- from interloper_api.dependencies import get_admin_config, get_store, require_super_admin
24
+ from interloper_api.dependencies import get_admin_config, get_quota_defaults, get_store, require_super_admin
23
25
  from interloper_api.email import send_invite_email
24
26
 
25
27
  logger = logging.getLogger(__name__)
@@ -39,6 +41,9 @@ class AdminOrganisationResponse(BaseModel):
39
41
  name: str
40
42
  member_count: int
41
43
  created_at: datetime | None = None
44
+ #: Soft-deleted orgs stay listed (their retained history and ledger are
45
+ #: still attributable) but are no longer manageable.
46
+ deleted_at: datetime | None = None
42
47
 
43
48
 
44
49
  class AdminUserOrganisation(BaseModel):
@@ -223,6 +228,57 @@ class AdminDataConfig(BaseModel):
223
228
  catalog: dict[str, list[str]]
224
229
 
225
230
 
231
+ class AdminQuotaLimits(BaseModel):
232
+ """One set of quota limits; null means unlimited (or unset, for overrides)."""
233
+
234
+ max_sources: int | None = None
235
+ max_assets_per_source: int | None = None
236
+ max_successful_runs_per_month: int | None = None
237
+
238
+
239
+ class AdminOrgQuotaStatus(BaseModel):
240
+ """One organisation's quota limits and current usage."""
241
+
242
+ id: UUID
243
+ name: str
244
+ #: Soft-deleted orgs keep their ledger visible but are read-only.
245
+ deleted_at: datetime | None = None
246
+ limits: AdminQuotaLimits
247
+ effective: AdminQuotaLimits
248
+ sources: int
249
+ max_assets_per_source: int
250
+ successful_runs: int
251
+ reserved_runs: int
252
+ #: Recomputed from the runs table; a mismatch with ``successful_runs``
253
+ #: (the ledger) signals counter drift.
254
+ recomputed_successful_runs: int
255
+
256
+
257
+ class AdminQuotasResponse(BaseModel):
258
+ """Quota overview: global defaults plus per-organisation status."""
259
+
260
+ period_start: dt.date
261
+ defaults: AdminQuotaLimits
262
+ organisations: list[AdminOrgQuotaStatus]
263
+
264
+
265
+ class AdminQuotaUpdateRequest(BaseModel):
266
+ """Per-org quota overrides. Omitted fields keep their value; null clears one."""
267
+
268
+ max_sources: int | None = Field(default=None, ge=0)
269
+ max_assets_per_source: int | None = Field(default=None, ge=0)
270
+ max_successful_runs_per_month: int | None = Field(default=None, ge=0)
271
+
272
+
273
+ class AdminActivityEntry(BaseModel):
274
+ """One event in an organisation's derived activity feed."""
275
+
276
+ kind: str
277
+ when: datetime
278
+ title: str
279
+ detail: str | None = None
280
+
281
+
226
282
  class AdminConfigResponse(BaseModel):
227
283
  """Read-only, secrets-redacted snapshot of the instance configuration."""
228
284
 
@@ -230,6 +286,7 @@ class AdminConfigResponse(BaseModel):
230
286
  auth: AdminAuthConfig
231
287
  services: AdminServicesConfig
232
288
  data: AdminDataConfig
289
+ quotas: AdminQuotaLimits
233
290
 
234
291
 
235
292
  # Non-secret launcher/runner config keys exposed on /admin/config. Anything not
@@ -400,12 +457,41 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
400
457
  encryption_configured=bool(settings.secrets.encryption_key),
401
458
  catalog=_catalog_by_kind(catalog) if catalog is not None else {},
402
459
  ),
460
+ quotas=_quota_limits(settings.quota),
403
461
  )
404
462
 
405
463
 
406
464
  # -- Helpers ------------------------------------------------------------------
407
465
 
408
466
 
467
+ def _quota_limits(limits: Any) -> AdminQuotaLimits:
468
+ """Map limits from a ``{key: limit}`` dict (store) or attributes (settings)."""
469
+ if isinstance(limits, dict):
470
+ return AdminQuotaLimits(**{key: limits.get(key) for key in AdminQuotaLimits.model_fields})
471
+ return AdminQuotaLimits(
472
+ max_sources=getattr(limits, "max_sources", None),
473
+ max_assets_per_source=getattr(limits, "max_assets_per_source", None),
474
+ max_successful_runs_per_month=getattr(limits, "max_successful_runs_per_month", None),
475
+ )
476
+
477
+
478
+ def _effective_limits(overrides: AdminQuotaLimits, defaults: AdminQuotaLimits) -> AdminQuotaLimits:
479
+ """Per-org overrides win field-by-field over the global defaults."""
480
+ return AdminQuotaLimits(
481
+ max_sources=overrides.max_sources if overrides.max_sources is not None else defaults.max_sources,
482
+ max_assets_per_source=(
483
+ overrides.max_assets_per_source
484
+ if overrides.max_assets_per_source is not None
485
+ else defaults.max_assets_per_source
486
+ ),
487
+ max_successful_runs_per_month=(
488
+ overrides.max_successful_runs_per_month
489
+ if overrides.max_successful_runs_per_month is not None
490
+ else defaults.max_successful_runs_per_month
491
+ ),
492
+ )
493
+
494
+
409
495
  def _require_org(store: Store, org_id: UUID) -> Organisation:
410
496
  """Fetch an organisation or raise 404."""
411
497
  org = store.get_organisation(org_id)
@@ -466,6 +552,100 @@ def get_instance_config(
466
552
  return config
467
553
 
468
554
 
555
+ # -- Quotas ---------------------------------------------------------------------
556
+
557
+
558
+ @router.get("/quotas")
559
+ def get_quotas(
560
+ user: Profile = Depends(require_super_admin),
561
+ store: Store = Depends(get_store),
562
+ quota_defaults: Any = Depends(get_quota_defaults),
563
+ ) -> AdminQuotasResponse:
564
+ """Quota limits and current-period usage for every organisation."""
565
+ defaults = _quota_limits(quota_defaults)
566
+ period_start = store.current_period_start()
567
+ overrides = store.list_quota_overrides()
568
+ usage = {
569
+ row.org_id: row
570
+ for row in store.list_usage(period_start=period_start)
571
+ if row.metric == METRIC_SUCCESSFUL_RUNS
572
+ }
573
+ sources = store.count_sources_by_org()
574
+ max_assets = store.max_assets_per_source_by_org()
575
+ recomputed = store.count_successful_runs_by_org(period_start)
576
+
577
+ organisations = []
578
+ for org, _member_count in store.list_all_organisations():
579
+ limits = _quota_limits(overrides.get(org.id, {}))
580
+ org_usage = usage.get(org.id)
581
+ organisations.append(
582
+ AdminOrgQuotaStatus(
583
+ id=org.id,
584
+ name=org.name,
585
+ deleted_at=org.deleted_at,
586
+ limits=limits,
587
+ effective=_effective_limits(limits, defaults),
588
+ sources=sources.get(org.id, 0),
589
+ max_assets_per_source=max_assets.get(org.id, 0),
590
+ successful_runs=org_usage.used if org_usage else 0,
591
+ reserved_runs=org_usage.reserved if org_usage else 0,
592
+ recomputed_successful_runs=recomputed.get(org.id, 0),
593
+ )
594
+ )
595
+ return AdminQuotasResponse(period_start=period_start, defaults=defaults, organisations=organisations)
596
+
597
+
598
+ def _activity_title(entry: dict[str, Any]) -> tuple[str, str | None]:
599
+ """Presentation strings for a derived activity entry."""
600
+ kind, subject, extra = entry["kind"], entry["subject"], entry["extra"]
601
+ if kind == "org_created":
602
+ return "Organisation created", None
603
+ if kind == "org_deleted":
604
+ return "Organisation deleted", "Retained read-only for billing history."
605
+ if kind == "member_joined":
606
+ return f"{subject} joined the organisation", f"Role: {extra}" if extra else None
607
+ if kind == "invitation_sent":
608
+ return f"Invitation sent to {subject}", f"Invited by {extra}" if extra else None
609
+ if kind == "source_added":
610
+ return f"Source added — {subject}", None
611
+ if kind == "runs_completed":
612
+ count = int(subject)
613
+ return f"{count:,} run{'' if count == 1 else 's'} completed successfully", None
614
+ return kind, None
615
+
616
+
617
+ @router.get("/organisations/{org_id}/activity")
618
+ def get_organisation_activity(
619
+ org_id: UUID,
620
+ user: Profile = Depends(require_super_admin),
621
+ store: Store = Depends(get_store),
622
+ ) -> list[AdminActivityEntry]:
623
+ """Derived activity feed for one organisation, newest first.
624
+
625
+ Composed from existing records (memberships, pending invitations,
626
+ sources, run aggregates, the org row itself) — there is no audit
627
+ trail, so actor attribution is limited to what those rows carry.
628
+ """
629
+ entries = []
630
+ for entry in store.list_organisation_activity(org_id):
631
+ title, detail = _activity_title(entry)
632
+ entries.append(AdminActivityEntry(kind=entry["kind"], when=entry["when"], title=title, detail=detail))
633
+ return entries
634
+
635
+
636
+ @router.patch("/organisations/{org_id}/quota")
637
+ def update_org_quota(
638
+ org_id: UUID,
639
+ body: AdminQuotaUpdateRequest,
640
+ user: Profile = Depends(require_super_admin),
641
+ store: Store = Depends(get_store),
642
+ ) -> AdminQuotaLimits:
643
+ """Set an organisation's quota overrides; omitted fields keep their value, null clears one."""
644
+ _require_org(store, org_id)
645
+ overrides = store.set_quota(org_id, body.model_dump(exclude_unset=True))
646
+ return _quota_limits(overrides)
647
+
648
+
469
649
  # -- Users --------------------------------------------------------------------
470
650
 
471
651
 
@@ -510,13 +690,14 @@ def list_all_organisations(
510
690
  user: Profile = Depends(require_super_admin),
511
691
  store: Store = Depends(get_store),
512
692
  ) -> list[AdminOrganisationResponse]:
513
- """List every organisation with its member count."""
693
+ """List every organisation with its member count, soft-deleted ones included."""
514
694
  return [
515
695
  AdminOrganisationResponse(
516
696
  id=org.id,
517
697
  name=org.name,
518
698
  member_count=count,
519
699
  created_at=org.created_at,
700
+ deleted_at=org.deleted_at,
520
701
  )
521
702
  for org, count in store.list_all_organisations()
522
703
  ]
@@ -563,7 +744,11 @@ def delete_organisation(
563
744
  user: Profile = Depends(require_super_admin),
564
745
  store: Store = Depends(get_store),
565
746
  ) -> dict[str, str]:
566
- """Delete an organisation and all its data. The body must repeat the exact name."""
747
+ """Soft-delete an organisation: purges its data, keeps execution history and the usage ledger.
748
+
749
+ The body must repeat the exact name. Deleted organisations stay in the
750
+ list (read-only) and read as missing everywhere else.
751
+ """
567
752
  org = _require_org(store, org_id)
568
753
  if body.name != org.name:
569
754
  raise HTTPException(status_code=400, detail="Organisation name does not match")
@@ -1,4 +1,4 @@
1
- """Backfills API: read endpoints for backfill state."""
1
+ """Backfills API: queue, inspect, and cancel backfills."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -100,14 +100,17 @@ def create_backfill(
100
100
  job = load_authorized(
101
101
  lambda i: store.get_component(i, kind="job"), body.component_id, user, store, label="Job", minimum="editor"
102
102
  )
103
- backfill = store.create_backfill(
104
- job.org_id,
105
- component_id=body.component_id,
106
- start_date=body.start_date,
107
- end_date=body.end_date,
108
- concurrency=body.concurrency,
109
- fail_fast=body.fail_fast,
110
- )
103
+ try:
104
+ backfill = store.create_backfill(
105
+ job.org_id,
106
+ component_id=body.component_id,
107
+ start_date=body.start_date,
108
+ end_date=body.end_date,
109
+ concurrency=body.concurrency,
110
+ fail_fast=body.fail_fast,
111
+ )
112
+ except ValueError as e: # span cap
113
+ raise HTTPException(status_code=400, detail=str(e))
111
114
  return _backfill_to_response(backfill)
112
115
 
113
116
 
@@ -124,3 +127,24 @@ def get_backfill(
124
127
  raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
125
128
  authorize_org_member(user, backfill.org_id, store, detail=f"Backfill {backfill_id} not found")
126
129
  return _backfill_to_response(backfill)
130
+
131
+
132
+ @router.post("/{backfill_id}/cancel")
133
+ def cancel_backfill(
134
+ backfill_id: UUID,
135
+ user: Profile = Depends(get_current_user),
136
+ store: Store = Depends(get_store),
137
+ ) -> BackfillResponse:
138
+ """Cancel a backfill: pending and queued runs are canceled, in-flight runs drain."""
139
+ try:
140
+ backfill = store.get_backfill(backfill_id)
141
+ except NotFoundError:
142
+ raise HTTPException(status_code=404, detail=f"Backfill {backfill_id} not found")
143
+ authorize_org_member(
144
+ user, backfill.org_id, store, minimum="editor", detail=f"Backfill {backfill_id} not found"
145
+ )
146
+ try:
147
+ backfill = store.cancel_backfill(backfill_id)
148
+ except ValueError as e:
149
+ raise HTTPException(status_code=409, detail=str(e))
150
+ return _backfill_to_response(backfill)