interloper-api 0.58.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.

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.59.0}/PKG-INFO +1 -1
  2. {interloper_api-0.58.0 → interloper_api-0.59.0}/pyproject.toml +1 -1
  3. {interloper_api-0.58.0 → interloper_api-0.59.0}/pyproject.toml.orig +1 -1
  4. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/admin.py +87 -6
  5. {interloper_api-0.58.0 → interloper_api-0.59.0}/README.md +0 -0
  6. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/__init__.py +0 -0
  7. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/app.py +0 -0
  8. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/components.py +0 -0
  9. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/dependencies.py +0 -0
  10. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/email.py +0 -0
  11. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/__init__.py +0 -0
  12. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/agent.py +0 -0
  13. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/auth.py +0 -0
  14. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/backfills.py +0 -0
  15. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/catalog.py +0 -0
  16. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/components.py +0 -0
  17. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/oauth.py +0 -0
  18. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/organisations.py +0 -0
  19. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/runs.py +0 -0
  20. {interloper_api-0.58.0 → interloper_api-0.59.0}/src/interloper_api/routes/tokens.py +0 -0
  21. {interloper_api-0.58.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.58.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.58.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.58.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" }]
@@ -19,7 +19,7 @@ from uuid import UUID
19
19
  from fastapi import APIRouter, Depends, HTTPException, Request
20
20
  from interloper_db import Organisation, Profile, Store
21
21
  from interloper_db.store.quotas import METRIC_SUCCESSFUL_RUNS
22
- from pydantic import BaseModel
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):
@@ -238,6 +241,8 @@ class AdminOrgQuotaStatus(BaseModel):
238
241
 
239
242
  id: UUID
240
243
  name: str
244
+ #: Soft-deleted orgs keep their ledger visible but are read-only.
245
+ deleted_at: datetime | None = None
241
246
  limits: AdminQuotaLimits
242
247
  effective: AdminQuotaLimits
243
248
  sources: int
@@ -257,6 +262,23 @@ class AdminQuotasResponse(BaseModel):
257
262
  organisations: list[AdminOrgQuotaStatus]
258
263
 
259
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
+
260
282
  class AdminConfigResponse(BaseModel):
261
283
  """Read-only, secrets-redacted snapshot of the instance configuration."""
262
284
 
@@ -443,7 +465,9 @@ def build_config_snapshot(settings: Any, features: dict[str, bool], catalog: Any
443
465
 
444
466
 
445
467
  def _quota_limits(limits: Any) -> AdminQuotaLimits:
446
- """Map any limits-shaped object (settings, Quota row, None) to the model."""
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})
447
471
  return AdminQuotaLimits(
448
472
  max_sources=getattr(limits, "max_sources", None),
449
473
  max_assets_per_source=getattr(limits, "max_assets_per_source", None),
@@ -540,7 +564,7 @@ def get_quotas(
540
564
  """Quota limits and current-period usage for every organisation."""
541
565
  defaults = _quota_limits(quota_defaults)
542
566
  period_start = store.current_period_start()
543
- overrides = {quota.org_id: quota for quota in store.list_quotas()}
567
+ overrides = store.list_quota_overrides()
544
568
  usage = {
545
569
  row.org_id: row
546
570
  for row in store.list_usage(period_start=period_start)
@@ -552,12 +576,13 @@ def get_quotas(
552
576
 
553
577
  organisations = []
554
578
  for org, _member_count in store.list_all_organisations():
555
- limits = _quota_limits(overrides.get(org.id))
579
+ limits = _quota_limits(overrides.get(org.id, {}))
556
580
  org_usage = usage.get(org.id)
557
581
  organisations.append(
558
582
  AdminOrgQuotaStatus(
559
583
  id=org.id,
560
584
  name=org.name,
585
+ deleted_at=org.deleted_at,
561
586
  limits=limits,
562
587
  effective=_effective_limits(limits, defaults),
563
588
  sources=sources.get(org.id, 0),
@@ -570,6 +595,57 @@ def get_quotas(
570
595
  return AdminQuotasResponse(period_start=period_start, defaults=defaults, organisations=organisations)
571
596
 
572
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
+
573
649
  # -- Users --------------------------------------------------------------------
574
650
 
575
651
 
@@ -614,13 +690,14 @@ def list_all_organisations(
614
690
  user: Profile = Depends(require_super_admin),
615
691
  store: Store = Depends(get_store),
616
692
  ) -> list[AdminOrganisationResponse]:
617
- """List every organisation with its member count."""
693
+ """List every organisation with its member count, soft-deleted ones included."""
618
694
  return [
619
695
  AdminOrganisationResponse(
620
696
  id=org.id,
621
697
  name=org.name,
622
698
  member_count=count,
623
699
  created_at=org.created_at,
700
+ deleted_at=org.deleted_at,
624
701
  )
625
702
  for org, count in store.list_all_organisations()
626
703
  ]
@@ -667,7 +744,11 @@ def delete_organisation(
667
744
  user: Profile = Depends(require_super_admin),
668
745
  store: Store = Depends(get_store),
669
746
  ) -> dict[str, str]:
670
- """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
+ """
671
752
  org = _require_org(store, org_id)
672
753
  if body.name != org.name:
673
754
  raise HTTPException(status_code=400, detail="Organisation name does not match")