Skip to content

Commit a49f73c

Browse files
sanjaydocclaude
andcommitted
Add operations layer: SaaS+cost, equipment, inventory, vendors, safety
Makes LabSuite the centralized ops source of truth beyond identity/access: - operations.Operations: SaaS licences (who has what + monthly/annual spend, orphaned-seat detection), equipment (maintenance/calibration due+overdue), inventory (low-stock reorder), vendors (upcoming renewals + annual cost), facility safety (open issues) - onboarding grants baseline + role SaaS seats; offboarding reclaims them same-day - engine: ops wired in, ops_summary(); state persisted - cli: ops, saas, assets, inventory, vendors, safety; onboard/offboard show SaaS - api: GET /ops /saas /assets /inventory /vendors /safety - seed: realistic Merge-lab equipment/reagents/vendors/safety with live flags - 6 operations tests (61 total, ruff-clean) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9BehfGmiCrz3J15W18rc9
1 parent 0f52dbf commit a49f73c

6 files changed

Lines changed: 500 additions & 0 deletions

File tree

src/labsuite/api.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,37 @@ def expire_training(username: str = Body(...), training: str = Body(...)) -> dic
151151
control.expire_training(username, training)
152152
return {"username": username, "training": training, "status": "expired"}
153153

154+
# --------------------------------------------------------------- #
155+
# Operations (SaaS / equipment / inventory / vendors / safety)
156+
# --------------------------------------------------------------- #
157+
@app.get("/ops")
158+
def ops() -> dict:
159+
return control.ops_summary()
160+
161+
@app.get("/saas")
162+
def saas() -> dict:
163+
return {
164+
"monthly_spend": control.ops.monthly_spend(),
165+
"annual_cost": control.ops.annual_saas_cost(),
166+
"apps": [a.to_dict() for a in control.ops.saas.values()],
167+
}
168+
169+
@app.get("/assets")
170+
def assets() -> dict:
171+
return {"equipment": [e.to_dict() for e in control.ops.equipment]}
172+
173+
@app.get("/inventory")
174+
def inventory() -> dict:
175+
return {"inventory": [i.to_dict() for i in control.ops.inventory]}
176+
177+
@app.get("/vendors")
178+
def vendors() -> dict:
179+
return {"vendors": [v.to_dict() for v in control.ops.vendors]}
180+
181+
@app.get("/safety")
182+
def safety() -> dict:
183+
return {"safety": [s.to_dict() for s in control.ops.safety]}
184+
154185
# --------------------------------------------------------------- #
155186
# Web GUI + marketing site
156187
# --------------------------------------------------------------- #

src/labsuite/cli.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,9 @@ def cmd_onboard(args: argparse.Namespace) -> int:
111111
print(f" required : {', '.join(result.trainings)} {DIM}(pending){RESET}")
112112
for share, missing in sorted(result.gated.items()):
113113
print(f" {_c('gated', RED)} {share:18} until {', '.join(missing)} complete")
114+
if result.saas:
115+
_header("SaaS provisioned")
116+
print(f" {', '.join(result.saas)}")
114117
_save(cp, args.state)
115118
return 0
116119

@@ -132,6 +135,8 @@ def cmd_offboard(args: argparse.Namespace) -> int:
132135
if result.device:
133136
d = result.device
134137
print(f" device : {d['asset_tag']} ({d['model']}) -> {_c('wipe & return', RED)}")
138+
if result.saas_revoked:
139+
print(f" SaaS seats reclaimed: {', '.join(result.saas_revoked)}")
135140
_save(cp, args.state)
136141
return 0 if result.clean else 1
137142

@@ -246,6 +251,86 @@ def cmd_devices(args: argparse.Namespace) -> int:
246251
return 0
247252

248253

254+
def cmd_ops(args: argparse.Namespace) -> int:
255+
cp = _load_or_build(args.state)
256+
s = cp.ops_summary()
257+
_header("Operations dashboard")
258+
spend = f"${s['monthly_saas_spend']:,.0f}/mo"
259+
print(f" SaaS spend : {_c(spend, CYAN)} across {s['saas_apps']} apps")
260+
print(f" Equipment : {s['equipment']} assets")
261+
262+
def _flags(title, items, colour=RED):
263+
mark = _c("!", colour)
264+
if items:
265+
print(f" {title}:")
266+
for item in items:
267+
print(f" {mark} {item}")
268+
269+
_flags("Orphaned SaaS seats (inactive users)", s["orphaned_seats"])
270+
_flags("Overdue maintenance", s["overdue_equipment"])
271+
_flags("Maintenance due soon", s["due_equipment"], CYAN)
272+
_flags("Low stock", s["low_stock"])
273+
_flags("Upcoming renewals (<=60d)", s["upcoming_renewals"], CYAN)
274+
_flags("Open safety issues", s["open_safety"])
275+
clean = not any(s[k] for k in ("orphaned_seats", "overdue_equipment", "low_stock", "open_safety"))
276+
if clean:
277+
print(_c(" all clear", GREEN))
278+
return 0
279+
280+
281+
def cmd_saas(args: argparse.Namespace) -> int:
282+
cp = _load_or_build(args.state)
283+
_header("SaaS licences")
284+
for app in sorted(cp.ops.saas.values(), key=lambda a: a.name):
285+
seats = len(app.assignees)
286+
monthly = app.monthly_cost_per_seat * seats
287+
print(f" {app.name:18} {seats:2} seats ${app.monthly_cost_per_seat:>5.1f}/seat = ${monthly:>7.0f}/mo")
288+
print(_c(f" total: ${cp.ops.monthly_spend():,.0f}/mo (${cp.ops.annual_saas_cost():,.0f}/yr)", CYAN))
289+
return 0
290+
291+
292+
def cmd_assets(args: argparse.Namespace) -> int:
293+
cp = _load_or_build(args.state)
294+
_header("Equipment / assets")
295+
for e in cp.ops.equipment:
296+
if e.maintenance_in_days < 0:
297+
m = _c(f"overdue {-e.maintenance_in_days}d", RED)
298+
elif e.maintenance_in_days <= 14:
299+
m = _c(f"due {e.maintenance_in_days}d", CYAN)
300+
else:
301+
m = f"{e.maintenance_in_days}d"
302+
print(f" {e.asset_tag} {e.name:30} {e.location:14} maint {m}")
303+
return 0
304+
305+
306+
def cmd_inventory(args: argparse.Namespace) -> int:
307+
cp = _load_or_build(args.state)
308+
_header("Inventory")
309+
for i in cp.ops.inventory:
310+
flag = _c("LOW", RED) if i.low else "ok"
311+
print(f" {i.sku:8} {i.name:22} {i.qty:3} {i.unit:8} (reorder {i.reorder_point}) {flag}")
312+
return 0
313+
314+
315+
def cmd_vendors(args: argparse.Namespace) -> int:
316+
cp = _load_or_build(args.state)
317+
_header("Vendors / contracts")
318+
for v in sorted(cp.ops.vendors, key=lambda x: x.renewal_in_days):
319+
soon = _c("renews soon", CYAN) if v.renewal_in_days <= 60 else ""
320+
print(f" {v.name:16} {v.category:22} renews {v.renewal_in_days:3}d ${v.annual_cost:>8,.0f}/yr {soon}")
321+
return 0
322+
323+
324+
def cmd_safety(args: argparse.Namespace) -> int:
325+
cp = _load_or_build(args.state)
326+
_header("Facility safety checks")
327+
for s in cp.ops.safety:
328+
mark = _c("OPEN", RED) if s.status == "open" else _c("pass", GREEN)
329+
note = f"{DIM}{s.note}{RESET}" if s.note else ""
330+
print(f" {mark:5} {s.area:16} {s.check}{note}")
331+
return 0
332+
333+
249334
def cmd_sync(args: argparse.Namespace) -> int:
250335
cp = _load_or_build(args.state)
251336
report = cp.sync()
@@ -397,6 +482,12 @@ def build_parser() -> argparse.ArgumentParser:
397482
p.add_argument("--expire", action="store_true", help="mark the training lapsed instead of complete")
398483
p.set_defaults(func=cmd_train)
399484

485+
sub.add_parser("ops", help="operations dashboard (SaaS spend + flags)").set_defaults(func=cmd_ops)
486+
sub.add_parser("saas", help="SaaS licences + cost").set_defaults(func=cmd_saas)
487+
sub.add_parser("assets", help="equipment + maintenance").set_defaults(func=cmd_assets)
488+
sub.add_parser("inventory", help="reagent / consumable inventory").set_defaults(func=cmd_inventory)
489+
sub.add_parser("vendors", help="vendor contracts + renewals").set_defaults(func=cmd_vendors)
490+
sub.add_parser("safety", help="facility safety checks").set_defaults(func=cmd_safety)
400491
sub.add_parser("sync", help="run the SCIM reconcile now").set_defaults(func=cmd_sync)
401492

402493
p = sub.add_parser("audit", help="print the audit trail")

src/labsuite/engine.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from labsuite.endpoints import DeviceFleet
3636
from labsuite.models import AccessDecision, AccessLevel, Department, ProxmoxRole
3737
from labsuite.okta import OktaDirectory
38+
from labsuite.operations import Operations
3839
from labsuite.policy import image_for, onboarding_groups, required_trainings
3940
from labsuite.proxmox import Proxmox
4041
from labsuite.scim import ScimSync, SyncReport
@@ -60,6 +61,7 @@ class OnboardResult:
6061
device: dict | None = None
6162
trainings: dict[str, str] = field(default_factory=dict)
6263
gated: dict[str, list[str]] = field(default_factory=dict)
64+
saas: list[str] = field(default_factory=list)
6365

6466
def to_dict(self) -> dict:
6567
return {
@@ -73,6 +75,7 @@ def to_dict(self) -> dict:
7375
"device": self.device,
7476
"trainings": self.trainings,
7577
"gated": self.gated,
78+
"saas": self.saas,
7679
}
7780

7881

@@ -84,6 +87,7 @@ class OffboardResult:
8487
residual_truenas: dict[str, str] = field(default_factory=dict)
8588
residual_proxmox: dict[int, str] = field(default_factory=dict)
8689
device: dict | None = None
90+
saas_revoked: list[str] = field(default_factory=list)
8791

8892
@property
8993
def clean(self) -> bool:
@@ -98,6 +102,7 @@ def to_dict(self) -> dict:
98102
"residual_proxmox": self.residual_proxmox,
99103
"clean": self.clean,
100104
"device": self.device,
105+
"saas_revoked": self.saas_revoked,
101106
}
102107

103108

@@ -136,6 +141,7 @@ def __init__(
136141
proxmox: ComputeProvider | None = None,
137142
endpoints: EndpointProvider | None = None,
138143
compliance: ComplianceRegistry | None = None,
144+
operations: Operations | None = None,
139145
audit: AuditLog | None = None,
140146
) -> None:
141147
# Any of these may be a real-system adapter; the control plane only ever
@@ -146,6 +152,7 @@ def __init__(
146152
self.proxmox: ComputeProvider = proxmox or Proxmox()
147153
self.endpoints: EndpointProvider = endpoints or DeviceFleet()
148154
self.compliance = compliance or ComplianceRegistry()
155+
self.ops = operations or Operations()
149156
self.audit = audit or AuditLog()
150157
self.scim = ScimSync(self.okta, self.ad, self.audit)
151158

@@ -214,6 +221,10 @@ def onboard(
214221
detail=f"pending: {', '.join(trainings)}",
215222
)
216223

224+
# SaaS half: grant the baseline + role app seats.
225+
saas = self.ops.provision_role_apps(username, role)
226+
self.audit.record(actor, "saas.provision", username, "saas", detail=", ".join(saas))
227+
217228
access = self.resolve_access(username)
218229
return OnboardResult(
219230
username=username,
@@ -226,6 +237,7 @@ def onboard(
226237
device=device.to_dict(),
227238
trainings=access.trainings,
228239
gated=access.blocked,
240+
saas=saas,
229241
)
230242

231243
# ------------------------------------------------------------------ #
@@ -249,6 +261,11 @@ def offboard(self, username: str, *, actor: str = "it-admin") -> OffboardResult:
249261
actor, "device.wipe", device.asset_tag, "endpoint", detail="wipe & return",
250262
)
251263

264+
# SaaS half: reclaim all licence seats (same-day).
265+
saas_revoked = self.ops.revoke_saas_all(username)
266+
if saas_revoked:
267+
self.audit.record(actor, "saas.revoke", username, "saas", detail=", ".join(saas_revoked))
268+
252269
# Verification: after deprovisioning, effective access must be empty.
253270
groups = self.ad.effective_groups(username)
254271
residual_nas = {k: str(v) for k, v in self.truenas.shares_for(groups).items()}
@@ -272,6 +289,7 @@ def offboard(self, username: str, *, actor: str = "it-admin") -> OffboardResult:
272289
residual_truenas=residual_nas,
273290
residual_proxmox={int(p.split("/")[-1]): r for p, r in residual_pve.items()},
274291
device=device.to_dict() if device is not None else None,
292+
saas_revoked=saas_revoked,
275293
)
276294

277295
# ------------------------------------------------------------------ #
@@ -426,6 +444,17 @@ def access_review(self, *, actor: str = "it-admin") -> dict:
426444
def sync(self, *, actor: str = "it-admin") -> SyncReport:
427445
return self.scim.reconcile(actor=actor)
428446

447+
# ------------------------------------------------------------------ #
448+
# Operations (SaaS / equipment / inventory / vendors / safety)
449+
# ------------------------------------------------------------------ #
450+
def _is_active(self, username: str) -> bool:
451+
user = self.okta.get_user(username)
452+
return bool(user and user.active)
453+
454+
def ops_summary(self) -> dict:
455+
"""SaaS spend + flags across equipment, inventory, vendors, and safety."""
456+
return self.ops.summary(self._is_active)
457+
429458
# ------------------------------------------------------------------ #
430459
# Serialisation (persist state between CLI invocations)
431460
# ------------------------------------------------------------------ #
@@ -437,6 +466,7 @@ def to_dict(self) -> dict:
437466
"proxmox": self.proxmox.to_dict(),
438467
"endpoints": self.endpoints.to_dict(),
439468
"compliance": self.compliance.to_dict(),
469+
"operations": self.ops.to_dict(),
440470
"audit": self.audit.to_list(),
441471
}
442472

@@ -451,6 +481,7 @@ def from_dict(cls, data: dict) -> ControlPlane:
451481
proxmox=Proxmox.from_dict(data["proxmox"]),
452482
endpoints=DeviceFleet.from_dict(data.get("endpoints", {})),
453483
compliance=ComplianceRegistry.from_dict(data.get("compliance", {})),
484+
operations=Operations.from_dict(data.get("operations", {})),
454485
audit=audit,
455486
)
456487
return cp

0 commit comments

Comments
 (0)