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.
- {interloper_api-0.58.0 → interloper_api-0.60.0}/PKG-INFO +1 -1
- {interloper_api-0.58.0 → interloper_api-0.60.0}/pyproject.toml +1 -1
- {interloper_api-0.58.0 → interloper_api-0.60.0}/pyproject.toml.orig +1 -1
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/admin.py +122 -24
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/backfills.py +8 -11
- {interloper_api-0.58.0 → interloper_api-0.60.0}/README.md +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/app.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/components.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/dependencies.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/components.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/oauth.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/tokens.py +0 -0
- {interloper_api-0.58.0 → interloper_api-0.60.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
"""
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|