podojo-cli 1.30.0__tar.gz → 1.32.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/CHANGELOG.md +23 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/PKG-INFO +6 -1
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/README.md +5 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/pyproject.toml +1 -1
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/aiinterviews.py +3 -2
- podojo_cli-1.32.0/src/podojo_cli/commands/transcripts.py +105 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/usertests.py +130 -3
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/videos.py +3 -2
- podojo_cli-1.32.0/src/podojo_cli/tables.py +13 -0
- podojo_cli-1.32.0/tests/test_transcripts.py +97 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_usertests.py +253 -0
- podojo_cli-1.32.0/tests/test_videos.py +28 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/uv.lock +1 -1
- podojo_cli-1.30.0/src/podojo_cli/commands/transcripts.py +0 -58
- podojo_cli-1.30.0/tests/test_transcripts.py +0 -30
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/.github/workflows/publish.yml +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/.gitignore +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/CLAUDE.md +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/LICENSE +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/__init__.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/client.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/__init__.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/auth.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/interviews.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/projects.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/showreel.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/commands/synth.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/config.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/main.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/synth/__init__.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/synth/driver.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/synth/session.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/version_check.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/video/__init__.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/src/podojo_cli/video/showreel.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/conftest.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_aiinterviews.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_auth.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_interviews.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_projects.py +0 -0
- {podojo_cli-1.30.0 → podojo_cli-1.32.0}/tests/test_showreel.py +0 -0
|
@@ -5,6 +5,29 @@ All notable changes to the Podojo CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org).
|
|
7
7
|
|
|
8
|
+
## [1.32.0] - 2026-11-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Answer quotas in the user test YAML: give an option of a single-select
|
|
12
|
+
screening question a `quota` (completed sessions wanted from participants who
|
|
13
|
+
pick it). `usertests validate` checks them; `usertests get` leaves the
|
|
14
|
+
server-managed `quota_counts` out so the output still goes back into `update`.
|
|
15
|
+
- `usertests status <id>` shows responses and how full each quota is, and says
|
|
16
|
+
when a test closed itself because every answer to a question is full or
|
|
17
|
+
max_responses was reached. `--json` for scripts.
|
|
18
|
+
|
|
19
|
+
## [1.31.0] - 2026-11-03
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- `transcripts download <project> --all -o <folder>` downloads every transcript
|
|
23
|
+
in a project, one `<name> - <batch-id>.txt` per interview, and lists the
|
|
24
|
+
interviews that have no transcript yet.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- List tables no longer cut batch, interview and user test IDs. Off a terminal
|
|
28
|
+
(Claude Code, a pipe) they were squeezed to 80 columns and IDs ended in "…",
|
|
29
|
+
so agents guessed the rest and got 404s; tables now print at full width there.
|
|
30
|
+
|
|
8
31
|
## [1.30.0] - 2026-10-31
|
|
9
32
|
|
|
10
33
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: podojo-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.32.0
|
|
4
4
|
Summary: CLI for the Podojo user research platform
|
|
5
5
|
Project-URL: Homepage, https://github.com/podojo/cli-podojo
|
|
6
6
|
Project-URL: Source, https://github.com/podojo/cli-podojo
|
|
@@ -92,8 +92,12 @@ podojo projects create "Checkout Redesign" --brief "Q1 usability study"
|
|
|
92
92
|
podojo interviews upload session-03.mp4 --project "Checkout Redesign"
|
|
93
93
|
podojo transcripts list "Checkout Redesign"
|
|
94
94
|
podojo transcripts download "Checkout Redesign" <batch-id> -o transcript.txt
|
|
95
|
+
podojo transcripts download "Checkout Redesign" --all -o transcripts/
|
|
95
96
|
```
|
|
96
97
|
|
|
98
|
+
`--all` saves one `<name> - <batch-id>.txt` per interview, so each file carries
|
|
99
|
+
its batch ID for showreel clips.
|
|
100
|
+
|
|
97
101
|
### Videos & showreels
|
|
98
102
|
|
|
99
103
|
```bash
|
|
@@ -120,6 +124,7 @@ podojo usertests create -f my-test.yaml
|
|
|
120
124
|
podojo usertests list
|
|
121
125
|
podojo usertests get checkout-usability-v1
|
|
122
126
|
podojo usertests update checkout-usability-v1 -f changes.yaml
|
|
127
|
+
podojo usertests status checkout-usability-v1 # responses and how full each answer quota is (--json for scripts)
|
|
123
128
|
podojo usertests delete checkout-usability-v1
|
|
124
129
|
podojo usertests snippet # recording snippet: two script tags + recorder.js
|
|
125
130
|
```
|
|
@@ -60,8 +60,12 @@ podojo projects create "Checkout Redesign" --brief "Q1 usability study"
|
|
|
60
60
|
podojo interviews upload session-03.mp4 --project "Checkout Redesign"
|
|
61
61
|
podojo transcripts list "Checkout Redesign"
|
|
62
62
|
podojo transcripts download "Checkout Redesign" <batch-id> -o transcript.txt
|
|
63
|
+
podojo transcripts download "Checkout Redesign" --all -o transcripts/
|
|
63
64
|
```
|
|
64
65
|
|
|
66
|
+
`--all` saves one `<name> - <batch-id>.txt` per interview, so each file carries
|
|
67
|
+
its batch ID for showreel clips.
|
|
68
|
+
|
|
65
69
|
### Videos & showreels
|
|
66
70
|
|
|
67
71
|
```bash
|
|
@@ -88,6 +92,7 @@ podojo usertests create -f my-test.yaml
|
|
|
88
92
|
podojo usertests list
|
|
89
93
|
podojo usertests get checkout-usability-v1
|
|
90
94
|
podojo usertests update checkout-usability-v1 -f changes.yaml
|
|
95
|
+
podojo usertests status checkout-usability-v1 # responses and how full each answer quota is (--json for scripts)
|
|
91
96
|
podojo usertests delete checkout-usability-v1
|
|
92
97
|
podojo usertests snippet # recording snippet: two script tags + recorder.js
|
|
93
98
|
```
|
|
@@ -8,6 +8,7 @@ from rich.table import Table
|
|
|
8
8
|
|
|
9
9
|
from ..client import PodojoClient
|
|
10
10
|
from ..config import load_config
|
|
11
|
+
from ..tables import print_table
|
|
11
12
|
|
|
12
13
|
app = typer.Typer(help="Manage AI voice interviews")
|
|
13
14
|
console = Console()
|
|
@@ -436,7 +437,7 @@ def list_ai_interviews(
|
|
|
436
437
|
return
|
|
437
438
|
|
|
438
439
|
table = Table(title="AI Interviews")
|
|
439
|
-
table.add_column("Interview ID")
|
|
440
|
+
table.add_column("Interview ID", no_wrap=True)
|
|
440
441
|
table.add_column("Title")
|
|
441
442
|
table.add_column("Language")
|
|
442
443
|
table.add_column("Questions", justify="right")
|
|
@@ -459,7 +460,7 @@ def list_ai_interviews(
|
|
|
459
460
|
s.get("last_updated", ""),
|
|
460
461
|
)
|
|
461
462
|
|
|
462
|
-
console
|
|
463
|
+
print_table(console, table)
|
|
463
464
|
|
|
464
465
|
|
|
465
466
|
@app.command("get")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from ..client import PodojoClient
|
|
10
|
+
from ..tables import print_table
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Manage transcripts")
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _format_date(value) -> str:
|
|
17
|
+
if not value:
|
|
18
|
+
return ""
|
|
19
|
+
try:
|
|
20
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
|
|
21
|
+
except (ValueError, AttributeError):
|
|
22
|
+
return str(value)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _filename(interview: dict) -> str:
|
|
26
|
+
"""Name plus batch ID, so a file maps back to its batch ID without a
|
|
27
|
+
lookup. Characters file systems reject become "_"."""
|
|
28
|
+
batch_id = interview["batch_id"]
|
|
29
|
+
name = re.sub(r'[\x00-\x1f/\\:*?"<>|]', "_", (interview.get("batch_name") or "").strip())
|
|
30
|
+
return f"{name} - {batch_id}.txt" if name else f"{batch_id}.txt"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("list")
|
|
34
|
+
def list_transcripts(project: str = typer.Argument(help="Project name")):
|
|
35
|
+
"""List transcripts for a project."""
|
|
36
|
+
client = PodojoClient()
|
|
37
|
+
data = client.list_transcripts(project)
|
|
38
|
+
|
|
39
|
+
table = Table(title=f"Transcripts — {project} ({data['total']} total)")
|
|
40
|
+
table.add_column("Date")
|
|
41
|
+
table.add_column("Batch ID", no_wrap=True)
|
|
42
|
+
table.add_column("Name")
|
|
43
|
+
|
|
44
|
+
for t in data["interviews"]:
|
|
45
|
+
table.add_row(
|
|
46
|
+
_format_date(t.get("date")),
|
|
47
|
+
t.get("batch_id", ""),
|
|
48
|
+
t.get("batch_name", ""),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
print_table(console, table)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _download_all(client: PodojoClient, project: str, folder: Path) -> None:
|
|
55
|
+
data = client.list_transcripts(project)
|
|
56
|
+
interviews = data["interviews"]
|
|
57
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
missing = []
|
|
60
|
+
for interview in interviews:
|
|
61
|
+
text = client.download_transcript(project, interview["batch_id"])
|
|
62
|
+
if not text.strip():
|
|
63
|
+
missing.append(interview.get("batch_name") or interview["batch_id"])
|
|
64
|
+
continue
|
|
65
|
+
path = folder / _filename(interview)
|
|
66
|
+
path.write_text(text, encoding="utf-8")
|
|
67
|
+
console.print(f"Saved {path}", soft_wrap=True, markup=False)
|
|
68
|
+
|
|
69
|
+
console.print(
|
|
70
|
+
f"Downloaded {len(interviews) - len(missing)} transcripts to {folder}",
|
|
71
|
+
soft_wrap=True,
|
|
72
|
+
markup=False,
|
|
73
|
+
)
|
|
74
|
+
if missing:
|
|
75
|
+
console.print(f"No transcript yet: {', '.join(missing)}", soft_wrap=True, markup=False)
|
|
76
|
+
if data["total"] > len(interviews):
|
|
77
|
+
console.print(f"Only the newest {len(interviews)} of {data['total']} interviews were downloaded.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command("download")
|
|
81
|
+
def download_transcript(
|
|
82
|
+
project: str = typer.Argument(help="Project name"),
|
|
83
|
+
batch_id: str = typer.Argument(None, help="Batch ID (leave out with --all)"),
|
|
84
|
+
all_: bool = typer.Option(False, "--all", help="Download every transcript in the project"),
|
|
85
|
+
output: Path = typer.Option(
|
|
86
|
+
None, "-o", "--output", help="Output file path, or the folder for --all (default: current folder)"
|
|
87
|
+
),
|
|
88
|
+
):
|
|
89
|
+
"""Download a transcript, or all of a project's transcripts with --all."""
|
|
90
|
+
if bool(batch_id) == all_:
|
|
91
|
+
console.print("[red]Error:[/red] Pass either a batch ID or --all.")
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
94
|
+
client = PodojoClient()
|
|
95
|
+
if all_:
|
|
96
|
+
_download_all(client, project, output or Path("."))
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
text = client.download_transcript(project, batch_id)
|
|
100
|
+
|
|
101
|
+
if output:
|
|
102
|
+
output.write_text(text, encoding="utf-8")
|
|
103
|
+
console.print(f"Saved to {output}")
|
|
104
|
+
else:
|
|
105
|
+
console.print(text)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import json
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
|
|
3
4
|
import httpx
|
|
@@ -7,6 +8,7 @@ from rich.console import Console
|
|
|
7
8
|
from rich.table import Table
|
|
8
9
|
|
|
9
10
|
from ..client import PodojoClient
|
|
11
|
+
from ..tables import print_table
|
|
10
12
|
|
|
11
13
|
app = typer.Typer(help="Manage unmoderated user tests")
|
|
12
14
|
console = Console()
|
|
@@ -69,6 +71,13 @@ EXAMPLE_YAML = """\
|
|
|
69
71
|
# options (required, at least 2) each with `text`; `qualifies: true`
|
|
70
72
|
# marks the accepted answers of a screener question and is
|
|
71
73
|
# ignored on plain closed questions
|
|
74
|
+
# quota (optional, on an option) how many completed sessions you
|
|
75
|
+
# want from participants who pick it; once it's full, new
|
|
76
|
+
# participants who pick it are turned away. Single-select
|
|
77
|
+
# only, and on a screener only with `qualifies: true`. The
|
|
78
|
+
# test goes off live when every option that lets people
|
|
79
|
+
# through on one question is full. `podojo usertests status`
|
|
80
|
+
# shows how full each quota is.
|
|
72
81
|
|
|
73
82
|
usertest_id: checkout-usability-v1
|
|
74
83
|
title: Checkout Flow Usability Test
|
|
@@ -125,7 +134,9 @@ screening_questions:
|
|
|
125
134
|
- text: Rarely or never
|
|
126
135
|
- text: A few times a year
|
|
127
136
|
- text: At least once a month
|
|
137
|
+
quota: 10
|
|
128
138
|
- text: Weekly or more
|
|
139
|
+
quota: 10
|
|
129
140
|
|
|
130
141
|
- text: Have you abandoned an online purchase at checkout in the past 3 months?
|
|
131
142
|
screener: true
|
|
@@ -301,6 +312,12 @@ def validate_usertest_data(data: dict) -> list[str]:
|
|
|
301
312
|
errors.append(f"Screening question {i}: 'options' must list at least 2 options")
|
|
302
313
|
continue
|
|
303
314
|
qualifying = 0
|
|
315
|
+
if multi_select is True and any(
|
|
316
|
+
isinstance(o, dict) and o.get("quota") is not None for o in options
|
|
317
|
+
):
|
|
318
|
+
errors.append(
|
|
319
|
+
f"Screening question {i}: 'quota' isn't supported on multi_select questions"
|
|
320
|
+
)
|
|
304
321
|
for j, option in enumerate(options, 1):
|
|
305
322
|
if not isinstance(option, dict) or "text" not in option:
|
|
306
323
|
errors.append(
|
|
@@ -314,6 +331,18 @@ def validate_usertest_data(data: dict) -> list[str]:
|
|
|
314
331
|
)
|
|
315
332
|
elif qualifies:
|
|
316
333
|
qualifying += 1
|
|
334
|
+
quota = option.get("quota")
|
|
335
|
+
if quota is None or multi_select is True:
|
|
336
|
+
continue
|
|
337
|
+
if not isinstance(quota, int) or isinstance(quota, bool) or quota < 1:
|
|
338
|
+
errors.append(
|
|
339
|
+
f"Screening question {i}, option {j}: 'quota' must be a positive integer"
|
|
340
|
+
)
|
|
341
|
+
elif screener and qualifies is not True:
|
|
342
|
+
errors.append(
|
|
343
|
+
f"Screening question {i}, option {j}: 'quota' only works on "
|
|
344
|
+
"options with 'qualifies: true'"
|
|
345
|
+
)
|
|
317
346
|
if screener and qualifying == 0:
|
|
318
347
|
errors.append(
|
|
319
348
|
f"Screening question {i}: needs at least one option with 'qualifies: true'"
|
|
@@ -423,7 +452,7 @@ def list_usertests(
|
|
|
423
452
|
return
|
|
424
453
|
|
|
425
454
|
table = Table(title="User Tests")
|
|
426
|
-
table.add_column("User Test ID")
|
|
455
|
+
table.add_column("User Test ID", no_wrap=True)
|
|
427
456
|
table.add_column("Title")
|
|
428
457
|
table.add_column("Steps", justify="right")
|
|
429
458
|
table.add_column("Live")
|
|
@@ -444,7 +473,7 @@ def list_usertests(
|
|
|
444
473
|
s.get("last_updated", ""),
|
|
445
474
|
)
|
|
446
475
|
|
|
447
|
-
console
|
|
476
|
+
print_table(console, table)
|
|
448
477
|
|
|
449
478
|
|
|
450
479
|
@app.command("get")
|
|
@@ -465,7 +494,7 @@ def get_usertest(
|
|
|
465
494
|
# Remove server-managed fields for a clean editable output
|
|
466
495
|
group = usertest.pop("group", "")
|
|
467
496
|
response_count = usertest.pop("response_count", 0) or 0
|
|
468
|
-
for key in ("id", "created_at", "created_by", "last_updated"):
|
|
497
|
+
for key in ("quota_counts", "id", "created_at", "created_by", "last_updated"):
|
|
469
498
|
usertest.pop(key, None)
|
|
470
499
|
|
|
471
500
|
# Plain stdout, so `get > file.yaml` round-trips: rich would hard-wrap long
|
|
@@ -481,6 +510,104 @@ def get_usertest(
|
|
|
481
510
|
err_console.print(f"Live: https://usertests.podojo.com/{group}?test={usertest_id}")
|
|
482
511
|
|
|
483
512
|
|
|
513
|
+
def _quota_status(usertest: dict) -> dict:
|
|
514
|
+
"""Work out each quota's fill level and why a closed test closed."""
|
|
515
|
+
counts = usertest.get("quota_counts") or []
|
|
516
|
+
quotas = []
|
|
517
|
+
closed_question = None
|
|
518
|
+
for qi, question in enumerate(usertest.get("screening_questions") or []):
|
|
519
|
+
if question.get("multi_select"):
|
|
520
|
+
continue
|
|
521
|
+
screener = question.get("screener", False)
|
|
522
|
+
question_counts = counts[qi] if qi < len(counts) else []
|
|
523
|
+
passing = []
|
|
524
|
+
for oi, option in enumerate(question.get("options") or []):
|
|
525
|
+
if screener and not option.get("qualifies"):
|
|
526
|
+
continue
|
|
527
|
+
count = question_counts[oi] if oi < len(question_counts) else 0
|
|
528
|
+
quota = option.get("quota")
|
|
529
|
+
passing.append(
|
|
530
|
+
{
|
|
531
|
+
"text": option["text"],
|
|
532
|
+
"count": count,
|
|
533
|
+
"quota": quota,
|
|
534
|
+
"full": quota is not None and count >= quota,
|
|
535
|
+
"left": None if quota is None else max(quota - count, 0),
|
|
536
|
+
}
|
|
537
|
+
)
|
|
538
|
+
if not any(o["quota"] is not None for o in passing):
|
|
539
|
+
continue
|
|
540
|
+
quotas.append({"question": question["text"], "options": passing})
|
|
541
|
+
if closed_question is None and all(o["full"] for o in passing):
|
|
542
|
+
closed_question = question["text"]
|
|
543
|
+
|
|
544
|
+
live = bool(usertest.get("live"))
|
|
545
|
+
max_responses = usertest.get("max_responses")
|
|
546
|
+
response_count = usertest.get("response_count") or 0
|
|
547
|
+
closed_reason = None
|
|
548
|
+
if not live:
|
|
549
|
+
if closed_question is not None:
|
|
550
|
+
closed_reason = f'every answer to "{closed_question}" is full'
|
|
551
|
+
elif max_responses and response_count >= max_responses:
|
|
552
|
+
closed_reason = "max responses reached"
|
|
553
|
+
return {
|
|
554
|
+
"usertest_id": usertest.get("usertest_id"),
|
|
555
|
+
"title": usertest.get("title"),
|
|
556
|
+
"live": live,
|
|
557
|
+
"response_count": response_count,
|
|
558
|
+
"max_responses": max_responses,
|
|
559
|
+
"closed_reason": closed_reason,
|
|
560
|
+
"quotas": quotas,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@app.command("status")
|
|
565
|
+
def usertest_status(
|
|
566
|
+
usertest_id: str = typer.Argument(help="User test ID to check"),
|
|
567
|
+
as_json: bool = typer.Option(False, "--json", help="Output as JSON"),
|
|
568
|
+
):
|
|
569
|
+
"""Show how far a user test is: responses and how full each quota is."""
|
|
570
|
+
client = PodojoClient()
|
|
571
|
+
try:
|
|
572
|
+
usertest = client.get_usertest(usertest_id)
|
|
573
|
+
except httpx.HTTPStatusError as e:
|
|
574
|
+
if e.response.status_code == 404:
|
|
575
|
+
console.print(f"[red]Error:[/red] User test '{usertest_id}' not found")
|
|
576
|
+
else:
|
|
577
|
+
console.print(f"[red]Error:[/red] {_format_api_error(e)}")
|
|
578
|
+
raise typer.Exit(1)
|
|
579
|
+
|
|
580
|
+
status = _quota_status(usertest)
|
|
581
|
+
if as_json:
|
|
582
|
+
typer.echo(json.dumps(status, indent=2, ensure_ascii=False))
|
|
583
|
+
return
|
|
584
|
+
|
|
585
|
+
typer.echo(f"{status['title']} · {'Live' if status['live'] else 'Not live'}")
|
|
586
|
+
if status["closed_reason"]:
|
|
587
|
+
typer.echo(f"Closed automatically: {status['closed_reason']}")
|
|
588
|
+
if status["max_responses"]:
|
|
589
|
+
typer.echo(f"Responses: {status['response_count']} / {status['max_responses']}")
|
|
590
|
+
else:
|
|
591
|
+
typer.echo(f"Responses: {status['response_count']}")
|
|
592
|
+
all_options = [o for q in status["quotas"] for o in q["options"]]
|
|
593
|
+
if all_options:
|
|
594
|
+
typer.echo("")
|
|
595
|
+
text_width = max((len(o["text"]) for o in all_options), default=0)
|
|
596
|
+
count_width = max((len(str(o["count"])) for o in all_options), default=0)
|
|
597
|
+
quota_width = max((len(str(o["quota"])) for o in all_options if o["quota"]), default=0)
|
|
598
|
+
for quota in status["quotas"]:
|
|
599
|
+
typer.echo(quota["question"])
|
|
600
|
+
for o in quota["options"]:
|
|
601
|
+
progress = f"{o['count']:>{count_width}}"
|
|
602
|
+
if o["quota"] is None:
|
|
603
|
+
note = "no limit"
|
|
604
|
+
progress = f"{progress} {'':>{quota_width}}"
|
|
605
|
+
else:
|
|
606
|
+
progress = f"{progress} / {o['quota']:>{quota_width}}"
|
|
607
|
+
note = "full" if o["full"] else f"{o['left']} left"
|
|
608
|
+
typer.echo(f" {o['text']:<{text_width}} {progress} {note}")
|
|
609
|
+
|
|
610
|
+
|
|
484
611
|
@app.command("create")
|
|
485
612
|
def create_usertest(
|
|
486
613
|
from_file: Path = typer.Option(..., "--from-file", "-f", help="YAML file with user test config"),
|
|
@@ -8,6 +8,7 @@ from rich.progress import Progress
|
|
|
8
8
|
from rich.table import Table
|
|
9
9
|
|
|
10
10
|
from ..client import PodojoClient
|
|
11
|
+
from ..tables import print_table
|
|
11
12
|
|
|
12
13
|
app = typer.Typer(help="Manage videos")
|
|
13
14
|
console = Console()
|
|
@@ -32,7 +33,7 @@ def list_videos(project: str = typer.Argument(help="Project name")):
|
|
|
32
33
|
|
|
33
34
|
table = Table(title=f"Videos — {project} ({data['total']} total)")
|
|
34
35
|
table.add_column("Date")
|
|
35
|
-
table.add_column("Batch ID")
|
|
36
|
+
table.add_column("Batch ID", no_wrap=True)
|
|
36
37
|
table.add_column("Name")
|
|
37
38
|
table.add_column("Duration (min)")
|
|
38
39
|
|
|
@@ -44,7 +45,7 @@ def list_videos(project: str = typer.Argument(help="Project name")):
|
|
|
44
45
|
str(v.get("duration_minutes", "")),
|
|
45
46
|
)
|
|
46
47
|
|
|
47
|
-
console
|
|
48
|
+
print_table(console, table)
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
@app.command("download")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.table import Table
|
|
3
|
+
|
|
4
|
+
# Off a terminal (Claude Code, a pipe) Rich falls back to 80 columns and cuts
|
|
5
|
+
# cells with "…", so agents end up guessing the rest of a batch ID. Tables
|
|
6
|
+
# render at their natural width up to this one instead.
|
|
7
|
+
OFF_TERMINAL_WIDTH = 200
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def print_table(console: Console, table: Table) -> None:
|
|
11
|
+
if not console.is_terminal:
|
|
12
|
+
console = Console(width=OFF_TERMINAL_WIDTH)
|
|
13
|
+
console.print(table)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from podojo_cli.main import app
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_list_transcripts(runner, httpx_mock):
|
|
5
|
+
httpx_mock.add_response(
|
|
6
|
+
method="GET",
|
|
7
|
+
url="http://test.local/api/v1/projects/Alpha/transcripts",
|
|
8
|
+
json={
|
|
9
|
+
"project": "Alpha",
|
|
10
|
+
"total": 2,
|
|
11
|
+
"interviews": [
|
|
12
|
+
{
|
|
13
|
+
"batch_id": "batch-1",
|
|
14
|
+
"batch_name": "session_1",
|
|
15
|
+
"date": "2026-05-01T12:00:00",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"batch_id": "batch-2",
|
|
19
|
+
"batch_name": "session_2",
|
|
20
|
+
"date": "2026-05-02T12:00:00",
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
result = runner.invoke(app, ["transcripts", "list", "Alpha"])
|
|
27
|
+
|
|
28
|
+
assert result.exit_code == 0
|
|
29
|
+
assert "batch-1" in result.output
|
|
30
|
+
assert "session_2" in result.output
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
BATCH_ID = "62d1b9bf-8f03-41d1-b5e0-d0c4bd0e7eb6"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_list_transcripts_keeps_batch_ids_whole_off_terminal(runner, httpx_mock, monkeypatch):
|
|
37
|
+
monkeypatch.setenv("COLUMNS", "80")
|
|
38
|
+
httpx_mock.add_response(
|
|
39
|
+
method="GET",
|
|
40
|
+
url="http://test.local/api/v1/projects/Alpha/transcripts",
|
|
41
|
+
json={
|
|
42
|
+
"project": "Alpha",
|
|
43
|
+
"total": 1,
|
|
44
|
+
"interviews": [
|
|
45
|
+
{"batch_id": BATCH_ID, "batch_name": "RapidUsertest 32721 1 (3)", "date": "2026-09-17T14:10:00"},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
result = runner.invoke(app, ["transcripts", "list", "Alpha"])
|
|
51
|
+
|
|
52
|
+
assert result.exit_code == 0
|
|
53
|
+
assert BATCH_ID in result.output
|
|
54
|
+
assert "RapidUsertest 32721 1 (3)" in result.output
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_download_all_transcripts(runner, httpx_mock, tmp_path):
|
|
58
|
+
httpx_mock.add_response(
|
|
59
|
+
method="GET",
|
|
60
|
+
url="http://test.local/api/v1/projects/Alpha/transcripts",
|
|
61
|
+
json={
|
|
62
|
+
"project": "Alpha",
|
|
63
|
+
"total": 3,
|
|
64
|
+
"interviews": [
|
|
65
|
+
{"batch_id": BATCH_ID, "batch_name": "Session 1/2"},
|
|
66
|
+
{"batch_id": "batch-2", "batch_name": "[DE] session_2"},
|
|
67
|
+
{"batch_id": "batch-3", "batch_name": "session_3"},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
httpx_mock.add_response(
|
|
72
|
+
method="GET", url=f"http://test.local/api/v1/projects/Alpha/transcripts/{BATCH_ID}", text="Hello"
|
|
73
|
+
)
|
|
74
|
+
httpx_mock.add_response(
|
|
75
|
+
method="GET", url="http://test.local/api/v1/projects/Alpha/transcripts/batch-2", text="Hallo"
|
|
76
|
+
)
|
|
77
|
+
httpx_mock.add_response(
|
|
78
|
+
method="GET", url="http://test.local/api/v1/projects/Alpha/transcripts/batch-3", text=""
|
|
79
|
+
)
|
|
80
|
+
folder = tmp_path / "transcripts"
|
|
81
|
+
|
|
82
|
+
result = runner.invoke(app, ["transcripts", "download", "Alpha", "--all", "-o", str(folder)])
|
|
83
|
+
|
|
84
|
+
assert result.exit_code == 0
|
|
85
|
+
assert (folder / f"Session 1_2 - {BATCH_ID}.txt").read_text() == "Hello"
|
|
86
|
+
assert (folder / "[DE] session_2 - batch-2.txt").read_text() == "Hallo"
|
|
87
|
+
assert sorted(p.name for p in folder.iterdir()) == [f"Session 1_2 - {BATCH_ID}.txt", "[DE] session_2 - batch-2.txt"]
|
|
88
|
+
assert "Downloaded 2 transcripts" in result.output
|
|
89
|
+
assert "No transcript yet: session_3" in result.output
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_download_transcript_needs_batch_id_or_all(runner):
|
|
93
|
+
for args in (["Alpha"], ["Alpha", "batch-1", "--all"]):
|
|
94
|
+
result = runner.invoke(app, ["transcripts", "download", *args])
|
|
95
|
+
|
|
96
|
+
assert result.exit_code == 1
|
|
97
|
+
assert "Pass either a batch ID or --all" in result.output
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
1
3
|
import yaml
|
|
2
4
|
|
|
3
5
|
from podojo_cli.main import app
|
|
@@ -799,3 +801,254 @@ def test_example_template_validates(runner, tmp_path):
|
|
|
799
801
|
|
|
800
802
|
assert "variant: ai-question" in example.output
|
|
801
803
|
assert result.exit_code == 0
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _quota_yaml(question: str) -> str:
|
|
807
|
+
return VALID_USERTEST_YAML + "screening_questions:\n" + question
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def test_validate_quota_valid(runner, tmp_path):
|
|
811
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
812
|
+
yaml_file.write_text(
|
|
813
|
+
_quota_yaml(
|
|
814
|
+
" - text: How long?\n"
|
|
815
|
+
" options:\n"
|
|
816
|
+
" - text: Short\n"
|
|
817
|
+
" quota: 5\n"
|
|
818
|
+
" - text: Long\n"
|
|
819
|
+
" quota: null\n"
|
|
820
|
+
" - text: Abandoned a purchase?\n"
|
|
821
|
+
" screener: true\n"
|
|
822
|
+
" options:\n"
|
|
823
|
+
" - text: \"Yes\"\n"
|
|
824
|
+
" qualifies: true\n"
|
|
825
|
+
" quota: 10\n"
|
|
826
|
+
" - text: \"No\"\n"
|
|
827
|
+
)
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
831
|
+
|
|
832
|
+
assert result.exit_code == 0
|
|
833
|
+
assert "Valid user test config" in result.output
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def test_validate_quota_must_be_a_positive_integer(runner, tmp_path):
|
|
837
|
+
for bad in ("0", "-2", "1.5", "true", "many"):
|
|
838
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
839
|
+
yaml_file.write_text(
|
|
840
|
+
_quota_yaml(
|
|
841
|
+
" - text: How long?\n"
|
|
842
|
+
" options:\n"
|
|
843
|
+
" - text: Short\n"
|
|
844
|
+
f" quota: {bad}\n"
|
|
845
|
+
" - text: Long\n"
|
|
846
|
+
)
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
850
|
+
|
|
851
|
+
assert result.exit_code == 1, bad
|
|
852
|
+
assert "Screening question 1, option 1: 'quota' must be a positive integer" in result.output
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def test_validate_quota_rejected_on_multi_select(runner, tmp_path):
|
|
856
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
857
|
+
yaml_file.write_text(
|
|
858
|
+
_quota_yaml(
|
|
859
|
+
" - text: Which devices?\n"
|
|
860
|
+
" multi_select: true\n"
|
|
861
|
+
" options:\n"
|
|
862
|
+
" - text: Phone\n"
|
|
863
|
+
" quota: 5\n"
|
|
864
|
+
" - text: Laptop\n"
|
|
865
|
+
" quota: 5\n"
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
870
|
+
|
|
871
|
+
assert result.exit_code == 1
|
|
872
|
+
assert result.output.count("'quota' isn't supported on multi_select questions") == 1
|
|
873
|
+
assert "Screening question 1: 'quota' isn't supported on multi_select questions" in result.output
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def test_validate_quota_on_screener_needs_qualifying_option(runner, tmp_path):
|
|
877
|
+
yaml_file = tmp_path / "usertest.yaml"
|
|
878
|
+
yaml_file.write_text(
|
|
879
|
+
_quota_yaml(
|
|
880
|
+
" - text: Abandoned a purchase?\n"
|
|
881
|
+
" screener: true\n"
|
|
882
|
+
" options:\n"
|
|
883
|
+
" - text: \"Yes\"\n"
|
|
884
|
+
" qualifies: true\n"
|
|
885
|
+
" - text: \"No\"\n"
|
|
886
|
+
" quota: 3\n"
|
|
887
|
+
)
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
result = runner.invoke(app, ["usertests", "validate", str(yaml_file)])
|
|
891
|
+
|
|
892
|
+
assert result.exit_code == 1
|
|
893
|
+
assert "Screening question 1, option 2: 'quota' only works on" in result.output
|
|
894
|
+
assert "'qualifies: true'" in result.output
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def test_get_usertest_drops_quota_counts(runner, httpx_mock):
|
|
898
|
+
httpx_mock.add_response(
|
|
899
|
+
url="http://test.local/api/v1/usertests/ut-1",
|
|
900
|
+
json={**GET_RESPONSE, "quota_counts": [[1, 2]]},
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
result = runner.invoke(app, ["usertests", "get", "ut-1"])
|
|
904
|
+
|
|
905
|
+
assert result.exit_code == 0
|
|
906
|
+
assert "quota_counts" not in result.stdout
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
STATUS_RESPONSE = {
|
|
910
|
+
**GET_RESPONSE,
|
|
911
|
+
"title": "Customer onboarding test",
|
|
912
|
+
"live": True,
|
|
913
|
+
"max_responses": None,
|
|
914
|
+
"response_count": 12,
|
|
915
|
+
"screening_questions": [
|
|
916
|
+
{
|
|
917
|
+
"text": "How long have you been a customer?",
|
|
918
|
+
"options": [
|
|
919
|
+
{"text": "Less than 1 year", "quota": 5},
|
|
920
|
+
{"text": "1–3 years", "quota": 5},
|
|
921
|
+
{"text": "More than 3 years", "quota": 5},
|
|
922
|
+
],
|
|
923
|
+
},
|
|
924
|
+
{"text": "Plain question?", "options": [{"text": "A"}, {"text": "B"}]},
|
|
925
|
+
{
|
|
926
|
+
"text": "Are you a buyer?",
|
|
927
|
+
"screener": True,
|
|
928
|
+
"options": [
|
|
929
|
+
{"text": "Yes", "qualifies": True, "quota": 10},
|
|
930
|
+
{"text": "Sometimes", "qualifies": True},
|
|
931
|
+
{"text": "No"},
|
|
932
|
+
],
|
|
933
|
+
},
|
|
934
|
+
],
|
|
935
|
+
"quota_counts": [[5, 3, 4], [0, 0], [2, 6, 9]],
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def test_status_live_with_quotas(runner, httpx_mock):
|
|
940
|
+
httpx_mock.add_response(url="http://test.local/api/v1/usertests/ut-1", json=STATUS_RESPONSE)
|
|
941
|
+
|
|
942
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1"])
|
|
943
|
+
|
|
944
|
+
assert result.exit_code == 0
|
|
945
|
+
assert result.stdout == (
|
|
946
|
+
"Customer onboarding test · Live\n"
|
|
947
|
+
"Responses: 12\n"
|
|
948
|
+
"\n"
|
|
949
|
+
"How long have you been a customer?\n"
|
|
950
|
+
" Less than 1 year 5 / 5 full\n"
|
|
951
|
+
" 1–3 years 3 / 5 2 left\n"
|
|
952
|
+
" More than 3 years 4 / 5 1 left\n"
|
|
953
|
+
"Are you a buyer?\n"
|
|
954
|
+
" Yes 2 / 10 8 left\n"
|
|
955
|
+
" Sometimes 6 no limit\n"
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def test_status_closed_by_full_question(runner, httpx_mock):
|
|
960
|
+
httpx_mock.add_response(
|
|
961
|
+
url="http://test.local/api/v1/usertests/ut-1",
|
|
962
|
+
json={
|
|
963
|
+
**STATUS_RESPONSE,
|
|
964
|
+
"live": False,
|
|
965
|
+
"max_responses": 50,
|
|
966
|
+
"quota_counts": [[5, 5, 5], [0, 0], [0, 0, 0]],
|
|
967
|
+
},
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1"])
|
|
971
|
+
|
|
972
|
+
lines = result.stdout.splitlines()
|
|
973
|
+
assert lines[0] == "Customer onboarding test · Not live"
|
|
974
|
+
assert lines[1] == 'Closed automatically: every answer to "How long have you been a customer?" is full'
|
|
975
|
+
assert lines[2] == "Responses: 12 / 50"
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def test_status_closed_by_max_responses(runner, httpx_mock):
|
|
979
|
+
httpx_mock.add_response(
|
|
980
|
+
url="http://test.local/api/v1/usertests/ut-1",
|
|
981
|
+
json={**STATUS_RESPONSE, "live": False, "max_responses": 12},
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1"])
|
|
985
|
+
|
|
986
|
+
assert "Closed automatically: max responses reached" in result.stdout
|
|
987
|
+
assert "Responses: 12 / 12" in result.stdout
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def test_status_not_live_without_a_reason(runner, httpx_mock):
|
|
991
|
+
httpx_mock.add_response(
|
|
992
|
+
url="http://test.local/api/v1/usertests/ut-1",
|
|
993
|
+
json={**STATUS_RESPONSE, "live": False},
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1"])
|
|
997
|
+
|
|
998
|
+
assert "Not live" in result.stdout
|
|
999
|
+
assert "Closed automatically" not in result.stdout
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def test_status_without_quotas(runner, httpx_mock):
|
|
1003
|
+
httpx_mock.add_response(
|
|
1004
|
+
url="http://test.local/api/v1/usertests/ut-1",
|
|
1005
|
+
json={**GET_RESPONSE, "title": "Plain", "live": True, "max_responses": None},
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1"])
|
|
1009
|
+
|
|
1010
|
+
assert result.exit_code == 0
|
|
1011
|
+
assert result.stdout == "Plain · Live\nResponses: 7\n"
|
|
1012
|
+
|
|
1013
|
+
|
|
1014
|
+
def test_status_missing_quota_counts_means_zeros(runner, httpx_mock):
|
|
1015
|
+
data = {k: v for k, v in STATUS_RESPONSE.items() if k != "quota_counts"}
|
|
1016
|
+
httpx_mock.add_response(url="http://test.local/api/v1/usertests/ut-1", json=data)
|
|
1017
|
+
|
|
1018
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1"])
|
|
1019
|
+
|
|
1020
|
+
assert result.exit_code == 0
|
|
1021
|
+
assert " Less than 1 year 0 / 5 5 left" in result.stdout
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def test_status_json(runner, httpx_mock):
|
|
1025
|
+
httpx_mock.add_response(url="http://test.local/api/v1/usertests/ut-1", json=STATUS_RESPONSE)
|
|
1026
|
+
|
|
1027
|
+
result = runner.invoke(app, ["usertests", "status", "ut-1", "--json"])
|
|
1028
|
+
|
|
1029
|
+
assert result.exit_code == 0
|
|
1030
|
+
data = json.loads(result.stdout)
|
|
1031
|
+
assert data["usertest_id"] == "ut-1"
|
|
1032
|
+
assert data["live"] is True
|
|
1033
|
+
assert data["response_count"] == 12
|
|
1034
|
+
assert data["max_responses"] is None
|
|
1035
|
+
assert data["closed_reason"] is None
|
|
1036
|
+
assert [q["question"] for q in data["quotas"]] == [
|
|
1037
|
+
"How long have you been a customer?",
|
|
1038
|
+
"Are you a buyer?",
|
|
1039
|
+
]
|
|
1040
|
+
assert data["quotas"][0]["options"][0] == {
|
|
1041
|
+
"text": "Less than 1 year", "count": 5, "quota": 5, "full": True, "left": 0,
|
|
1042
|
+
}
|
|
1043
|
+
assert data["quotas"][1]["options"][1] == {
|
|
1044
|
+
"text": "Sometimes", "count": 6, "quota": None, "full": False, "left": None,
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def test_status_not_found(runner, httpx_mock):
|
|
1049
|
+
httpx_mock.add_response(url="http://test.local/api/v1/usertests/nope", status_code=404)
|
|
1050
|
+
|
|
1051
|
+
result = runner.invoke(app, ["usertests", "status", "nope"])
|
|
1052
|
+
|
|
1053
|
+
assert result.exit_code == 1
|
|
1054
|
+
assert "User test 'nope' not found" in result.output
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from podojo_cli.main import app
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_list_videos_keeps_batch_ids_whole_off_terminal(runner, httpx_mock, monkeypatch):
|
|
5
|
+
monkeypatch.setenv("COLUMNS", "80")
|
|
6
|
+
batch_id = "62d1b9bf-8f03-41d1-b5e0-d0c4bd0e7eb6"
|
|
7
|
+
httpx_mock.add_response(
|
|
8
|
+
method="GET",
|
|
9
|
+
url="http://test.local/api/v1/projects/Alpha/videos",
|
|
10
|
+
json={
|
|
11
|
+
"project": "Alpha",
|
|
12
|
+
"total": 1,
|
|
13
|
+
"videos": [
|
|
14
|
+
{
|
|
15
|
+
"batch_id": batch_id,
|
|
16
|
+
"batch_name": "RapidUsertest 32721 1 (3)",
|
|
17
|
+
"batch_timestamp": "2026-09-17T14:10:00",
|
|
18
|
+
"duration_minutes": 12.4,
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
result = runner.invoke(app, ["videos", "list", "Alpha"])
|
|
25
|
+
|
|
26
|
+
assert result.exit_code == 0
|
|
27
|
+
assert batch_id in result.output
|
|
28
|
+
assert "RapidUsertest 32721 1 (3)" in result.output
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
from datetime import datetime
|
|
2
|
-
from pathlib import Path
|
|
3
|
-
|
|
4
|
-
import typer
|
|
5
|
-
from rich.console import Console
|
|
6
|
-
from rich.table import Table
|
|
7
|
-
|
|
8
|
-
from ..client import PodojoClient
|
|
9
|
-
|
|
10
|
-
app = typer.Typer(help="Manage transcripts")
|
|
11
|
-
console = Console()
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _format_date(value) -> str:
|
|
15
|
-
if not value:
|
|
16
|
-
return ""
|
|
17
|
-
try:
|
|
18
|
-
return datetime.fromisoformat(value.replace("Z", "+00:00")).strftime("%Y-%m-%d %H:%M")
|
|
19
|
-
except (ValueError, AttributeError):
|
|
20
|
-
return str(value)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
@app.command("list")
|
|
24
|
-
def list_transcripts(project: str = typer.Argument(help="Project name")):
|
|
25
|
-
"""List transcripts for a project."""
|
|
26
|
-
client = PodojoClient()
|
|
27
|
-
data = client.list_transcripts(project)
|
|
28
|
-
|
|
29
|
-
table = Table(title=f"Transcripts — {project} ({data['total']} total)")
|
|
30
|
-
table.add_column("Date")
|
|
31
|
-
table.add_column("Batch ID")
|
|
32
|
-
table.add_column("Name")
|
|
33
|
-
|
|
34
|
-
for t in data["interviews"]:
|
|
35
|
-
table.add_row(
|
|
36
|
-
_format_date(t.get("date")),
|
|
37
|
-
t.get("batch_id", ""),
|
|
38
|
-
t.get("batch_name", ""),
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
console.print(table)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@app.command("download")
|
|
45
|
-
def download_transcript(
|
|
46
|
-
project: str = typer.Argument(help="Project name"),
|
|
47
|
-
batch_id: str = typer.Argument(help="Batch ID"),
|
|
48
|
-
output: Path = typer.Option(None, "-o", "--output", help="Output file path"),
|
|
49
|
-
):
|
|
50
|
-
"""Download a transcript."""
|
|
51
|
-
client = PodojoClient()
|
|
52
|
-
text = client.download_transcript(project, batch_id)
|
|
53
|
-
|
|
54
|
-
if output:
|
|
55
|
-
output.write_text(text, encoding="utf-8")
|
|
56
|
-
console.print(f"Saved to {output}")
|
|
57
|
-
else:
|
|
58
|
-
console.print(text)
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
from podojo_cli.main import app
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_list_transcripts(runner, httpx_mock):
|
|
5
|
-
httpx_mock.add_response(
|
|
6
|
-
method="GET",
|
|
7
|
-
url="http://test.local/api/v1/projects/Alpha/transcripts",
|
|
8
|
-
json={
|
|
9
|
-
"project": "Alpha",
|
|
10
|
-
"total": 2,
|
|
11
|
-
"interviews": [
|
|
12
|
-
{
|
|
13
|
-
"batch_id": "batch-1",
|
|
14
|
-
"batch_name": "session_1",
|
|
15
|
-
"date": "2026-05-01T12:00:00",
|
|
16
|
-
},
|
|
17
|
-
{
|
|
18
|
-
"batch_id": "batch-2",
|
|
19
|
-
"batch_name": "session_2",
|
|
20
|
-
"date": "2026-05-02T12:00:00",
|
|
21
|
-
},
|
|
22
|
-
],
|
|
23
|
-
},
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
result = runner.invoke(app, ["transcripts", "list", "Alpha"])
|
|
27
|
-
|
|
28
|
-
assert result.exit_code == 0
|
|
29
|
-
assert "batch-1" in result.output
|
|
30
|
-
assert "session_2" in result.output
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|