opensees-cli 0.2.2__tar.gz → 0.2.4__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.4}/PKG-INFO +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/pyproject.toml +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/__init__.py +1 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/admin.py +239 -69
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/api.py +35 -2
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/auth.py +52 -2
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/config.py +31 -7
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/main.py +5 -1
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/PKG-INFO +1 -1
- opensees_cli-0.2.4/tests/test_admin_commands.py +296 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_api.py +22 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_auth_commands.py +21 -4
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_config.py +13 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_files_commands.py +4 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/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.4}/README.md +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/setup.cfg +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/__main__.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/files.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/path_setup.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/run.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli/version.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/SOURCES.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/dependency_links.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/entry_points.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/requires.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/src/opensees_cli.egg-info/top_level.txt +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/tests/test_main_commands.py +0 -0
- {opensees_cli-0.2.2 → opensees_cli-0.2.4}/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
|
|
@@ -10,12 +11,29 @@ from rich.console import Console
|
|
|
10
11
|
from rich.table import Table
|
|
11
12
|
from typer.core import TyperGroup
|
|
12
13
|
|
|
13
|
-
from opensees_cli import api, config
|
|
14
|
-
from opensees_cli.auth import _to_local,
|
|
14
|
+
from opensees_cli import __version__, api, config
|
|
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
|
|
|
18
27
|
|
|
28
|
+
def _cli_cell(raw: Optional[str], current: str) -> str:
|
|
29
|
+
v = (raw or "").strip()
|
|
30
|
+
if not v:
|
|
31
|
+
return "-"
|
|
32
|
+
if config.is_older_version(v, current):
|
|
33
|
+
return f"[yellow]{v}[/yellow]"
|
|
34
|
+
return v
|
|
35
|
+
|
|
36
|
+
|
|
19
37
|
def _parse_runtime_quota(raw: str) -> int:
|
|
20
38
|
"""Parse -r: plain digits = seconds; or duration tokens like 2h, 10m, 1h30m, 90s."""
|
|
21
39
|
s = raw.strip().lower().replace(" ", "")
|
|
@@ -106,14 +124,17 @@ def list_users():
|
|
|
106
124
|
console.print("[dim]No users found.[/dim]")
|
|
107
125
|
return
|
|
108
126
|
|
|
109
|
-
|
|
110
|
-
table
|
|
127
|
+
current_cli = __version__
|
|
128
|
+
table = Table(title=f"Users ({len(users)}) · current CLI {current_cli}")
|
|
129
|
+
# Always show the full email (never ellipsize / crop).
|
|
130
|
+
table.add_column("Email", style="cyan", overflow="ignore", no_wrap=True)
|
|
111
131
|
table.add_column("Status")
|
|
112
132
|
table.add_column("Enabled")
|
|
113
133
|
table.add_column("Admin")
|
|
114
134
|
table.add_column("Last Login")
|
|
115
135
|
table.add_column("Last Activity")
|
|
116
|
-
table.add_column("
|
|
136
|
+
table.add_column("CLI", overflow="ignore", no_wrap=True)
|
|
137
|
+
table.add_column("Pinned", overflow="ignore", no_wrap=True)
|
|
117
138
|
|
|
118
139
|
for u in users:
|
|
119
140
|
enabled = "[green]yes[/green]" if u.get("enabled") else "[red]no[/red]"
|
|
@@ -125,6 +146,7 @@ def list_users():
|
|
|
125
146
|
admin,
|
|
126
147
|
u.get("last_login_at", "") or "-",
|
|
127
148
|
u.get("last_activity_at", "") or "-",
|
|
149
|
+
_cli_cell(u.get("last_cli_version"), current_cli),
|
|
128
150
|
u.get("openseespy_version", "") or "-",
|
|
129
151
|
)
|
|
130
152
|
console.print(table)
|
|
@@ -175,11 +197,7 @@ def delete_user(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
175
197
|
raise typer.Exit(1)
|
|
176
198
|
|
|
177
199
|
|
|
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")
|
|
200
|
+
def _print_user_info(email: str) -> None:
|
|
183
201
|
try:
|
|
184
202
|
r = api.post("/admin/user-info", {"email": email})
|
|
185
203
|
except api.ApiError as e:
|
|
@@ -200,8 +218,14 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
200
218
|
console.print(f" OpenSeesPy: {pin} (pinned)")
|
|
201
219
|
else:
|
|
202
220
|
console.print(" OpenSeesPy: [dim]-[/dim] (not pinned)")
|
|
203
|
-
|
|
204
|
-
|
|
221
|
+
cli_ver = (r.get("last_cli_version") or "").strip()
|
|
222
|
+
if cli_ver:
|
|
223
|
+
if config.is_older_version(cli_ver, __version__):
|
|
224
|
+
console.print(f" CLI: [yellow]{cli_ver}[/yellow] (behind {__version__})")
|
|
225
|
+
else:
|
|
226
|
+
console.print(f" CLI: {cli_ver}")
|
|
227
|
+
else:
|
|
228
|
+
console.print(" CLI: [dim]-[/dim]")
|
|
205
229
|
if r.get("last_ip"):
|
|
206
230
|
console.print(f" Last IP: {r['last_ip']}")
|
|
207
231
|
|
|
@@ -210,62 +234,6 @@ def user_info(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
210
234
|
console.print()
|
|
211
235
|
print_quota(q)
|
|
212
236
|
|
|
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
237
|
|
|
270
238
|
@app.command("set-quota")
|
|
271
239
|
def set_quota(
|
|
@@ -415,3 +383,205 @@ def workers(
|
|
|
415
383
|
_to_local(t["started_at"]) if t.get("started_at") else "-",
|
|
416
384
|
)
|
|
417
385
|
console.print(table)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# ---------------------------------------------------------------------------
|
|
389
|
+
# user-info: account details, plus read-only run / files inspect
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
user_info_grp = typer.Typer(help="Show a user's account details; run / files inspect that user.")
|
|
393
|
+
run_grp = typer.Typer(help="Inspect the user's analyses (read-only).")
|
|
394
|
+
files_grp = typer.Typer(help="Inspect the user's files (read-only).")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _email_from_ctx(ctx: typer.Context) -> Optional[str]:
|
|
398
|
+
cur: Optional[click.Context] = ctx
|
|
399
|
+
while cur is not None:
|
|
400
|
+
obj = cur.obj
|
|
401
|
+
if isinstance(obj, dict) and obj.get("email"):
|
|
402
|
+
return str(obj["email"])
|
|
403
|
+
cur = cur.parent
|
|
404
|
+
return None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_email(ctx: typer.Context, email: Optional[str]) -> str:
|
|
408
|
+
resolved = (email or _email_from_ctx(ctx) or "").strip()
|
|
409
|
+
if not resolved:
|
|
410
|
+
console.print("[red]Provide --email / -e for the target user.[/red]")
|
|
411
|
+
raise typer.Exit(1)
|
|
412
|
+
return resolved
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _email_opt() -> Any:
|
|
416
|
+
return typer.Option(None, "--email", "-e", help="Target user's email")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@user_info_grp.callback(invoke_without_command=True)
|
|
420
|
+
def user_info(
|
|
421
|
+
ctx: typer.Context,
|
|
422
|
+
email: Optional[str] = typer.Option(None, "--email", "-e", help="Target user's email"),
|
|
423
|
+
):
|
|
424
|
+
"""Show a user's account details and quota."""
|
|
425
|
+
ctx.ensure_object(dict)
|
|
426
|
+
if email:
|
|
427
|
+
ctx.obj["email"] = email.strip()
|
|
428
|
+
if ctx.invoked_subcommand is not None:
|
|
429
|
+
return
|
|
430
|
+
if not email:
|
|
431
|
+
email = typer.prompt("Email")
|
|
432
|
+
ctx.obj["email"] = email.strip()
|
|
433
|
+
_print_user_info(email)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@run_grp.callback(invoke_without_command=True)
|
|
437
|
+
def _admin_run_callback(ctx: typer.Context) -> None:
|
|
438
|
+
ctx.ensure_object(dict)
|
|
439
|
+
parent_email = _email_from_ctx(ctx)
|
|
440
|
+
if parent_email:
|
|
441
|
+
ctx.obj["email"] = parent_email
|
|
442
|
+
if ctx.invoked_subcommand is None:
|
|
443
|
+
typer.echo(ctx.get_help())
|
|
444
|
+
raise typer.Exit(0)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@files_grp.callback(invoke_without_command=True)
|
|
448
|
+
def _admin_files_callback(ctx: typer.Context) -> None:
|
|
449
|
+
ctx.ensure_object(dict)
|
|
450
|
+
parent_email = _email_from_ctx(ctx)
|
|
451
|
+
if parent_email:
|
|
452
|
+
ctx.obj["email"] = parent_email
|
|
453
|
+
if ctx.invoked_subcommand is None:
|
|
454
|
+
typer.echo(ctx.get_help())
|
|
455
|
+
raise typer.Exit(0)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@run_grp.command("list", cls=_BareDefaultCommand)
|
|
459
|
+
def admin_run_list(
|
|
460
|
+
ctx: typer.Context,
|
|
461
|
+
email: Optional[str] = _email_opt(),
|
|
462
|
+
limit: int = typer.Option(20, "--limit", "-n", help="Max number of analyses to show"),
|
|
463
|
+
after: Optional[str] = typer.Option(None, "--after", help="Show analyses after date (YYYY-MM-DD)"),
|
|
464
|
+
before: Optional[str] = typer.Option(None, "--before", help="Show analyses before date (YYYY-MM-DD)"),
|
|
465
|
+
week: Optional[str] = typer.Option(
|
|
466
|
+
None, "--week", "-w",
|
|
467
|
+
help="Week filter: 0=this week; 1-52=ISO week; negative=-k; W1-W2=range.",
|
|
468
|
+
),
|
|
469
|
+
month: Optional[str] = typer.Option(
|
|
470
|
+
None, "--month", "-m",
|
|
471
|
+
help="Month filter: 0=this month; 1-12; negative=-k; M-N=range.",
|
|
472
|
+
),
|
|
473
|
+
year: Optional[str] = typer.Option(
|
|
474
|
+
None, "--year", "-y",
|
|
475
|
+
help="Year filter: 0=this year; positive=absolute year; negative=-k; Y1-Y2=range.",
|
|
476
|
+
),
|
|
477
|
+
all_: bool = typer.Option(False, "--all", "-a", help="Show all analyses (no date filter)"),
|
|
478
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
479
|
+
):
|
|
480
|
+
"""List the user's analyses.
|
|
481
|
+
|
|
482
|
+
Same flags as ``ops run list``. Example: ops admin user-info run list -e alice@test.com
|
|
483
|
+
"""
|
|
484
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
485
|
+
run_list(
|
|
486
|
+
limit=limit,
|
|
487
|
+
after=after,
|
|
488
|
+
before=before,
|
|
489
|
+
week=week,
|
|
490
|
+
month=month,
|
|
491
|
+
year=year,
|
|
492
|
+
all_=all_,
|
|
493
|
+
as_json=as_json,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@run_grp.command("status")
|
|
498
|
+
def admin_run_status(
|
|
499
|
+
ctx: typer.Context,
|
|
500
|
+
id: Optional[str] = typer.Argument(None, help="Analysis ID (omit to list running analyses)"),
|
|
501
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
502
|
+
email: Optional[str] = _email_opt(),
|
|
503
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="Output as JSON"),
|
|
504
|
+
):
|
|
505
|
+
"""Show the user's analysis or task status."""
|
|
506
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
507
|
+
run_status(id=id, range_=range_, as_json=as_json)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@run_grp.command("output")
|
|
511
|
+
def admin_run_output(
|
|
512
|
+
ctx: typer.Context,
|
|
513
|
+
id: str = typer.Argument(..., help="Analysis ID"),
|
|
514
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
515
|
+
email: Optional[str] = _email_opt(),
|
|
516
|
+
):
|
|
517
|
+
"""Show the user's printed output (stdout)."""
|
|
518
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
519
|
+
run_output(id=id, range_=range_)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@run_grp.command("stats")
|
|
523
|
+
def admin_run_stats(
|
|
524
|
+
ctx: typer.Context,
|
|
525
|
+
id: str = typer.Argument(..., help="Analysis ID"),
|
|
526
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
527
|
+
email: Optional[str] = _email_opt(),
|
|
528
|
+
):
|
|
529
|
+
"""Show the user's task execution stats."""
|
|
530
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
531
|
+
run_stats(id=id, range_=range_)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@run_grp.command("data")
|
|
535
|
+
def admin_run_data(
|
|
536
|
+
ctx: typer.Context,
|
|
537
|
+
id: str = typer.Argument(..., help="Analysis ID"),
|
|
538
|
+
range_: Optional[str] = typer.Argument(None, help="Task range: index, start-end, or 'all'"),
|
|
539
|
+
email: Optional[str] = _email_opt(),
|
|
540
|
+
download: Optional[str] = typer.Option(
|
|
541
|
+
None, "--download", "-d", metavar="PATH",
|
|
542
|
+
help="Download mode (omit to list only). Same as ``ops run data -d``.",
|
|
543
|
+
),
|
|
544
|
+
output_dir: str = typer.Option(
|
|
545
|
+
".", "--output", "-o",
|
|
546
|
+
help="Directory to write downloaded files.",
|
|
547
|
+
),
|
|
548
|
+
):
|
|
549
|
+
"""List or download the user's task data files."""
|
|
550
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
551
|
+
run_data(id=id, range_=range_, download=download, output_dir=output_dir)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
@files_grp.command("list")
|
|
555
|
+
def admin_files_list(
|
|
556
|
+
ctx: typer.Context,
|
|
557
|
+
folder: Optional[str] = typer.Argument(
|
|
558
|
+
None,
|
|
559
|
+
help="Only list files in this folder (and its subfolders)",
|
|
560
|
+
),
|
|
561
|
+
email: Optional[str] = _email_opt(),
|
|
562
|
+
as_json: bool = typer.Option(False, "--json", "-j", help="Emit JSON instead of a table"),
|
|
563
|
+
):
|
|
564
|
+
"""List the user's files."""
|
|
565
|
+
email = _resolve_email(ctx, email)
|
|
566
|
+
console.print(f"[dim]Files for {email}[/dim]")
|
|
567
|
+
with api.acting_as(email):
|
|
568
|
+
files_list(folder=folder, as_json=as_json)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
@files_grp.command("download")
|
|
572
|
+
def admin_files_download(
|
|
573
|
+
ctx: typer.Context,
|
|
574
|
+
remote_paths: List[str] = typer.Argument(..., help="Remote file path(s) or glob pattern(s)"),
|
|
575
|
+
email: Optional[str] = _email_opt(),
|
|
576
|
+
output: Optional[Path] = typer.Option(None, "--output", "-o", help="Local output path"),
|
|
577
|
+
):
|
|
578
|
+
"""Download file(s) from the user's file system."""
|
|
579
|
+
with api.acting_as(_resolve_email(ctx, email)):
|
|
580
|
+
files_download(remote_paths=remote_paths, output=output)
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
user_info_grp.add_typer(run_grp, name="run")
|
|
584
|
+
user_info_grp.add_typer(files_grp, name="files")
|
|
585
|
+
app.add_typer(user_info_grp, name="user-info")
|
|
586
|
+
|
|
587
|
+
|
|
@@ -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:
|
|
@@ -2,15 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
|
+
import time
|
|
5
6
|
from typing import Any, Dict, Optional, Sequence
|
|
6
7
|
|
|
8
|
+
import httpx
|
|
7
9
|
import typer
|
|
8
10
|
from rich.console import Console
|
|
9
11
|
|
|
10
|
-
from opensees_cli import api, config
|
|
12
|
+
from opensees_cli import __version__, api, config
|
|
11
13
|
|
|
12
14
|
console = Console()
|
|
13
15
|
|
|
16
|
+
_PYPI_CLI_URL = "https://pypi.org/pypi/opensees_cli/json"
|
|
17
|
+
_PYPI_CACHE_TTL_SEC = 24 * 3600
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def maybe_print_cli_upgrade(*, latest: Optional[str] = None) -> None:
|
|
21
|
+
"""If a newer opensees_cli is on PyPI, print a one-line pip upgrade hint."""
|
|
22
|
+
newest = (latest or _pypi_latest_cli_version() or "").strip()
|
|
23
|
+
if not newest or not config.is_older_version(__version__, newest):
|
|
24
|
+
return
|
|
25
|
+
console.print(
|
|
26
|
+
f"[yellow]A newer opensees_cli is available ({newest}; you have {__version__}).[/yellow]"
|
|
27
|
+
)
|
|
28
|
+
console.print("[yellow]Upgrade: pip install -U opensees_cli[/yellow]")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pypi_latest_cli_version() -> Optional[str]:
|
|
32
|
+
"""Latest PyPI version, cached ~24h. Best-effort; never raises."""
|
|
33
|
+
if "pytest" in sys.modules:
|
|
34
|
+
return None
|
|
35
|
+
cache_path = config.CONFIG_DIR / "pypi_cli_latest.json"
|
|
36
|
+
now = time.time()
|
|
37
|
+
try:
|
|
38
|
+
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
39
|
+
ver = str(cached.get("version") or "").strip()
|
|
40
|
+
checked = float(cached.get("checked_at") or 0)
|
|
41
|
+
if ver and now - checked < _PYPI_CACHE_TTL_SEC:
|
|
42
|
+
return ver
|
|
43
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError):
|
|
44
|
+
pass
|
|
45
|
+
try:
|
|
46
|
+
r = httpx.get(_PYPI_CLI_URL, timeout=2.0)
|
|
47
|
+
if r.status_code != 200:
|
|
48
|
+
return None
|
|
49
|
+
ver = str((r.json().get("info") or {}).get("version") or "").strip()
|
|
50
|
+
except Exception:
|
|
51
|
+
return None
|
|
52
|
+
if not ver:
|
|
53
|
+
return None
|
|
54
|
+
try:
|
|
55
|
+
config._ensure_dir()
|
|
56
|
+
cache_path.write_text(
|
|
57
|
+
json.dumps({"version": ver, "checked_at": now}),
|
|
58
|
+
encoding="utf-8",
|
|
59
|
+
)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
return ver
|
|
63
|
+
|
|
14
64
|
_CONFIRM_CLI_HINT = (
|
|
15
65
|
"[dim]Use [bold]ops auth confirm[/bold] to confirm the verification code.[/dim]"
|
|
16
66
|
)
|
|
@@ -457,7 +507,6 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
457
507
|
password = _ask_password()
|
|
458
508
|
|
|
459
509
|
try:
|
|
460
|
-
from opensees_cli import __version__
|
|
461
510
|
r = api.post(
|
|
462
511
|
"/auth/login",
|
|
463
512
|
{
|
|
@@ -478,6 +527,7 @@ def login(email: Optional[str] = typer.Option(None, "--email", "-e")):
|
|
|
478
527
|
)
|
|
479
528
|
api.invalidate_session_cache()
|
|
480
529
|
console.print(f"[green]Logged in as [bold]{email}[/bold][/green]")
|
|
530
|
+
maybe_print_cli_upgrade()
|
|
481
531
|
except api.ApiError as e:
|
|
482
532
|
console.print(f"[red]{e.message}[/red]")
|
|
483
533
|
raise typer.Exit(1)
|
|
@@ -14,7 +14,7 @@ import re
|
|
|
14
14
|
import sys
|
|
15
15
|
import time
|
|
16
16
|
from pathlib import Path
|
|
17
|
-
from typing import Any, Dict, Optional
|
|
17
|
+
from typing import Any, Dict, Optional, Tuple
|
|
18
18
|
|
|
19
19
|
CONFIG_DIR = Path.home() / ".opensees"
|
|
20
20
|
CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
|
|
@@ -168,26 +168,50 @@ def get_api_url() -> str:
|
|
|
168
168
|
# OpenSeesPy version (sticky local preference)
|
|
169
169
|
# ---------------------------------------------------------------------------
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
def _openseespy_version_file() -> Path:
|
|
172
|
+
return CONFIG_DIR / "openseespy_version"
|
|
172
173
|
|
|
173
174
|
|
|
174
175
|
def set_openseespy_version(version: str) -> None:
|
|
175
176
|
"""Save the sticky OpenSeesPy version preference."""
|
|
176
177
|
_ensure_dir()
|
|
177
|
-
|
|
178
|
+
_openseespy_version_file().write_text(version.strip())
|
|
178
179
|
|
|
179
180
|
|
|
180
181
|
def get_openseespy_version() -> Optional[str]:
|
|
181
182
|
"""Return the sticky OpenSeesPy version, or None if not set."""
|
|
182
|
-
|
|
183
|
+
path = _openseespy_version_file()
|
|
184
|
+
if not path.exists():
|
|
183
185
|
return None
|
|
184
186
|
try:
|
|
185
|
-
return
|
|
187
|
+
return path.read_text().strip() or None
|
|
186
188
|
except OSError:
|
|
187
189
|
return None
|
|
188
190
|
|
|
189
191
|
|
|
190
192
|
def clear_openseespy_version() -> None:
|
|
191
193
|
"""Clear the sticky OpenSeesPy version preference."""
|
|
192
|
-
|
|
193
|
-
|
|
194
|
+
path = _openseespy_version_file()
|
|
195
|
+
if path.exists():
|
|
196
|
+
path.unlink()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def version_tuple(raw: str) -> Tuple[int, ...]:
|
|
200
|
+
"""Numeric dotted version parts, or empty if none (e.g. ``web/1.0.0`` still parses)."""
|
|
201
|
+
parts: list[int] = []
|
|
202
|
+
for token in re.findall(r"\d+", (raw or "").strip()):
|
|
203
|
+
parts.append(int(token))
|
|
204
|
+
if len(parts) >= 4:
|
|
205
|
+
break
|
|
206
|
+
return tuple(parts)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def is_older_version(installed: str, latest: str) -> bool:
|
|
210
|
+
"""True when *installed* is a lower dotted version than *latest*."""
|
|
211
|
+
a, b = version_tuple(installed), version_tuple(latest)
|
|
212
|
+
if not a or not b:
|
|
213
|
+
return False
|
|
214
|
+
n = max(len(a), len(b))
|
|
215
|
+
a = a + (0,) * (n - len(a))
|
|
216
|
+
b = b + (0,) * (n - len(b))
|
|
217
|
+
return a < b
|
|
@@ -8,6 +8,7 @@ from opensees_cli import __version__
|
|
|
8
8
|
from opensees_cli.auth import (
|
|
9
9
|
app as auth_app,
|
|
10
10
|
format_status_json,
|
|
11
|
+
maybe_print_cli_upgrade,
|
|
11
12
|
print_me,
|
|
12
13
|
print_quota,
|
|
13
14
|
)
|
|
@@ -140,7 +141,7 @@ def _print_help() -> None:
|
|
|
140
141
|
console.print()
|
|
141
142
|
console.print("[bold]ops admin[/bold]\n")
|
|
142
143
|
console.print(" [bold]list-users[/bold] List all users")
|
|
143
|
-
console.print(" [bold]user-info[/bold]
|
|
144
|
+
console.print(" [bold]user-info[/bold] Account details; run / files inspect that user")
|
|
144
145
|
console.print(" [bold]set-quota[/bold] Update a user's quota")
|
|
145
146
|
console.print(" [bold]enable-user[/bold] Re-enable a user")
|
|
146
147
|
console.print(" [bold]disable-user[/bold] Disable a user")
|
|
@@ -161,6 +162,7 @@ def _print_help() -> None:
|
|
|
161
162
|
|
|
162
163
|
console.print()
|
|
163
164
|
console.print("[dim]Run any command with --help for more details.[/dim]")
|
|
165
|
+
maybe_print_cli_upgrade()
|
|
164
166
|
|
|
165
167
|
|
|
166
168
|
@app.callback(invoke_without_command=True)
|
|
@@ -221,7 +223,9 @@ def status(
|
|
|
221
223
|
q = r.get("quota")
|
|
222
224
|
if not q:
|
|
223
225
|
console.print("[dim]No quota info available.[/dim]")
|
|
226
|
+
maybe_print_cli_upgrade()
|
|
224
227
|
return
|
|
225
228
|
console.print()
|
|
226
229
|
print_quota(q)
|
|
230
|
+
maybe_print_cli_upgrade()
|
|
227
231
|
|
|
@@ -0,0 +1,296 @@
|
|
|
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
|
+
"last_cli_version": "0.2.1",
|
|
20
|
+
"openseespy_version": "3.7.1.0",
|
|
21
|
+
},
|
|
22
|
+
]
|
|
23
|
+
})
|
|
24
|
+
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
25
|
+
assert result.exit_code == 0
|
|
26
|
+
assert "alice@test.com" in result.output
|
|
27
|
+
assert "3.7.1.0" in result.output
|
|
28
|
+
assert "0.2.1" in result.output
|
|
29
|
+
assert "CLI" in result.output
|
|
30
|
+
assert "Pinned" in result.output
|
|
31
|
+
from opensees_cli import __version__
|
|
32
|
+
assert f"current CLI {__version__}" in result.output
|
|
33
|
+
|
|
34
|
+
def test_list_users_empty(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
35
|
+
mock_router.add("GET", "/prod/admin/users", json={"users": []})
|
|
36
|
+
result = cli_runner.invoke(app, ["admin", "list-users"])
|
|
37
|
+
assert result.exit_code == 0
|
|
38
|
+
assert "No users" in result.output
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestDisableUser:
|
|
42
|
+
def test_disable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
43
|
+
mock_router.add("POST", "/prod/admin/disable-user", json={"message": "User disabled."})
|
|
44
|
+
result = cli_runner.invoke(app, ["admin", "disable-user", "-e", "alice@test.com"])
|
|
45
|
+
assert result.exit_code == 0
|
|
46
|
+
assert "disabled" in result.output.lower()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TestEnableUser:
|
|
50
|
+
def test_enable_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
51
|
+
mock_router.add("POST", "/prod/admin/enable-user", json={"message": "User enabled."})
|
|
52
|
+
result = cli_runner.invoke(app, ["admin", "enable-user", "-e", "alice@test.com"])
|
|
53
|
+
assert result.exit_code == 0
|
|
54
|
+
assert "enabled" in result.output.lower()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class TestDeleteUser:
|
|
58
|
+
def test_delete_user_confirmed(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
59
|
+
mock_router.add("POST", "/prod/admin/delete-user", json={"message": "User deleted."})
|
|
60
|
+
result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="y\n")
|
|
61
|
+
assert result.exit_code == 0
|
|
62
|
+
assert "deleted" in result.output.lower()
|
|
63
|
+
|
|
64
|
+
def test_delete_user_cancelled(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
65
|
+
result = cli_runner.invoke(app, ["admin", "delete-user", "-e", "alice@test.com"], input="n\n")
|
|
66
|
+
assert result.exit_code == 0
|
|
67
|
+
assert "Cancelled" in result.output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TestUserInfo:
|
|
71
|
+
def test_user_info(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
72
|
+
mock_router.add("POST", "/prod/admin/user-info", json={
|
|
73
|
+
"email": "alice@test.com",
|
|
74
|
+
"is_admin": False,
|
|
75
|
+
"is_enabled": True,
|
|
76
|
+
"openseespy_version": "3.7.1.0",
|
|
77
|
+
"last_cli_version": "0.2.2",
|
|
78
|
+
"quota": {
|
|
79
|
+
"max_concurrent_runs": 5,
|
|
80
|
+
"max_tasks_per_analysis": 100,
|
|
81
|
+
"max_monthly_runtime": 3600,
|
|
82
|
+
"monthly_runtime_used": 0,
|
|
83
|
+
"monthly_runtime_remaining": 3600,
|
|
84
|
+
"max_monthly_storage": 1073741824,
|
|
85
|
+
"storage_used": 0,
|
|
86
|
+
},
|
|
87
|
+
"analyses_total": 2,
|
|
88
|
+
"analyses_running": 1,
|
|
89
|
+
"analyses": [
|
|
90
|
+
{
|
|
91
|
+
"analysis_id": "aaa11111-0000-0000-0000-000000000000",
|
|
92
|
+
"status": "running",
|
|
93
|
+
"filename": "Truss.py",
|
|
94
|
+
"type": "fast",
|
|
95
|
+
"total_tasks": 1,
|
|
96
|
+
"created_at": "2026-09-01T00:00:00Z",
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
"files_total": 1,
|
|
100
|
+
"files_size": 128,
|
|
101
|
+
"files": [
|
|
102
|
+
{"path": "Truss.py", "size": 128, "last_modified": "2026-09-01T00:00:00Z"},
|
|
103
|
+
],
|
|
104
|
+
})
|
|
105
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "-e", "alice@test.com"])
|
|
106
|
+
assert result.exit_code == 0
|
|
107
|
+
assert "alice@test.com" in result.output
|
|
108
|
+
assert "3.7.1.0" in result.output
|
|
109
|
+
assert "pinned" in result.output.lower()
|
|
110
|
+
assert "0.2.2" in result.output
|
|
111
|
+
assert "CLI" in result.output
|
|
112
|
+
assert "Analyses" not in result.output
|
|
113
|
+
assert "Files" not in result.output
|
|
114
|
+
|
|
115
|
+
def test_user_info_not_pinned(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
116
|
+
mock_router.add("POST", "/prod/admin/user-info", json={
|
|
117
|
+
"email": "bob@test.com",
|
|
118
|
+
"is_admin": False,
|
|
119
|
+
"is_enabled": True,
|
|
120
|
+
"openseespy_version": None,
|
|
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" not in result.output
|
|
126
|
+
assert "No files" not 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()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _query_of(url: str) -> dict:
|
|
154
|
+
from urllib.parse import parse_qs, urlparse
|
|
155
|
+
return {k: v[0] for k, v in parse_qs(urlparse(url).query).items()}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _get_calls(mock_router, path_suffix: str) -> list:
|
|
159
|
+
return [
|
|
160
|
+
c for c in mock_router.calls
|
|
161
|
+
if c["method"] == "GET" and path_suffix in c["url"].split("?")[0]
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TestAdminRun:
|
|
166
|
+
def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
167
|
+
result = cli_runner.invoke(app, ["admin", "run", "list", "-e", "alice@test.com"])
|
|
168
|
+
assert result.exit_code != 0
|
|
169
|
+
|
|
170
|
+
def test_help_lists_read_commands(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
171
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "--help"])
|
|
172
|
+
assert result.exit_code == 0
|
|
173
|
+
assert "run" in result.output
|
|
174
|
+
assert "files" in result.output
|
|
175
|
+
|
|
176
|
+
def test_run_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
177
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "run", "--help"])
|
|
178
|
+
assert result.exit_code == 0
|
|
179
|
+
assert "list" in result.output
|
|
180
|
+
assert "status" in result.output
|
|
181
|
+
assert "submit" not in result.output
|
|
182
|
+
assert "cancel" not in result.output
|
|
183
|
+
|
|
184
|
+
def test_submit_not_a_command(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
185
|
+
result = cli_runner.invoke(
|
|
186
|
+
app, ["admin", "user-info", "run", "submit", "-e", "alice@test.com", "model.py"]
|
|
187
|
+
)
|
|
188
|
+
assert result.exit_code != 0
|
|
189
|
+
|
|
190
|
+
def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
191
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
|
|
192
|
+
result = cli_runner.invoke(
|
|
193
|
+
app, ["admin", "user-info", "run", "list", "-e", "alice@test.com", "--all"]
|
|
194
|
+
)
|
|
195
|
+
assert result.exit_code == 0
|
|
196
|
+
calls = _get_calls(mock_router, "/prod/run/list")
|
|
197
|
+
assert calls
|
|
198
|
+
assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
|
|
199
|
+
|
|
200
|
+
def test_list_parent_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
201
|
+
mock_router.add("GET", "/prod/run/list", json={"analyses": [], "as_user": "alice@test.com"})
|
|
202
|
+
result = cli_runner.invoke(
|
|
203
|
+
app, ["admin", "user-info", "-e", "alice@test.com", "run", "list", "--all"]
|
|
204
|
+
)
|
|
205
|
+
assert result.exit_code == 0
|
|
206
|
+
calls = _get_calls(mock_router, "/prod/run/list")
|
|
207
|
+
assert calls
|
|
208
|
+
assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
|
|
209
|
+
|
|
210
|
+
def test_list_requires_email(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
211
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "run", "list"])
|
|
212
|
+
assert result.exit_code != 0
|
|
213
|
+
|
|
214
|
+
def test_status_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
215
|
+
mock_router.add("GET", "/prod/run/status", json={
|
|
216
|
+
"task_id": "ttt55555",
|
|
217
|
+
"analysis_id": "aaa55555",
|
|
218
|
+
"status": "completed",
|
|
219
|
+
"filename": "model.py",
|
|
220
|
+
"type": "fast",
|
|
221
|
+
"total_tasks": 1,
|
|
222
|
+
"as_user": "alice@test.com",
|
|
223
|
+
})
|
|
224
|
+
result = cli_runner.invoke(
|
|
225
|
+
app, ["admin", "user-info", "run", "status", "aaa55555", "-e", "alice@test.com"]
|
|
226
|
+
)
|
|
227
|
+
assert result.exit_code == 0
|
|
228
|
+
calls = _get_calls(mock_router, "/prod/run/status")
|
|
229
|
+
assert calls
|
|
230
|
+
q = _query_of(calls[0]["url"])
|
|
231
|
+
assert q["as_user"] == "alice@test.com"
|
|
232
|
+
assert q["id"] == "aaa55555"
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestAdminFiles:
|
|
236
|
+
def test_not_top_level(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
237
|
+
result = cli_runner.invoke(app, ["admin", "files", "list", "-e", "alice@test.com"])
|
|
238
|
+
assert result.exit_code != 0
|
|
239
|
+
|
|
240
|
+
def test_help_is_read_only(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
241
|
+
result = cli_runner.invoke(app, ["admin", "user-info", "files", "--help"])
|
|
242
|
+
assert result.exit_code == 0
|
|
243
|
+
assert "list" in result.output
|
|
244
|
+
assert "download" in result.output
|
|
245
|
+
assert "upload" not in result.output
|
|
246
|
+
assert "delete" not in result.output
|
|
247
|
+
|
|
248
|
+
def test_list_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router):
|
|
249
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
250
|
+
"files": [{"path": "model.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
|
|
251
|
+
"as_user": "alice@test.com",
|
|
252
|
+
})
|
|
253
|
+
result = cli_runner.invoke(
|
|
254
|
+
app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
|
|
255
|
+
)
|
|
256
|
+
assert result.exit_code == 0
|
|
257
|
+
assert "model.py" in result.output
|
|
258
|
+
calls = _get_calls(mock_router, "/prod/files/list")
|
|
259
|
+
assert calls
|
|
260
|
+
assert _query_of(calls[0]["url"])["as_user"] == "alice@test.com"
|
|
261
|
+
assert "Files for alice@test.com" in result.output
|
|
262
|
+
|
|
263
|
+
def test_list_old_api_does_not_show_caller_files(
|
|
264
|
+
self, cli_runner, admin_creds, patch_httpx, mock_router
|
|
265
|
+
):
|
|
266
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
267
|
+
"files": [{"path": "mine.py", "size": 10, "last_modified": "2026-01-01T00:00:00Z"}],
|
|
268
|
+
})
|
|
269
|
+
result = cli_runner.invoke(
|
|
270
|
+
app, ["admin", "user-info", "files", "list", "-e", "alice@test.com"]
|
|
271
|
+
)
|
|
272
|
+
assert result.exit_code == 1
|
|
273
|
+
assert "mine.py" not in result.output
|
|
274
|
+
assert "Deploy the API" in result.output
|
|
275
|
+
|
|
276
|
+
def test_download_sends_as_user(self, cli_runner, admin_creds, patch_httpx, mock_router, tmp_path):
|
|
277
|
+
mock_router.add("GET", "/prod/files/list", json={
|
|
278
|
+
"files": [{"path": "model.py", "size": 5, "last_modified": "2026-01-01T00:00:00Z"}],
|
|
279
|
+
"as_user": "alice@test.com",
|
|
280
|
+
})
|
|
281
|
+
mock_router.add("GET", "/prod/files/download", json={
|
|
282
|
+
"download_url": "https://s3.example.com/presigned-get?sig=abc",
|
|
283
|
+
"as_user": "alice@test.com",
|
|
284
|
+
})
|
|
285
|
+
mock_router.add_any_url("GET", status=200, content=b"hello")
|
|
286
|
+
out = tmp_path / "model.py"
|
|
287
|
+
result = cli_runner.invoke(
|
|
288
|
+
app,
|
|
289
|
+
["admin", "user-info", "files", "download", "model.py", "-e", "alice@test.com", "-o", str(out)],
|
|
290
|
+
)
|
|
291
|
+
assert result.exit_code == 0
|
|
292
|
+
list_calls = _get_calls(mock_router, "/prod/files/list")
|
|
293
|
+
dl_calls = _get_calls(mock_router, "/prod/files/download")
|
|
294
|
+
assert list_calls and dl_calls
|
|
295
|
+
assert _query_of(list_calls[0]["url"])["as_user"] == "alice@test.com"
|
|
296
|
+
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]
|
|
@@ -5,6 +5,7 @@ from unittest.mock import patch
|
|
|
5
5
|
|
|
6
6
|
from opensees_cli.auth import app as auth_app
|
|
7
7
|
from opensees_cli.main import app
|
|
8
|
+
from opensees_cli.auth import maybe_print_cli_upgrade
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
def _patch_password(*passwords):
|
|
@@ -93,12 +94,15 @@ class TestLogin:
|
|
|
93
94
|
assert result.exit_code == 0
|
|
94
95
|
assert "Logged in" in result.output
|
|
95
96
|
|
|
96
|
-
def
|
|
97
|
-
mock_router.add("POST", "/prod/auth/login", status=400, json={
|
|
98
|
-
|
|
97
|
+
def test_login_stale_cli(self, cli_runner, patch_httpx, mock_router):
|
|
98
|
+
mock_router.add("POST", "/prod/auth/login", status=400, json={
|
|
99
|
+
"error": "This CLI (0.2.3) is out of date. Upgrade with: pip install -U opensees_cli",
|
|
100
|
+
"code": "auth_error",
|
|
101
|
+
})
|
|
102
|
+
with patch("opensees_cli.auth._ask_password", return_value="Pass1!"):
|
|
99
103
|
result = cli_runner.invoke(app, ["auth", "login", "-e", "u@t.com"])
|
|
100
104
|
assert result.exit_code == 1
|
|
101
|
-
assert "
|
|
105
|
+
assert "pip install -U opensees_cli" in result.output
|
|
102
106
|
|
|
103
107
|
|
|
104
108
|
class TestLogout:
|
|
@@ -165,3 +169,16 @@ class TestChangePassword:
|
|
|
165
169
|
result = cli_runner.invoke(app, ["auth", "change-password"])
|
|
166
170
|
assert result.exit_code == 1
|
|
167
171
|
assert "do not match" in result.output
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TestCliUpgradeNag:
|
|
175
|
+
def test_prints_when_behind(self, capsys):
|
|
176
|
+
maybe_print_cli_upgrade(latest="99.0.0")
|
|
177
|
+
out = capsys.readouterr().out
|
|
178
|
+
assert "pip install -U opensees_cli" in out
|
|
179
|
+
assert "99.0.0" in out
|
|
180
|
+
|
|
181
|
+
def test_silent_when_current(self, capsys):
|
|
182
|
+
from opensees_cli import __version__
|
|
183
|
+
maybe_print_cli_upgrade(latest=__version__)
|
|
184
|
+
assert capsys.readouterr().out == ""
|
|
@@ -8,6 +8,8 @@ from opensees_cli.config import (
|
|
|
8
8
|
get_refresh_token,
|
|
9
9
|
get_api_url,
|
|
10
10
|
is_admin,
|
|
11
|
+
is_older_version,
|
|
12
|
+
version_tuple,
|
|
11
13
|
)
|
|
12
14
|
|
|
13
15
|
|
|
@@ -82,3 +84,14 @@ class TestApiUrl:
|
|
|
82
84
|
def test_env_override(self, monkeypatch):
|
|
83
85
|
monkeypatch.setenv("OPENSEES_API_URL", "https://custom.example/v1")
|
|
84
86
|
assert get_api_url() == "https://custom.example/v1"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestVersionCompare:
|
|
90
|
+
def test_older(self):
|
|
91
|
+
assert is_older_version("0.2.1", "0.2.3")
|
|
92
|
+
assert not is_older_version("0.2.3", "0.2.3")
|
|
93
|
+
assert not is_older_version("0.2.4", "0.2.3")
|
|
94
|
+
|
|
95
|
+
def test_non_numeric_prefix(self):
|
|
96
|
+
assert version_tuple("web/1.0.0") == (1, 0, 0)
|
|
97
|
+
assert not is_older_version("web/1.0.0", "0.2.3")
|
|
@@ -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
|