interloper-api 0.58.0__tar.gz → 0.60.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.

Potentially problematic release.


This version of interloper-api might be problematic. Click here for more details.

Files changed (21) hide show
  1. {interloper_api-0.58.0 → interloper_api-0.60.0}/PKG-INFO +1 -1
  2. {interloper_api-0.58.0 → interloper_api-0.60.0}/pyproject.toml +1 -1
  3. {interloper_api-0.58.0 → interloper_api-0.60.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/admin.py +122 -24
  5. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/backfills.py +8 -11
  6. {interloper_api-0.58.0 → interloper_api-0.60.0}/README.md +0 -0
  7. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/__init__.py +0 -0
  8. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/app.py +0 -0
  9. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/components.py +0 -0
  10. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/dependencies.py +0 -0
  11. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/email.py +0 -0
  12. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/__init__.py +0 -0
  13. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/agent.py +0 -0
  14. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/auth.py +0 -0
  15. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/components.py +0 -0
  17. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/oauth.py +0 -0
  18. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/organisations.py +0 -0
  19. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/runs.py +0 -0
  20. {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/tokens.py +0 -0
  21. {interloper_api-0.58.0 → interloper_api-0.60.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.58.0
3
+ Version: 0.60.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.58.0"
3
+ version = "0.60.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.58.0"
6
+ version = "0.60.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -18,8 +18,8 @@ from uuid import UUID
18
18
 
19
19
  from fastapi import APIRouter, Depends, HTTPException, Request
20
20
  from interloper_db import Organisation, Profile, Store
21
- from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS
22
- from pydantic import BaseModel
21
+ from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS, QUOTAS
22
+ from pydantic import BaseModel, Field
23
23
 
24
24
  from interloper_api.dependencies import get_admin_config, get_quota_defaults, get_store, require_super_admin
25
25
  from interloper_api.email import send_invite_email
@@ -41,6 +41,9 @@ class AdminOrganisationResponse(BaseModel):
41
41
  name: str
42
42
  member_count: int
43
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
44
47
 
45
48
 
46
49
  class AdminUserOrganisation(BaseModel):
@@ -231,6 +234,7 @@ class AdminQuotaLimits(BaseModel):
231
234
  max_sources: int | None = None
232
235
  max_assets_per_source: int | None = None
233
236
  max_successful_runs_per_month: int | None = None
237
+ max_backfill_days: int | None = None
234
238
 
235
239
 
236
240
  class AdminOrgQuotaStatus(BaseModel):
@@ -238,6 +242,8 @@ class AdminOrgQuotaStatus(BaseModel):
238
242
 
239
243
  id: UUID
240
244
  name: str
245
+ #: Soft-deleted orgs keep their ledger visible but are read-only.
246
+ deleted_at: datetime | None = None
241
247
  limits: AdminQuotaLimits
242
248
  effective: AdminQuotaLimits
243
249
  sources: int
@@ -249,14 +255,41 @@ class AdminOrgQuotaStatus(BaseModel):
249
255
  recomputed_successful_runs: int
250
256
 
251
257
 
258
+ class AdminQuotaField(BaseModel):
259
+ """One quota's display descriptor: key, registry label, instance default."""
260
+
261
+ key: str
262
+ label: str
263
+ default: int | None = None
264
+
265
+
252
266
  class AdminQuotasResponse(BaseModel):
253
267
  """Quota overview: global defaults plus per-organisation status."""
254
268
 
255
269
  period_start: dt.date
256
270
  defaults: AdminQuotaLimits
271
+ fields: list[AdminQuotaField]
257
272
  organisations: list[AdminOrgQuotaStatus]
258
273
 
259
274
 
275
+ class AdminQuotaUpdateRequest(BaseModel):
276
+ """Per-org quota overrides. Omitted fields keep their value; null clears one."""
277
+
278
+ max_sources: int | None = Field(default=None, ge=0)
279
+ max_assets_per_source: int | None = Field(default=None, ge=0)
280
+ max_successful_runs_per_month: int | None = Field(default=None, ge=0)
281
+ max_backfill_days: int | None = Field(default=None, ge=0)
282
+
283
+
284
+ class AdminActivityEntry(BaseModel):
285
+ """One event in an organisation's derived activity feed."""
286
+
287
+ kind: str
288
+ when: datetime
289
+ title: str
290
+ detail: str | None = None
291
+
292
+
260
293
  class AdminConfigResponse(BaseModel):
261
294
  """Read-only, secrets-redacted snapshot of the instance configuration."""
262
295
 
@@ -443,31 +476,34 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
443
476
 
444
477
 
445
478
  def _quota_limits(limits: Any) -> AdminQuotaLimits:
446
- """Map any limits-shaped object (settings, Quota row, None) to the model."""
447
- return AdminQuotaLimits(
448
- max_sources=getattr(limits, "max_sources", None),
449
- max_assets_per_source=getattr(limits, "max_assets_per_source", None),
450
- max_successful_runs_per_month=getattr(limits, "max_successful_runs_per_month", None),
451
- )
479
+ """Map limits from a ``{key: limit}`` dict (store) or attributes (settings)."""
480
+ if isinstance(limits, dict):
481
+ return AdminQuotaLimits(**{key: limits.get(key) for key in AdminQuotaLimits.model_fields})
482
+ return AdminQuotaLimits(**{key: getattr(limits, key, None) for key in AdminQuotaLimits.model_fields})
452
483
 
453
484
 
454
485
  def _effective_limits(overrides: AdminQuotaLimits, defaults: AdminQuotaLimits) -> AdminQuotaLimits:
455
486
  """Per-org overrides win field-by-field over the global defaults."""
456
487
  return AdminQuotaLimits(
457
- max_sources=overrides.max_sources if overrides.max_sources is not None else defaults.max_sources,
458
- max_assets_per_source=(
459
- overrides.max_assets_per_source
460
- if overrides.max_assets_per_source is not None
461
- else defaults.max_assets_per_source
462
- ),
463
- max_successful_runs_per_month=(
464
- overrides.max_successful_runs_per_month
465
- if overrides.max_successful_runs_per_month is not None
466
- else defaults.max_successful_runs_per_month
467
- ),
488
+ **{
489
+ key: override if (override := getattr(overrides, key)) is not None else getattr(defaults, key)
490
+ for key in AdminQuotaLimits.model_fields
491
+ }
468
492
  )
469
493
 
470
494
 
495
+ def _quota_fields(defaults: AdminQuotaLimits) -> list[AdminQuotaField]:
496
+ """Field descriptors for admin quota surfaces, in the wire model's order.
497
+
498
+ Labels come from the registry definitions, so the frontend renders new
499
+ quotas without a matching code change.
500
+ """
501
+ return [
502
+ AdminQuotaField(key=key, label=QUOTAS[key].label, default=getattr(defaults, key))
503
+ for key in AdminQuotaLimits.model_fields
504
+ ]
505
+
506
+
471
507
  def _require_org(store: Store, org_id: UUID) -> Organisation:
472
508
  """Fetch an organisation or raise 404."""
473
509
  org = store.get_organisation(org_id)
@@ -540,7 +576,7 @@ def get_quotas(
540
576
  """Quota limits and current-period usage for every organisation."""
541
577
  defaults = _quota_limits(quota_defaults)
542
578
  period_start = store.current_period_start()
543
- overrides = {quota.org_id: quota for quota in store.list_quotas()}
579
+ overrides = store.list_quota_overrides()
544
580
  usage = {
545
581
  row.org_id: row
546
582
  for row in store.list_usage(period_start=period_start)
@@ -552,12 +588,13 @@ def get_quotas(
552
588
 
553
589
  organisations = []
554
590
  for org, _member_count in store.list_all_organisations():
555
- limits = _quota_limits(overrides.get(org.id))
591
+ limits = _quota_limits(overrides.get(org.id, {}))
556
592
  org_usage = usage.get(org.id)
557
593
  organisations.append(
558
594
  AdminOrgQuotaStatus(
559
595
  id=org.id,
560
596
  name=org.name,
597
+ deleted_at=org.deleted_at,
561
598
  limits=limits,
562
599
  effective=_effective_limits(limits, defaults),
563
600
  sources=sources.get(org.id, 0),
@@ -567,7 +604,63 @@ def get_quotas(
567
604
  recomputed_successful_runs=recomputed.get(org.id, 0),
568
605
  )
569
606
  )
570
- return AdminQuotasResponse(period_start=period_start, defaults=defaults, organisations=organisations)
607
+ return AdminQuotasResponse(
608
+ period_start=period_start,
609
+ defaults=defaults,
610
+ fields=_quota_fields(defaults),
611
+ organisations=organisations,
612
+ )
613
+
614
+
615
+ def _activity_title(entry: dict[str, Any]) -> tuple[str, str | None]:
616
+ """Presentation strings for a derived activity entry."""
617
+ kind, subject, extra = entry["kind"], entry["subject"], entry["extra"]
618
+ if kind == "org_created":
619
+ return "Organisation created", None
620
+ if kind == "org_deleted":
621
+ return "Organisation deleted", "Retained read-only for billing history."
622
+ if kind == "member_joined":
623
+ return f"{subject} joined the organisation", f"Role: {extra}" if extra else None
624
+ if kind == "invitation_sent":
625
+ return f"Invitation sent to {subject}", f"Invited by {extra}" if extra else None
626
+ if kind == "source_added":
627
+ return f"Source added — {subject}", None
628
+ if kind == "runs_completed":
629
+ count = int(subject)
630
+ return f"{count:,} run{'' if count == 1 else 's'} completed successfully", None
631
+ return kind, None
632
+
633
+
634
+ @router.get("/organisations/{org_id}/activity")
635
+ def get_organisation_activity(
636
+ org_id: UUID,
637
+ user: Profile = Depends(require_super_admin),
638
+ store: Store = Depends(get_store),
639
+ ) -> list[AdminActivityEntry]:
640
+ """Derived activity feed for one organisation, newest first.
641
+
642
+ Composed from existing records (memberships, pending invitations,
643
+ sources, run aggregates, the org row itself) — there is no audit
644
+ trail, so actor attribution is limited to what those rows carry.
645
+ """
646
+ entries = []
647
+ for entry in store.list_organisation_activity(org_id):
648
+ title, detail = _activity_title(entry)
649
+ entries.append(AdminActivityEntry(kind=entry["kind"], when=entry["when"], title=title, detail=detail))
650
+ return entries
651
+
652
+
653
+ @router.patch("/organisations/{org_id}/quota")
654
+ def update_org_quota(
655
+ org_id: UUID,
656
+ body: AdminQuotaUpdateRequest,
657
+ user: Profile = Depends(require_super_admin),
658
+ store: Store = Depends(get_store),
659
+ ) -> AdminQuotaLimits:
660
+ """Set an organisation's quota overrides; omitted fields keep their value, null clears one."""
661
+ _require_org(store, org_id)
662
+ overrides = store.set_quota(org_id, body.model_dump(exclude_unset=True))
663
+ return _quota_limits(overrides)
571
664
 
572
665
 
573
666
  # -- Users --------------------------------------------------------------------
@@ -614,13 +707,14 @@ def list_all_organisations(
614
707
  user: Profile = Depends(require_super_admin),
615
708
  store: Store = Depends(get_store),
616
709
  ) -> list[AdminOrganisationResponse]:
617
- """List every organisation with its member count."""
710
+ """List every organisation with its member count, soft-deleted ones included."""
618
711
  return [
619
712
  AdminOrganisationResponse(
620
713
  id=org.id,
621
714
  name=org.name,
622
715
  member_count=count,
623
716
  created_at=org.created_at,
717
+ deleted_at=org.deleted_at,
624
718
  )
625
719
  for org, count in store.list_all_organisations()
626
720
  ]
@@ -667,7 +761,11 @@ def delete_organisation(
667
761
  user: Profile = Depends(require_super_admin),
668
762
  store: Store = Depends(get_store),
669
763
  ) -> dict[str, str]:
670
- """Delete an organisation and all its data. The body must repeat the exact name."""
764
+ """Soft-delete an organisation: purges its data, keeps execution history and the usage ledger.
765
+
766
+ The body must repeat the exact name. Deleted organisations stay in the
767
+ list (read-only) and read as missing everywhere else.
768
+ """
671
769
  org = _require_org(store, org_id)
672
770
  if body.name != org.name:
673
771
  raise HTTPException(status_code=400, detail="Organisation name does not match")
@@ -100,17 +100,14 @@ 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
- 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))
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
+ )
114
111
  return _backfill_to_response(backfill)
115
112
 
116
113