opensees-cli 0.2.2__tar.gz → 0.2.3__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.
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/PKG-INFO +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/pyproject.toml +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/__init__.py +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/admin.py +214 -65
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/api.py +35 -2
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/main.py +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/PKG-INFO +1 -1
- opensees_cli-0.2.3/tests/test_admin_commands.py +291 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_api.py +22 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_files_commands.py +4 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_run_commands.py +13 -0
- opensees_cli-0.2.2/tests/test_admin_commands.py +0 -150
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/README.md +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/setup.cfg +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/__main__.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/auth.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/config.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/files.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/path_setup.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/run.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli/version.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/SOURCES.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/entry_points.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/requires.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/src/opensees_cli.egg-info/top_level.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_auth_commands.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_config.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_main_commands.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.3}/tests/test_version_commands.py +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import re
|
|
5
|
-
from
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, List, Optional
|
|
6
7
|
|
|
7
8
|
import click
|
|
8
9
|
import typer
|
|
@@ -11,7 +12,15 @@ from rich.table import Table
|
|
|
11
12
|
from typer.core import TyperGroup
|
|
12
13
|
|
|
13
14
|
from opensees_cli import api, config
|
|
14
|
-
from opensees_cli.auth import _to_local,
|
|
15
|
+
from opensees_cli.auth import _to_local, print_quota
|
|
16
|
+
from opensees_cli.files import download as files_download
|
|
17
|
+
from opensees_cli.files import list_files as files_list
|
|
18
|
+
from opensees_cli.run import _BareDefaultCommand
|
|
19
|
+
from opensees_cli.run import data as run_data
|
|
20
|
+
from opensees_cli.run import list_analyses as run_list
|
|
21
|
+
from opensees_cli.run import output as run_output
|
|
22
|
+
from opensees_cli.run import stats as run_stats
|
|
23
|
+
from opensees_cli.run import status as run_status
|
|
15
24
|
|
|
16
25
|
_RUNTIME_TOKEN_RE = re.compile(r"^((\d+(?:\.\d+)?)(h|m|s))+$")
|
|
17
26
|
|
|
@@ -175,11 +184,7 @@ def delete_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
175
184
|
raise typer.Exit(1)
|
|
176
185
|
|
|
177
186
|
|
|
178
|
-
|
|
179
|
-
def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
180
|
-
"""Show a user's account details, quota, analyses, and files."""
|
|
181
|
-
if not email:
|
|
182
|
-
email = typer.prompt("Email")
|
|
187
|
+
def _print_user_info(email: str) -> None:
|
|
183
188
|
try:
|
|
184
189
|
r = api.post("/admin/user-info", {"email": email})
|
|
185
190
|
except api.ApiError as e:
|
|
@@ -200,8 +205,6 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
200
205
|
console.print(f" OpenSeesPy: {pin} (pinned)")
|
|
201
206
|
else:
|
|
202
207
|
console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
|
|
203
|
-
if r.get("last_cli_version"):
|
|
204
|
-
console.print(f" CLI version: {r['last_cli_version']}")
|
|
205
208
|
if r.get("last_ip"):
|
|
206
209
|
console.print(f" Last IP: {r['last_ip']}")
|
|
207
210
|
|
|
@@ -210,62 +213,6 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
210
213
|
console.print()
|
|
211
214
|
print_quota(q)
|
|
212
215
|
|
|
213
|
-
analyses = r.get("analyses") or []
|
|
214
|
-
analyses_total = int(r.get("analyses_total") or 0)
|
|
215
|
-
analyses_running = int(r.get("analyses_running") or 0)
|
|
216
|
-
console.print()
|
|
217
|
-
extra = f", {analyses_running} running" if analyses_running else ""
|
|
218
|
-
console.print(f"[bold]Analyses[/bold] [dim]{analyses_total} total{extra}[/dim]")
|
|
219
|
-
if not analyses:
|
|
220
|
-
console.print(" [dim]No analyses.[/dim]")
|
|
221
|
-
else:
|
|
222
|
-
table = Table(show_header=True)
|
|
223
|
-
table.add_column("Analysis", style="dim", max_width=8)
|
|
224
|
-
table.add_column("Status")
|
|
225
|
-
table.add_column("Type")
|
|
226
|
-
table.add_column("File")
|
|
227
|
-
table.add_column("Tasks")
|
|
228
|
-
table.add_column("Created")
|
|
229
|
-
for a in analyses:
|
|
230
|
-
aid = (a.get("analysis_id") or "")[:8]
|
|
231
|
-
total = int(a.get("total_tasks") or 1)
|
|
232
|
-
table.add_row(
|
|
233
|
-
aid,
|
|
234
|
-
str(a.get("status") or ""),
|
|
235
|
-
str(a.get("type") or ""),
|
|
236
|
-
str(a.get("filename") or ""),
|
|
237
|
-
str(total),
|
|
238
|
-
_to_local(a["created_at"]) if a.get("created_at") else "",
|
|
239
|
-
)
|
|
240
|
-
console.print(table)
|
|
241
|
-
if analyses_total > len(analyses):
|
|
242
|
-
console.print(f" [dim]Showing {len(analyses)} of {analyses_total}.[/dim]")
|
|
243
|
-
|
|
244
|
-
files = r.get("files") or []
|
|
245
|
-
files_total = int(r.get("files_total") or 0)
|
|
246
|
-
files_size = int(r.get("files_size") or 0)
|
|
247
|
-
console.print()
|
|
248
|
-
console.print(
|
|
249
|
-
f"[bold]Files[/bold] [dim]{files_total} file"
|
|
250
|
-
f"{'' if files_total == 1 else 's'}, {_fmt_bytes(files_size)}[/dim]"
|
|
251
|
-
)
|
|
252
|
-
if not files:
|
|
253
|
-
console.print(" [dim]No files.[/dim]")
|
|
254
|
-
else:
|
|
255
|
-
table = Table(show_header=True)
|
|
256
|
-
table.add_column("Path")
|
|
257
|
-
table.add_column("Size", justify="right")
|
|
258
|
-
table.add_column("Modified")
|
|
259
|
-
for f in files:
|
|
260
|
-
table.add_row(
|
|
261
|
-
str(f.get("path") or ""),
|
|
262
|
-
_fmt_bytes(f.get("size") or 0),
|
|
263
|
-
_to_local(f["last_modified"]) if f.get("last_modified") else "",
|
|
264
|
-
)
|
|
265
|
-
console.print(table)
|
|
266
|
-
if files_total > len(files):
|
|
267
|
-
console.print(f" [dim]Showing {len(files)} of {files_total}.[/dim]")
|
|
268
|
-
|
|
269
216
|
|
|
270
217
|
@app.command("set-quota")
|
|
271
218
|
def set_quota(
|
|
@@ -415,3 +362,205 @@ def workers(
|
|
|
415
362
|
_to_local(t["started_at"]) if t.get("started_at") else "-",
|
|
416
363
|
)
|
|
417
364
|
console.print(table)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ---------------------------------------------------------------------------
|
|
368
|
+
# user-info: account details, plus read-only run / files inspect
|
|
369
|
+
# ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
user_info_grp = typer.Typer(help="Show a user's account details; run / files inspect that user.")
|
|
372
|
+
run_grp = typer.Typer(help="Inspect the user's analyses (read-only).")
|
|
373
|
+
files_grp = typer.Typer(help="Inspect the user's files (read-only).")
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _email_from_ctx(ctx: typer.Context) -> Optional[str]:
|
|
377
|
+
cur: Optional[click.Context] = ctx
|
|
378
|
+
while cur is not None:
|
|
379
|
+
obj = cur.obj
|
|
380
|
+
if isinstance(obj, dict) and obj.get("email"):
|
|
381
|
+
return str(obj["email"])
|
|
382
|
+
cur = cur.parent
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _resolve_email(ctx: typer.Context, email: Optional[str]) -> str:
|
|
387
|
+
resolved = (email or _email_from_ctx(ctx) or "").strip()
|
|
388
|
+
if not resolved:
|
|
389
|
+
console.print("[red]Provide --email / -e for the target user.[/red]")
|
|
390
|
+
raise typer.Exit(1)
|
|
391
|
+
return resolved
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _email_opt() -> Any:
|
|
395
|
+
return typer.Option(None, "--email", "-e", help="Target user's email")
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
@user_info_grp.callback(invoke_without_command=True)
|
|
399
|
+
def user_info(
|
|
400
|
+
ctx: typer.Context,
|
|
401
|
+
email: Optional[str] = typer.Option(None, "--email", "-e", help="Target user's email"),
|
|
402
|
+
):
|
|
403
|
+
"""Show a user's account details and quota."""
|
|
404
|
+
ctx.ensure_object(dict)
|
|
405
|
+
if email:
|
|
406
|
+
ctx.obj["email"] = email.strip()
|
|
407
|
+
if ctx.invoked_subcommand is not None:
|
|
408
|
+
return
|
|
409
|
+
if not email:
|
|
410
|
+
email = typer.prompt("Email")
|
|
411
|
+
ctx.obj["email"] = email.strip()
|
|
412
|
+
_print_user_info(email)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@run_grp.callback(invoke_without_command=True)
|
|
416
|
+
def _admin_run_callback(ctx: typer.Context) -> None:
|
|
417
|
+
ctx.ensure_object(dict)
|
|
418
|
+
parent_email = _email_from_ctx(ctx)
|
|
419
|
+
if parent_email:
|
|
420
|
+
ctx.obj["email"] = parent_email
|
|
421
|
+
if ctx.invoked_subcommand is None:
|
|
422
|
+
typer.echo(ctx.get_help())
|
|
423
|
+
raise typer.Exit(0)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@files_grp.callback(invoke_without_command=True)
|
|
427
|
+
def _admin_files_callback(ctx: typer.Context) -> None:
|
|
428
|
+
ctx.ensure_object(dict)
|
|
429
|
+
parent_email = _email_from_ctx(ctx)
|
|
430
|
+
if parent_email:
|
|
431
|
+
ctx.obj["email"] = parent_email
|
|
432
|
+
if ctx.invoked_subcommand is None:
|
|
433
|
+
typer.echo(ctx.get_help())
|
|
434
|
+
raise typer.Exit(0)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@run_grp.command("list", cls=_BareDefaultCommand)
|
|
438
|
+
def admin_run_list(
|
|
439
|
+
ctx: typer.Context,
|
|
440
|
+
email: Optional[str] = _email_opt(),
|
|
441
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Max number of analyses to show"),
|
|
442
|
+
after: Optional[str] = typer.Option(None, "--after", help="Show analyses after date (YYYY-MM-DD)"),
|
|
443
|
+
before: Optional[str] = typer.Option(None, "--before", help="Show analyses before date (YYYY-MM-DD)"),
|
|
444
|
+
week: Optional[str] = typer.Option(
|
|
445
|
+
None, "--week", "-w",
|
|
446
|
+
help="Week filter: 0=this week; 1-52=ISO week; negative=-k; W1-W2=range.",
|
|
447
|
+
),
|
|
448
|
+
month: Optional[str] = typer.Option(
|
|
449
|
+
None, "--month", "-m",
|
|
450
|
+
help="Month filter: 0=this month; 1-12; negative=-k; M-N=range.",
|
|
451
|
+
),
|
|
452
|
+
year: Optional[str] = typer.Option(
|
|
453
|
+
None, "--year", "-y",
|
|
454
|
+
help="Year filter: 0=this year; positive=absolute year; negative=-k; Y1-Y2=range.",
|
|
455
|
+
),
|
|
456
|
+
all_: bool = typer.Option(False, "--all", "-a", help="Show all analyses (no date filter)"),
|
|
457
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
458
|
+
):
|
|
459
|
+
"""List the user's analyses.
|
|
460
|
+
|
|
461
|
+
Same flags as ``ops run list``. Example: ops admin user-info run list -e alice@test.com
|
|
462
|
+
"""
|
|
463
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
464
|
+
run_list(
|
|
465
|
+
limit=limit,
|
|
466
|
+
after=after,
|
|
467
|
+
before=before,
|
|
468
|
+
week=week,
|
|
469
|
+
month=month,
|
|
470
|
+
year=year,
|
|
471
|
+
all_=all_,
|
|
472
|
+
as_json=as_json,
|
|
473
|
+
)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@run_grp.command("status")
|
|
477
|
+
def admin_run_status(
|
|
478
|
+
ctx: typer.Context,
|
|
479
|
+
id: Optional[str] = typer.Argument(None, help="Analysis ID (omit to list running analyses)"),
|
|
480
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
481
|
+
email: Optional[str] = _email_opt(),
|
|
482
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
483
|
+
):
|
|
484
|
+
"""Show the user's analysis or task status."""
|
|
485
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
486
|
+
run_status(id=id, range_=range_, as_json=as_json)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@run_grp.command("output")
|
|
490
|
+
def admin_run_output(
|
|
491
|
+
ctx: typer.Context,
|
|
492
|
+
id: str = typer.Argument(..., help="Analysis ID"),
|
|
493
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
494
|
+
email: Optional[str] = _email_opt(),
|
|
495
|
+
):
|
|
496
|
+
"""Show the user's printed output (stdout)."""
|
|
497
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
498
|
+
run_output(id=id, range_=range_)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@run_grp.command("stats")
|
|
502
|
+
def admin_run_stats(
|
|
503
|
+
ctx: typer.Context,
|
|
504
|
+
id: str = typer.Argument(..., help="Analysis ID"),
|
|
505
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
506
|
+
email: Optional[str] = _email_opt(),
|
|
507
|
+
):
|
|
508
|
+
"""Show the user's task execution stats."""
|
|
509
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
510
|
+
run_stats(id=id, range_=range_)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@run_grp.command("data")
|
|
514
|
+
def admin_run_data(
|
|
515
|
+
ctx: typer.Context,
|
|
516
|
+
id: str = typer.Argument(..., help="Analysis ID"),
|
|
517
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
518
|
+
email: Optional[str] = _email_opt(),
|
|
519
|
+
download: Optional[str] = typer.Option(
|
|
520
|
+
None, "--download", "-d", metavar="PATH",
|
|
521
|
+
help="Download mode (omit to list only). Same as ``ops run data -d``.",
|
|
522
|
+
),
|
|
523
|
+
output_dir: str = typer.Option(
|
|
524
|
+
".", "--output", "-o",
|
|
525
|
+
help="Directory to write downloaded files.",
|
|
526
|
+
),
|
|
527
|
+
):
|
|
528
|
+
"""List or download the user's task data files."""
|
|
529
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
530
|
+
run_data(id=id, range_=range_, download=download, output_dir=output_dir)
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@files_grp.command("list")
|
|
534
|
+
def admin_files_list(
|
|
535
|
+
ctx: typer.Context,
|
|
536
|
+
folder: Optional[str] = typer.Argument(
|
|
537
|
+
None,
|
|
538
|
+
help="Only list files in this folder (and its subfolders)",
|
|
539
|
+
),
|
|
540
|
+
email: Optional[str] = _email_opt(),
|
|
541
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="Emit JSON instead of a table"),
|
|
542
|
+
):
|
|
543
|
+
"""List the user's files."""
|
|
544
|
+
email = _resolve_email(ctx, email)
|
|
545
|
+
console.print(f"[dim]Files for {email}[/dim]")
|
|
546
|
+
with api.acting_as(email):
|
|
547
|
+
files_list(folder=folder, as_json=as_json)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@files_grp.command("download")
|
|
551
|
+
def admin_files_download(
|
|
552
|
+
ctx: typer.Context,
|
|
553
|
+
remote_paths: List[str] = typer.Argument(..., help="Remote file path(s) or glob pattern(s)"),
|
|
554
|
+
email: Optional[str] = _email_opt(),
|
|
555
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Local output path"),
|
|
556
|
+
):
|
|
557
|
+
"""Download file(s) from the user's file system."""
|
|
558
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
559
|
+
files_download(remote_paths=remote_paths, output=output)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
user_info_grp.add_typer(run_grp, name="run")
|
|
563
|
+
user_info_grp.add_typer(files_grp, name="files")
|
|
564
|
+
app.add_typer(user_info_grp, name="user-info")
|
|
565
|
+
|
|
566
|
+
|
|
@@ -4,7 +4,9 @@ Handles auth headers and automatic token refresh when the server reports an
|
|
|
4
4
|
expired session (HTTP 401, or HTTP 400 with code ``auth_error`` — e.g. /auth/me).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
from
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from typing import Any, Dict, Iterator, Optional
|
|
8
10
|
|
|
9
11
|
import httpx
|
|
10
12
|
|
|
@@ -22,6 +24,7 @@ TIMEOUT = 30.0
|
|
|
22
24
|
# Seconds before access-token expiry to refresh proactively (when expires_at is known).
|
|
23
25
|
_REFRESH_BUFFER_SEC = 300
|
|
24
26
|
_session_ok: Optional[bool] = None
|
|
27
|
+
_as_user: ContextVar[Optional[str]] = ContextVar("as_user", default=None)
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
class ApiError(Exception):
|
|
@@ -32,6 +35,25 @@ class ApiError(Exception):
|
|
|
32
35
|
super().__init__(message)
|
|
33
36
|
|
|
34
37
|
|
|
38
|
+
@contextmanager
|
|
39
|
+
def acting_as(email: str) -> Iterator[None]:
|
|
40
|
+
"""Send ``as_user=<email>`` on subsequent GET requests (admin impersonation)."""
|
|
41
|
+
token = _as_user.set((email or "").strip().lower() or None)
|
|
42
|
+
try:
|
|
43
|
+
yield
|
|
44
|
+
finally:
|
|
45
|
+
_as_user.reset(token)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _merge_as_user(params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
49
|
+
email = _as_user.get()
|
|
50
|
+
if not email:
|
|
51
|
+
return params
|
|
52
|
+
merged = dict(params or {})
|
|
53
|
+
merged["as_user"] = email
|
|
54
|
+
return merged
|
|
55
|
+
|
|
56
|
+
|
|
35
57
|
def invalidate_session_cache() -> None:
|
|
36
58
|
global _session_ok
|
|
37
59
|
_session_ok = None
|
|
@@ -164,6 +186,9 @@ def _headers(token: Optional[str] = None) -> Dict[str, str]:
|
|
|
164
186
|
h["Authorization"] = f"Bearer {id_tok}"
|
|
165
187
|
if access_tok:
|
|
166
188
|
h["X-Access-Token"] = access_tok
|
|
189
|
+
as_user = _as_user.get()
|
|
190
|
+
if as_user:
|
|
191
|
+
h["X-As-User"] = as_user
|
|
167
192
|
return h
|
|
168
193
|
|
|
169
194
|
|
|
@@ -288,13 +313,21 @@ def post(path: str, body: Dict[str, Any], auth: bool = True) -> Dict[str, Any]:
|
|
|
288
313
|
|
|
289
314
|
def get(path: str, params: Optional[Dict[str, Any]] = None, auth: bool = True) -> Dict[str, Any]:
|
|
290
315
|
hdrs = _headers() if auth else _headers(token="")
|
|
316
|
+
params = _merge_as_user(params)
|
|
291
317
|
resp = httpx.get(_url(path), params=params, headers=hdrs, timeout=TIMEOUT)
|
|
292
318
|
if auth and _needs_auth_refresh(resp):
|
|
293
319
|
if _try_refresh():
|
|
294
320
|
resp = httpx.get(_url(path), params=params, headers=_headers(), timeout=TIMEOUT)
|
|
295
321
|
else:
|
|
296
322
|
_auth_failure()
|
|
297
|
-
|
|
323
|
+
data = _parse(resp)
|
|
324
|
+
expected = _as_user.get()
|
|
325
|
+
if expected and str(data.get("as_user") or "").strip().lower() != expected:
|
|
326
|
+
raise ApiError(
|
|
327
|
+
"Server did not switch to the target user. Deploy the API (./deploy-api.sh) and try again.",
|
|
328
|
+
status=resp.status_code,
|
|
329
|
+
)
|
|
330
|
+
return data
|
|
298
331
|
|
|
299
332
|
|
|
300
333
|
def upload_file(presigned_url: str, file_bytes: bytes, content_type: str = "text/x-python") -> None:
|
|
@@ -140,7 +140,7 @@ def _print_help() -> None:
|
|
|
140
140
|
console.print()
|
|
141
141
|
console.print("[bold]ops admin[/bold]\n")
|
|
142
142
|
console.print(" [bold]list-users[/bold] List all users")
|
|
143
|
-
console.print(" [bold]user-info[/bold]
|
|
143
|
+
console.print(" [bold]user-info[/bold] Account details; run / files inspect that user")
|
|
144
144
|
console.print(" [bold]set-quota[/bold] Update a user's quota")
|
|
145
145
|
console.print(" [bold]enable-user[/bold] Re-enable a user")
|
|
146
146
|
console.print(" [bold]disable-user[/bold] Disable a user")
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Tests for ops admin subcommands — one test per command, using admin creds."""
|
|
2
|
+
|
|
3
|
+
from unittest.mock import patch
|
|
4
|
+
|
|
5
|
+
from opensees_cli.main import app
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestListUsers:
|
|
9
|
+
def test_list_users(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
10
|
+
mock_router.add("GET", "/prod/admin/users", json={
|
|
11
|
+
"users": [
|
|
12
|
+
{
|
|
13
|
+
"email": "alice@test.com",
|
|
14
|
+
"status": "CONFIRMED",
|
|
15
|
+
"enabled": True,
|
|
16
|
+
"is_admin": False,
|
|
17
|
+
"last_login_at": "2025-01-01T00:00:00Z",
|
|
18
|
+
"last_activity_at": "",
|
|
19
|
+
"openseespy_version": "3.7.1.0",
|
|
20
|
+
},
|
|
21
|
+
]
|
|
22
|
+
})
|
|
23
|
+
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
24
|
+
assert result.exit_code == 0
|
|
25
|
+
assert "alice@te" in result.output
|
|
26
|
+
assert "3.7.1.0" in result.output
|
|
27
|
+
assert "CLI Version" not in result.output
|
|
28
|
+
assert "Pinned" in result.output
|
|
29
|
+
|
|
30
|
+
def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
31
|
+
mock_router.add("GET", "/prod/admin/users", json={"users": []})
|
|
32
|
+
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
33
|
+
assert result.exit_code == 0
|
|
34
|
+
assert "No users" in result.output
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TestDisableUser:
|
|
38
|
+
def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
39
|
+
mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
|
|
40
|
+
result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
|
|
41
|
+
assert result.exit_code == 0
|
|
42
|
+
assert "disabled" in result.output.lower()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class TestEnableUser:
|
|
46
|
+
def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
47
|
+
mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
|
|
48
|
+
result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
|
|
49
|
+
assert result.exit_code == 0
|
|
50
|
+
assert "enabled" in result.output.lower()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class TestDeleteUser:
|
|
54
|
+
def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
55
|
+
mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
|
|
56
|
+
result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
|
|
57
|
+
assert result.exit_code == 0
|
|
58
|
+
assert "deleted" in result.output.lower()
|
|
59
|
+
|
|
60
|
+
def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
61
|
+
result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
|
|
62
|
+
assert result.exit_code == 0
|
|
63
|
+
assert "Cancelled" in result.output
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestUserInfo:
|
|
67
|
+
def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
68
|
+
mock_router.add("POST", "/prod/admin/user-info", json={
|
|
69
|
+
"email": "alice@test.com",
|
|
70
|
+
"is_admin": False,
|
|
71
|
+
"is_enabled": True,
|
|
72
|
+
"openseespy_version": "3.7.1.0",
|
|
73
|
+
"last_cli_version": "0.2.2",
|
|
74
|
+
"quota": {
|
|
75
|
+
"max_concurrent_runs": 5,
|
|
76
|
+
"max_tasks_per_analysis": 100,
|
|
77
|
+
"max_monthly_runtime": 3600,
|
|
78
|
+
"monthly_runtime_used": 0,
|
|
79
|
+
"monthly_runtime_remaining": 3600,
|
|
80
|
+
"max_monthly_storage": 1073741824,
|
|
81
|
+
"storage_used": 0,
|
|
82
|
+
},
|
|
83
|
+
"analyses_total": 2,
|
|
84
|
+
"analyses_running": 1,
|
|
85
|
+
"analyses": [
|
|
86
|
+
{
|
|
87
|
+
"analysis_id": "aaa11111-0000-0000-0000-000000000000",
|
|
88
|
+
"status": "running",
|
|
89
|
+
"filename": "Truss.py",
|
|
90
|
+
"type": "fast",
|
|
91
|
+
"total_tasks": 1,
|
|
92
|
+
"created_at": "2026-09-01T00:00:00Z",
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
"files_total": 1,
|
|
96
|
+
"files_size": 128,
|
|
97
|
+
"files": [
|
|
98
|
+
{"path": "Truss.py", "size": 128, "last_modified": "2026-09-01T00:00:00Z"},
|
|
99
|
+
],
|
|
100
|
+
})
|
|
101
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
|
|
102
|
+
assert result.exit_code == 0
|
|
103
|
+
assert "alice@test.com" in result.output
|
|
104
|
+
assert "3.7.1.0" in result.output
|
|
105
|
+
assert "pinned" in result.output.lower()
|
|
106
|
+
assert "CLI version" not in result.output
|
|
107
|
+
assert "Analyses" not in result.output
|
|
108
|
+
assert "Files" not in result.output
|
|
109
|
+
|
|
110
|
+
def test_user_info_not_pinned(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
111
|
+
mock_router.add("POST", "/prod/admin/user-info", json={
|
|
112
|
+
"email": "bob@test.com",
|
|
113
|
+
"is_admin": False,
|
|
114
|
+
"is_enabled": True,
|
|
115
|
+
"openseespy_version": None,
|
|
116
|
+
})
|
|
117
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "-e", "bob@test.com"])
|
|
118
|
+
assert result.exit_code == 0
|
|
119
|
+
assert "not pinned" in result.output.lower()
|
|
120
|
+
assert "No analyses" not in result.output
|
|
121
|
+
assert "No files" not in result.output
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TestSetQuota:
|
|
125
|
+
def test_set_quota_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
126
|
+
mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
|
|
127
|
+
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com", "-r", "2h"])
|
|
128
|
+
assert result.exit_code == 0
|
|
129
|
+
assert "updated" in result.output.lower()
|
|
130
|
+
|
|
131
|
+
def test_set_quota_no_flags(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
132
|
+
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com"])
|
|
133
|
+
assert result.exit_code == 0
|
|
134
|
+
assert "Provide at least one" in result.output
|
|
135
|
+
|
|
136
|
+
def test_set_quota_invalid_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
137
|
+
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-r", "abc"])
|
|
138
|
+
assert result.exit_code == 1
|
|
139
|
+
assert "Invalid" in result.output
|
|
140
|
+
|
|
141
|
+
def test_set_quota_storage(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
142
|
+
mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
|
|
143
|
+
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-s", "10mb"])
|
|
144
|
+
assert result.exit_code == 0
|
|
145
|
+
assert "updated" in result.output.lower()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _query_of(url: str) -> dict:
|
|
149
|
+
from urllib.parse import parse_qs, urlparse
|
|
150
|
+
return {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_calls(mock_router, path_suffix: str) -> list:
|
|
154
|
+
return [
|
|
155
|
+
c for c in mock_router.calls
|
|
156
|
+
if c["method"] == "GET" and path_suffix in c["url"].split("?")[0]
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestAdminRun:
|
|
161
|
+
def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
162
|
+
result = cli_runner.invoke(app, ["admin", "run", "list", "-e", "alice@test.com"])
|
|
163
|
+
assert result.exit_code != 0
|
|
164
|
+
|
|
165
|
+
def test_help_lists_read_commands(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
166
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "--help"])
|
|
167
|
+
assert result.exit_code == 0
|
|
168
|
+
assert "run" in result.output
|
|
169
|
+
assert "files" in result.output
|
|
170
|
+
|
|
171
|
+
def test_run_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
172
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "run", "--help"])
|
|
173
|
+
assert result.exit_code == 0
|
|
174
|
+
assert "list" in result.output
|
|
175
|
+
assert "status" in result.output
|
|
176
|
+
assert "submit" not in result.output
|
|
177
|
+
assert "cancel" not in result.output
|
|
178
|
+
|
|
179
|
+
def test_submit_not_a_command(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
180
|
+
result = cli_runner.invoke(
|
|
181
|
+
app, ["admin", "user-info", "run", "submit", "-e", "alice@test.com", "model.py"]
|
|
182
|
+
)
|
|
183
|
+
assert result.exit_code != 0
|
|
184
|
+
|
|
185
|
+
def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
186
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
|
|
187
|
+
result = cli_runner.invoke(
|
|
188
|
+
app, ["admin", "user-info", "run", "list", "-e", "alice@test.com", "--all"]
|
|
189
|
+
)
|
|
190
|
+
assert result.exit_code == 0
|
|
191
|
+
calls = _get_calls(mock_router, "/prod/run/list")
|
|
192
|
+
assert calls
|
|
193
|
+
assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
|
|
194
|
+
|
|
195
|
+
def test_list_parent_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
196
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
|
|
197
|
+
result = cli_runner.invoke(
|
|
198
|
+
app, ["admin", "user-info", "-e", "alice@test.com", "run", "list", "--all"]
|
|
199
|
+
)
|
|
200
|
+
assert result.exit_code == 0
|
|
201
|
+
calls = _get_calls(mock_router, "/prod/run/list")
|
|
202
|
+
assert calls
|
|
203
|
+
assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
|
|
204
|
+
|
|
205
|
+
def test_list_requires_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
206
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "run", "list"])
|
|
207
|
+
assert result.exit_code != 0
|
|
208
|
+
|
|
209
|
+
def test_status_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
210
|
+
mock_router.add("GET", "/prod/run/status", json={
|
|
211
|
+
"task_id": "ttt55555",
|
|
212
|
+
"analysis_id": "aaa55555",
|
|
213
|
+
"status": "completed",
|
|
214
|
+
"filename": "model.py",
|
|
215
|
+
"type": "fast",
|
|
216
|
+
"total_tasks": 1,
|
|
217
|
+
"as_user": "alice@test.com",
|
|
218
|
+
})
|
|
219
|
+
result = cli_runner.invoke(
|
|
220
|
+
app, ["admin", "user-info", "run", "status", "aaa55555", "-e", "alice@test.com"]
|
|
221
|
+
)
|
|
222
|
+
assert result.exit_code == 0
|
|
223
|
+
calls = _get_calls(mock_router, "/prod/run/status")
|
|
224
|
+
assert calls
|
|
225
|
+
q = _query_of(calls[0]["url"])
|
|
226
|
+
assert q["as_user"] == "alice@test.com"
|
|
227
|
+
assert q["id"] == "aaa55555"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class TestAdminFiles:
|
|
231
|
+
def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
232
|
+
result = cli_runner.invoke(app, ["admin", "files", "list", "-e", "alice@test.com"])
|
|
233
|
+
assert result.exit_code != 0
|
|
234
|
+
|
|
235
|
+
def test_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
236
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "files", "--help"])
|
|
237
|
+
assert result.exit_code == 0
|
|
238
|
+
assert "list" in result.output
|
|
239
|
+
assert "download" in result.output
|
|
240
|
+
assert "upload" not in result.output
|
|
241
|
+
assert "delete" not in result.output
|
|
242
|
+
|
|
243
|
+
def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
244
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
245
|
+
"files": [{"path": "model.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
|
|
246
|
+
"as_user": "alice@test.com",
|
|
247
|
+
})
|
|
248
|
+
result = cli_runner.invoke(
|
|
249
|
+
app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
|
|
250
|
+
)
|
|
251
|
+
assert result.exit_code == 0
|
|
252
|
+
assert "model.py" in result.output
|
|
253
|
+
calls = _get_calls(mock_router, "/prod/files/list")
|
|
254
|
+
assert calls
|
|
255
|
+
assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
|
|
256
|
+
assert "Files for alice@test.com" in result.output
|
|
257
|
+
|
|
258
|
+
def test_list_old_api_does_not_show_caller_files(
|
|
259
|
+
self, cli_runner, admin_creds, patch_httpx, mock_router
|
|
260
|
+
):
|
|
261
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
262
|
+
"files": [{"path": "mine.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
|
|
263
|
+
})
|
|
264
|
+
result = cli_runner.invoke(
|
|
265
|
+
app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
|
|
266
|
+
)
|
|
267
|
+
assert result.exit_code == 1
|
|
268
|
+
assert "mine.py" not in result.output
|
|
269
|
+
assert "Deploy the API" in result.output
|
|
270
|
+
|
|
271
|
+
def test_download_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router, tmp_path):
|
|
272
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
273
|
+
"files": [{"path": "model.py", "size": 5, "last_modified": "2026-01-01T00:00:00Z"}],
|
|
274
|
+
"as_user": "alice@test.com",
|
|
275
|
+
})
|
|
276
|
+
mock_router.add("GET", "/prod/files/download", json={
|
|
277
|
+
"download_url": "https://s3.example.com/presigned-get?sig=abc",
|
|
278
|
+
"as_user": "alice@test.com",
|
|
279
|
+
})
|
|
280
|
+
mock_router.add_any_url("GET", status=200, content=b"hello")
|
|
281
|
+
out = tmp_path / "model.py"
|
|
282
|
+
result = cli_runner.invoke(
|
|
283
|
+
app,
|
|
284
|
+
["admin", "user-info", "files", "download", "model.py", "-e", "alice@test.com", "-o", str(out)],
|
|
285
|
+
)
|
|
286
|
+
assert result.exit_code == 0
|
|
287
|
+
list_calls = _get_calls(mock_router, "/prod/files/list")
|
|
288
|
+
dl_calls = _get_calls(mock_router, "/prod/files/download")
|
|
289
|
+
assert list_calls and dl_calls
|
|
290
|
+
assert _query_of(list_calls[0]["url"])["as_user"] == "alice@test.com"
|
|
291
|
+
assert _query_of(dl_calls[0]["url"])["as_user"] == "alice@test.com"
|
|
@@ -8,6 +8,7 @@ import pytest
|
|
|
8
8
|
from opensees_cli.api import (
|
|
9
9
|
ApiError,
|
|
10
10
|
_parse,
|
|
11
|
+
acting_as,
|
|
11
12
|
post,
|
|
12
13
|
get,
|
|
13
14
|
upload_file,
|
|
@@ -248,3 +249,24 @@ class TestFetchS3Range:
|
|
|
248
249
|
data, complete = fetch_s3_range("https://s3.example.com/stdout?sig=abc")
|
|
249
250
|
assert data == b""
|
|
250
251
|
assert complete is False
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class TestActingAs:
|
|
255
|
+
def test_get_includes_as_user(self, logged_in_creds, patch_httpx, mock_router):
|
|
256
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
|
|
257
|
+
with acting_as("Alice@Test.com"):
|
|
258
|
+
get("/run/list")
|
|
259
|
+
urls = [c["url"] for c in mock_router.calls if "/prod/run/list" in c["url"]]
|
|
260
|
+
assert urls
|
|
261
|
+
assert "as_user=alice%40test.com" in urls[0]
|
|
262
|
+
|
|
263
|
+
def test_get_clears_after_context(self, logged_in_creds, patch_httpx, mock_router):
|
|
264
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
|
|
265
|
+
with acting_as("alice@test.com"):
|
|
266
|
+
get("/run/list")
|
|
267
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": []})
|
|
268
|
+
get("/run/list")
|
|
269
|
+
urls = [c["url"] for c in mock_router.calls if "/prod/run/list" in c["url"]]
|
|
270
|
+
assert len(urls) == 2
|
|
271
|
+
assert "as_user" in urls[0]
|
|
272
|
+
assert "as_user" not in urls[1]
|
|
@@ -15,6 +15,10 @@ class TestFilesList:
|
|
|
15
15
|
assert result.exit_code == 0
|
|
16
16
|
assert result.output == ""
|
|
17
17
|
|
|
18
|
+
def test_list_rejects_user_flag(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
|
|
19
|
+
result = cli_runner.invoke(app, ["files", "list", "--user", "alice@test.com"])
|
|
20
|
+
assert result.exit_code != 0
|
|
21
|
+
|
|
18
22
|
def test_list_with_files(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
|
|
19
23
|
mock_router.add("GET", "/prod/files/list", json={
|
|
20
24
|
"files": [
|
|
@@ -342,3 +342,16 @@ class TestList:
|
|
|
342
342
|
result = cli_runner.invoke(app, ["run", "list"])
|
|
343
343
|
assert result.exit_code == 0
|
|
344
344
|
assert "No analyses" in result.output
|
|
345
|
+
|
|
346
|
+
def test_list_rejects_user_flag(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
|
|
347
|
+
result = cli_runner.invoke(app, ["run", "list", "--user", "alice@test.com"])
|
|
348
|
+
assert result.exit_code != 0
|
|
349
|
+
assert "as_user" not in " ".join(c["url"] for c in mock_router.calls)
|
|
350
|
+
|
|
351
|
+
def test_list_does_not_send_as_user(self, cli_runner, logged_in_creds, patch_httpx, mock_router):
|
|
352
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": []})
|
|
353
|
+
result = cli_runner.invoke(app, ["run", "list", "--all"])
|
|
354
|
+
assert result.exit_code == 0
|
|
355
|
+
urls = [c["url"] for c in mock_router.calls if "/prod/run/list" in c["url"]]
|
|
356
|
+
assert urls
|
|
357
|
+
assert "as_user" not in urls[0]
|
|
@@ -1,150 +0,0 @@
|
|
|
1
|
-
"""Tests for ops admin subcommands — one test per command, using admin creds."""
|
|
2
|
-
|
|
3
|
-
from unittest.mock import patch
|
|
4
|
-
|
|
5
|
-
from opensees_cli.main import app
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
class TestListUsers:
|
|
9
|
-
def test_list_users(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
10
|
-
mock_router.add("GET", "/prod/admin/users", json={
|
|
11
|
-
"users": [
|
|
12
|
-
{
|
|
13
|
-
"email": "alice@test.com",
|
|
14
|
-
"status": "CONFIRMED",
|
|
15
|
-
"enabled": True,
|
|
16
|
-
"is_admin": False,
|
|
17
|
-
"last_login_at": "2025-01-01T00:00:00Z",
|
|
18
|
-
"last_activity_at": "",
|
|
19
|
-
"openseespy_version": "3.7.1.0",
|
|
20
|
-
},
|
|
21
|
-
]
|
|
22
|
-
})
|
|
23
|
-
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
24
|
-
assert result.exit_code == 0
|
|
25
|
-
assert "alice@te" in result.output
|
|
26
|
-
assert "3.7.1.0" in result.output
|
|
27
|
-
assert "CLI Version" not in result.output
|
|
28
|
-
assert "Pinned" in result.output
|
|
29
|
-
|
|
30
|
-
def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
31
|
-
mock_router.add("GET", "/prod/admin/users", json={"users": []})
|
|
32
|
-
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
33
|
-
assert result.exit_code == 0
|
|
34
|
-
assert "No users" in result.output
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
class TestDisableUser:
|
|
38
|
-
def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
39
|
-
mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
|
|
40
|
-
result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
|
|
41
|
-
assert result.exit_code == 0
|
|
42
|
-
assert "disabled" in result.output.lower()
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class TestEnableUser:
|
|
46
|
-
def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
47
|
-
mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
|
|
48
|
-
result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
|
|
49
|
-
assert result.exit_code == 0
|
|
50
|
-
assert "enabled" in result.output.lower()
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
class TestDeleteUser:
|
|
54
|
-
def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
55
|
-
mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
|
|
56
|
-
result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
|
|
57
|
-
assert result.exit_code == 0
|
|
58
|
-
assert "deleted" in result.output.lower()
|
|
59
|
-
|
|
60
|
-
def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
61
|
-
result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
|
|
62
|
-
assert result.exit_code == 0
|
|
63
|
-
assert "Cancelled" in result.output
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
class TestUserInfo:
|
|
67
|
-
def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
68
|
-
mock_router.add("POST", "/prod/admin/user-info", json={
|
|
69
|
-
"email": "alice@test.com",
|
|
70
|
-
"is_admin": False,
|
|
71
|
-
"is_enabled": True,
|
|
72
|
-
"openseespy_version": "3.7.1.0",
|
|
73
|
-
"quota": {
|
|
74
|
-
"max_concurrent_runs": 5,
|
|
75
|
-
"max_tasks_per_analysis": 100,
|
|
76
|
-
"max_monthly_runtime": 3600,
|
|
77
|
-
"monthly_runtime_used": 0,
|
|
78
|
-
"monthly_runtime_remaining": 3600,
|
|
79
|
-
"max_monthly_storage": 1073741824,
|
|
80
|
-
"storage_used": 0,
|
|
81
|
-
},
|
|
82
|
-
"analyses_total": 2,
|
|
83
|
-
"analyses_running": 1,
|
|
84
|
-
"analyses": [
|
|
85
|
-
{
|
|
86
|
-
"analysis_id": "aaa11111-0000-0000-0000-000000000000",
|
|
87
|
-
"status": "running",
|
|
88
|
-
"filename": "Truss.py",
|
|
89
|
-
"type": "fast",
|
|
90
|
-
"total_tasks": 1,
|
|
91
|
-
"created_at": "2026-09-01T00:00:00Z",
|
|
92
|
-
},
|
|
93
|
-
],
|
|
94
|
-
"files_total": 1,
|
|
95
|
-
"files_size": 128,
|
|
96
|
-
"files": [
|
|
97
|
-
{"path": "Truss.py", "size": 128, "last_modified": "2026-09-01T00:00:00Z"},
|
|
98
|
-
],
|
|
99
|
-
})
|
|
100
|
-
result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
|
|
101
|
-
assert result.exit_code == 0
|
|
102
|
-
assert "alice@test.com" in result.output
|
|
103
|
-
assert "3.7.1.0" in result.output
|
|
104
|
-
assert "pinned" in result.output.lower()
|
|
105
|
-
assert "Truss.py" in result.output
|
|
106
|
-
assert "Analyses" in result.output
|
|
107
|
-
assert "Files" in result.output
|
|
108
|
-
|
|
109
|
-
def test_user_info_not_pinned(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
110
|
-
mock_router.add("POST", "/prod/admin/user-info", json={
|
|
111
|
-
"email": "bob@test.com",
|
|
112
|
-
"is_admin": False,
|
|
113
|
-
"is_enabled": True,
|
|
114
|
-
"openseespy_version": None,
|
|
115
|
-
"analyses_total": 0,
|
|
116
|
-
"analyses_running": 0,
|
|
117
|
-
"analyses": [],
|
|
118
|
-
"files_total": 0,
|
|
119
|
-
"files_size": 0,
|
|
120
|
-
"files": [],
|
|
121
|
-
})
|
|
122
|
-
result = cli_runner.invoke(app, ["admin", "user-info", "-e", "bob@test.com"])
|
|
123
|
-
assert result.exit_code == 0
|
|
124
|
-
assert "not pinned" in result.output.lower()
|
|
125
|
-
assert "No analyses" in result.output
|
|
126
|
-
assert "No files" in result.output
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
class TestSetQuota:
|
|
130
|
-
def test_set_quota_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
131
|
-
mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
|
|
132
|
-
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com", "-r", "2h"])
|
|
133
|
-
assert result.exit_code == 0
|
|
134
|
-
assert "updated" in result.output.lower()
|
|
135
|
-
|
|
136
|
-
def test_set_quota_no_flags(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
137
|
-
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "alice@test.com"])
|
|
138
|
-
assert result.exit_code == 0
|
|
139
|
-
assert "Provide at least one" in result.output
|
|
140
|
-
|
|
141
|
-
def test_set_quota_invalid_runtime(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
142
|
-
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-r", "abc"])
|
|
143
|
-
assert result.exit_code == 1
|
|
144
|
-
assert "Invalid" in result.output
|
|
145
|
-
|
|
146
|
-
def test_set_quota_storage(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
147
|
-
mock_router.add("POST", "/prod/admin/set-quota", json={"message": "Quota updated."})
|
|
148
|
-
result = cli_runner.invoke(app, ["admin", "set-quota", "-e", "a@t.com", "-s", "10mb"])
|
|
149
|
-
assert result.exit_code == 0
|
|
150
|
-
assert "updated" in result.output.lower()
|
|
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
|
|
File without changes
|
|
File without changes
|