withfeedback 0.1.0__py3-none-any.whl
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.
- withfeedback-0.1.0.dist-info/METADATA +175 -0
- withfeedback-0.1.0.dist-info/RECORD +8 -0
- withfeedback-0.1.0.dist-info/WHEEL +4 -0
- withfeedback-0.1.0.dist-info/entry_points.txt +2 -0
- withfeedback_cli/__init__.py +3 -0
- withfeedback_cli/auth.py +284 -0
- withfeedback_cli/client.py +175 -0
- withfeedback_cli/main.py +582 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: withfeedback
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for withfeedback.com — testimonials, feedback moderation, imports, widgets, and NPS
|
|
5
|
+
Project-URL: Homepage, https://withfeedback.com
|
|
6
|
+
Project-URL: Documentation, https://withfeedback.com/api/docs/
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: cli,feedback,testimonials,withfeedback
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: click>=8.1
|
|
11
|
+
Requires-Dist: httpx>=0.27
|
|
12
|
+
Requires-Dist: keyring>=24
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# withfeedback CLI
|
|
16
|
+
|
|
17
|
+
Command-line client for [withfeedback.com](https://withfeedback.com):
|
|
18
|
+
testimonials, feedback moderation, CSV imports, surveys, NPS, widgets, and
|
|
19
|
+
quota usage — from your terminal or CI.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install withfeedback
|
|
25
|
+
# or run without installing:
|
|
26
|
+
uvx withfeedback --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
The console command is `withfeedback`.
|
|
30
|
+
|
|
31
|
+
## Quick start with a personal access token (PAT)
|
|
32
|
+
|
|
33
|
+
Good for CI and quick scripting:
|
|
34
|
+
|
|
35
|
+
1. Log in at your withfeedback.com instance and open **Account → API Tokens**
|
|
36
|
+
(`/accounts/tokens/`).
|
|
37
|
+
2. Create a token with the scopes you need (see the table below).
|
|
38
|
+
3. Either export it per-shell:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
export WITHFEEDBACK_TOKEN=spd_abc123...
|
|
42
|
+
withfeedback testimonials list --team <TEAM_ID> --project <PROJECT_ID>
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
or persist it in the OS keychain:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
withfeedback login --token spd_abc123...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
API access requires a plan that includes the API/CLI/MCP feature (Pro and
|
|
52
|
+
above).
|
|
53
|
+
|
|
54
|
+
## Login via OAuth2 device flow (interactive)
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
withfeedback login
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
No flags needed against withfeedback.com — the CLI defaults to the official
|
|
61
|
+
public client id `7yQMsnY2Is2f5tCuwwgItoQu3fRkEX2wnzIRj0Vh` (public by
|
|
62
|
+
design — device-flow clients carry no secret). For a self-hosted instance
|
|
63
|
+
pass your own client:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
withfeedback login --client-id <CLIENT_ID> # or WITHFEEDBACK_CLIENT_ID
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
What happens:
|
|
70
|
+
|
|
71
|
+
1. The CLI calls `POST /o/device-authorization/` and prints a verification
|
|
72
|
+
URL plus a short code (it also tries to open your browser).
|
|
73
|
+
2. You open the URL, enter the code, and approve the requested scopes.
|
|
74
|
+
3. The CLI polls `POST /o/token/` until you approve, then stores the access
|
|
75
|
+
and refresh tokens in the **OS keychain** via `keyring` (macOS Keychain,
|
|
76
|
+
Windows Credential Locker, Secret Service/KWallet on Linux) — never in a
|
|
77
|
+
plaintext file.
|
|
78
|
+
|
|
79
|
+
When an access token expires, the CLI transparently refreshes it. Refresh
|
|
80
|
+
tokens rotate on every use; the rotated token replaces the stored one.
|
|
81
|
+
(Stored PATs are never "refreshed" — if a PAT is rejected, the CLI tells you
|
|
82
|
+
to create a new one and run `withfeedback login --token` again.)
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
withfeedback logout # remove stored tokens from the keychain
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Token resolution precedence for every command:
|
|
89
|
+
`--token` flag > `WITHFEEDBACK_TOKEN` env var > OS keychain.
|
|
90
|
+
|
|
91
|
+
## Commands
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
withfeedback login [--client-id <ID> | --token <PAT>]
|
|
95
|
+
withfeedback logout
|
|
96
|
+
|
|
97
|
+
withfeedback testimonials list --team <ID> --project <ID> [--status pending|approved|rejected|spam] [--rating N] [--q text] [--json]
|
|
98
|
+
withfeedback testimonials approve <SUBMISSION_ID> --team <ID> [--json]
|
|
99
|
+
withfeedback testimonials reject <SUBMISSION_ID> --team <ID> [--json]
|
|
100
|
+
withfeedback testimonials create --team <ID> --project <ID> --text "..." [--rating N] [--name ..] [--email ..] [--title ..] [--company ..] [--consent] [--tag t]... [--json]
|
|
101
|
+
|
|
102
|
+
withfeedback import csv FILE --team <ID> --project <ID> --text-col COL [--rating-col COL] [--name-col COL] [--email-col COL] [--consent] [--json]
|
|
103
|
+
|
|
104
|
+
withfeedback surveys list --team <ID> --project <ID> [--json]
|
|
105
|
+
withfeedback nps summary --team <ID> --project <ID> [--json]
|
|
106
|
+
withfeedback widgets list --team <ID> --project <ID> [--json]
|
|
107
|
+
withfeedback usage --team <ID> [--json]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Human output is plain text tables. Pass `--json` for machine-readable output
|
|
111
|
+
(raw API responses) — errors still go to stderr.
|
|
112
|
+
|
|
113
|
+
### CSV import
|
|
114
|
+
|
|
115
|
+
`withfeedback import csv` validates the file locally (header columns, row
|
|
116
|
+
count), builds the column mapping from your `--*-col` options, and uploads
|
|
117
|
+
the file to the server's multipart import endpoint. Rows are processed
|
|
118
|
+
asynchronously and land as **pending** submissions; the command prints the
|
|
119
|
+
batch id and a status URL to poll.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
withfeedback import csv reviews.csv \
|
|
123
|
+
--team <TEAM_ID> --project <PROJECT_ID> \
|
|
124
|
+
--text-col "Testimonial" --rating-col "Stars" \
|
|
125
|
+
--name-col "Author" --email-col "Email" --consent
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Pass `--consent` only when display consent was actually collected for these
|
|
129
|
+
rows.
|
|
130
|
+
|
|
131
|
+
## Required scopes per command
|
|
132
|
+
|
|
133
|
+
| Command | Required scope |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `testimonials list` | `read:feedback` (or `read:testimonials`) |
|
|
136
|
+
| `testimonials approve` / `reject` | `moderate:submissions` (`write:*` cannot moderate) |
|
|
137
|
+
| `testimonials create` | `write:testimonials` (or `write:feedback`) |
|
|
138
|
+
| `import csv` | `write:feedback` |
|
|
139
|
+
| `surveys list`, `nps summary` | `read:surveys` |
|
|
140
|
+
| `widgets list` | `read:widgets` |
|
|
141
|
+
| `usage` | `read:feedback` |
|
|
142
|
+
|
|
143
|
+
## Exit codes
|
|
144
|
+
|
|
145
|
+
| Code | Meaning |
|
|
146
|
+
|---|---|
|
|
147
|
+
| 0 | Success |
|
|
148
|
+
| 1 | General / unknown error |
|
|
149
|
+
| 2 | Usage / config error (bad flags, unreadable CSV, missing column) |
|
|
150
|
+
| 3 | Auth error (not logged in, invalid or expired token) |
|
|
151
|
+
| 4 | Forbidden or payment required (missing scope/role, plan without API access, quota exceeded, billing blocked) |
|
|
152
|
+
| 5 | Not found (unknown team/project/object or no access) |
|
|
153
|
+
| 6 | Validation error or invalid state transition |
|
|
154
|
+
| 7 | Network error (server unreachable) |
|
|
155
|
+
|
|
156
|
+
On API errors the API's error detail is printed to stderr.
|
|
157
|
+
|
|
158
|
+
## Environment variables
|
|
159
|
+
|
|
160
|
+
| Variable | Default | Purpose |
|
|
161
|
+
|---|---|---|
|
|
162
|
+
| `WITHFEEDBACK_API_URL` | `https://withfeedback.com` | API base URL (self-hosted/staging override) |
|
|
163
|
+
| `WITHFEEDBACK_TOKEN` | — | PAT (beats the keychain, loses to `--token`) |
|
|
164
|
+
| `WITHFEEDBACK_CLIENT_ID` | official public client id | Client id for `login` (override for self-hosted) |
|
|
165
|
+
| `WITHFEEDBACK_SCOPES` | all CLI scopes | Scopes requested during device-flow login |
|
|
166
|
+
|
|
167
|
+
On the first request of a run the CLI checks `GET /.well-known/speedpy.json`
|
|
168
|
+
once and warns (never fails) when the manifest is unreachable or the API is
|
|
169
|
+
older than the minimum this client expects.
|
|
170
|
+
|
|
171
|
+
## Development
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
uv run --with pytest --with httpx --with click --with keyring pytest packages/withfeedback-cli/
|
|
175
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
withfeedback_cli/__init__.py,sha256=clnmpqafsocCATdjRtqF8VsYdR2hUmHo0hnWRo05Ns0,59
|
|
2
|
+
withfeedback_cli/auth.py,sha256=oUQPdMKxFblM-Rekr3I5HrFaUvDlqaYTwsd4OiN5Bco,9295
|
|
3
|
+
withfeedback_cli/client.py,sha256=db2T4vEPZUmyJkng3CCmpk40HCIxx2HZBNRvxSfTbzQ,5445
|
|
4
|
+
withfeedback_cli/main.py,sha256=tbSS4EVMhiM2iEWhFHpsWdoU6Y15utk6QzZNp63W-fQ,20957
|
|
5
|
+
withfeedback-0.1.0.dist-info/METADATA,sha256=c-ZT9FZM5kXc06YBSbWfs6DimyetbEJB5wwEnfqO0gU,6295
|
|
6
|
+
withfeedback-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
7
|
+
withfeedback-0.1.0.dist-info/entry_points.txt,sha256=GpkGmOuJYp1tagxxF4n_4wawWiUt-eBntWcgzM-mwbY,59
|
|
8
|
+
withfeedback-0.1.0.dist-info/RECORD,,
|
withfeedback_cli/auth.py
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
"""Authentication for the withfeedback CLI.
|
|
2
|
+
|
|
3
|
+
Tokens live in the OS keychain via ``keyring`` (macOS Keychain, Windows
|
|
4
|
+
Credential Locker, Secret Service on Linux) — never in plaintext files.
|
|
5
|
+
|
|
6
|
+
Stored entries (service ``withfeedback-cli``):
|
|
7
|
+
|
|
8
|
+
- ``access_token`` — OAuth2 access token or PAT
|
|
9
|
+
- ``refresh_token`` — OAuth2 refresh token (rotation handled on refresh)
|
|
10
|
+
- ``token_kind`` — ``"oauth"`` or ``"pat"``
|
|
11
|
+
- ``client_id`` — OAuth2 client id used for login/refresh
|
|
12
|
+
|
|
13
|
+
Token resolution precedence for API calls:
|
|
14
|
+
``--token`` flag > ``WITHFEEDBACK_TOKEN`` env > keychain.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
import webbrowser
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
import keyring
|
|
26
|
+
import keyring.errors
|
|
27
|
+
|
|
28
|
+
from .client import APIError, TIMEOUT, USER_AGENT, get_base_url
|
|
29
|
+
|
|
30
|
+
KEYRING_SERVICE = "withfeedback-cli"
|
|
31
|
+
|
|
32
|
+
#: Official public device-flow OAuth2 client for withfeedback.com
|
|
33
|
+
#: (public by design — device-flow clients have no secret).
|
|
34
|
+
#: Override with --client-id / WITHFEEDBACK_CLIENT_ID for self-hosted
|
|
35
|
+
#: instances.
|
|
36
|
+
DEFAULT_CLIENT_ID = "7yQMsnY2Is2f5tCuwwgItoQu3fRkEX2wnzIRj0Vh"
|
|
37
|
+
|
|
38
|
+
#: Scopes requested during device-flow login — everything the CLI uses.
|
|
39
|
+
DEFAULT_SCOPES = (
|
|
40
|
+
"read:teams read:feedback write:feedback read:testimonials "
|
|
41
|
+
"write:testimonials moderate:submissions read:widgets read:surveys"
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
_KEYS = ("access_token", "refresh_token", "token_kind", "client_id")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
# Keychain storage
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def store_tokens(
|
|
53
|
+
access_token: str,
|
|
54
|
+
refresh_token: str = "",
|
|
55
|
+
*,
|
|
56
|
+
kind: str = "oauth",
|
|
57
|
+
client_id: str = "",
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Persist tokens to the OS keychain.
|
|
60
|
+
|
|
61
|
+
Writes the new values before deleting anything, so an interruption
|
|
62
|
+
mid-update never leaves the keychain without a usable access token.
|
|
63
|
+
"""
|
|
64
|
+
keyring.set_password(KEYRING_SERVICE, "access_token", access_token)
|
|
65
|
+
keyring.set_password(KEYRING_SERVICE, "token_kind", kind)
|
|
66
|
+
if client_id:
|
|
67
|
+
keyring.set_password(KEYRING_SERVICE, "client_id", client_id)
|
|
68
|
+
if refresh_token:
|
|
69
|
+
keyring.set_password(KEYRING_SERVICE, "refresh_token", refresh_token)
|
|
70
|
+
else:
|
|
71
|
+
# Delete last: everything new is already written at this point.
|
|
72
|
+
_delete_entry("refresh_token")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read(key: str) -> str:
|
|
76
|
+
try:
|
|
77
|
+
return keyring.get_password(KEYRING_SERVICE, key) or ""
|
|
78
|
+
except Exception:
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def stored_access_token() -> str:
|
|
83
|
+
return _read("access_token")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def stored_refresh_token() -> str:
|
|
87
|
+
return _read("refresh_token")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def stored_token_kind() -> str:
|
|
91
|
+
return _read("token_kind")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def stored_client_id() -> str:
|
|
95
|
+
return _read("client_id")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _delete_entry(key: str) -> None:
|
|
99
|
+
"""Delete a keychain entry. A missing entry is fine; any other failure
|
|
100
|
+
is logged (the stale credential is still in the keychain)."""
|
|
101
|
+
try:
|
|
102
|
+
keyring.delete_password(KEYRING_SERVICE, key)
|
|
103
|
+
except keyring.errors.PasswordDeleteError:
|
|
104
|
+
pass # entry didn't exist — nothing to delete
|
|
105
|
+
except Exception as exc:
|
|
106
|
+
print(
|
|
107
|
+
f"warning: could not delete keychain entry {key!r}: {exc}. "
|
|
108
|
+
"The stale credential may still be stored — remove it with your "
|
|
109
|
+
"OS keychain tool if needed.",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def clear_tokens() -> None:
|
|
115
|
+
for key in _KEYS:
|
|
116
|
+
_delete_entry(key)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# OAuth2 device flow + refresh-token rotation
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def device_flow(client_id: str, scope: str | None = None) -> dict:
|
|
125
|
+
"""Run the device authorization grant; return the full token response
|
|
126
|
+
(``access_token``, ``refresh_token``, ``expires_in``, ...)."""
|
|
127
|
+
base = get_base_url()
|
|
128
|
+
scope = scope or os.environ.get("WITHFEEDBACK_SCOPES", DEFAULT_SCOPES)
|
|
129
|
+
try:
|
|
130
|
+
resp = httpx.post(
|
|
131
|
+
f"{base}/o/device-authorization/",
|
|
132
|
+
data={"client_id": client_id, "scope": scope},
|
|
133
|
+
headers={
|
|
134
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
135
|
+
"User-Agent": USER_AGENT,
|
|
136
|
+
},
|
|
137
|
+
timeout=TIMEOUT,
|
|
138
|
+
)
|
|
139
|
+
resp.raise_for_status()
|
|
140
|
+
except httpx.RequestError as exc:
|
|
141
|
+
raise APIError(0, f"Network error contacting {base}: {exc}") from exc
|
|
142
|
+
except httpx.HTTPStatusError as exc:
|
|
143
|
+
raise APIError(
|
|
144
|
+
exc.response.status_code,
|
|
145
|
+
f"Device authorization failed: HTTP {exc.response.status_code}. "
|
|
146
|
+
"Is the client id correct?",
|
|
147
|
+
) from exc
|
|
148
|
+
data = resp.json()
|
|
149
|
+
|
|
150
|
+
verification_uri = data.get("verification_uri_complete") or data["verification_uri"]
|
|
151
|
+
user_code = data["user_code"]
|
|
152
|
+
device_code = data["device_code"]
|
|
153
|
+
interval = data.get("interval", 5)
|
|
154
|
+
expires_in = data.get("expires_in", 600)
|
|
155
|
+
|
|
156
|
+
print(
|
|
157
|
+
f"\n Open this URL in your browser:\n {verification_uri}\n"
|
|
158
|
+
f"\n Enter code: {user_code}\n",
|
|
159
|
+
file=sys.stderr,
|
|
160
|
+
)
|
|
161
|
+
try:
|
|
162
|
+
webbrowser.open(verification_uri)
|
|
163
|
+
except Exception:
|
|
164
|
+
pass # headless environments
|
|
165
|
+
|
|
166
|
+
# Poll no longer than the device code is valid (RFC 8628 §3.5): the
|
|
167
|
+
# deadline caps the loop, and each sleep is clamped to the time left.
|
|
168
|
+
deadline = time.monotonic() + expires_in
|
|
169
|
+
while True:
|
|
170
|
+
remaining = deadline - time.monotonic()
|
|
171
|
+
if remaining <= 0:
|
|
172
|
+
break
|
|
173
|
+
time.sleep(min(interval, remaining))
|
|
174
|
+
try:
|
|
175
|
+
token_resp = httpx.post(
|
|
176
|
+
f"{base}/o/token/",
|
|
177
|
+
data={
|
|
178
|
+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
|
|
179
|
+
"device_code": device_code,
|
|
180
|
+
"client_id": client_id,
|
|
181
|
+
},
|
|
182
|
+
headers={
|
|
183
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
184
|
+
"User-Agent": USER_AGENT,
|
|
185
|
+
},
|
|
186
|
+
timeout=TIMEOUT,
|
|
187
|
+
)
|
|
188
|
+
except httpx.HTTPError as exc:
|
|
189
|
+
raise APIError(
|
|
190
|
+
0,
|
|
191
|
+
f"Network error while waiting for device-flow approval: {exc}. "
|
|
192
|
+
"Check your connection and run 'withfeedback login' again.",
|
|
193
|
+
) from exc
|
|
194
|
+
if token_resp.status_code == 200:
|
|
195
|
+
print(" Authenticated successfully.\n", file=sys.stderr)
|
|
196
|
+
return token_resp.json()
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
error = token_resp.json().get("error", "")
|
|
200
|
+
except ValueError:
|
|
201
|
+
raise APIError(
|
|
202
|
+
token_resp.status_code,
|
|
203
|
+
f"Device flow: token endpoint returned HTTP "
|
|
204
|
+
f"{token_resp.status_code}.",
|
|
205
|
+
)
|
|
206
|
+
if error == "authorization_pending":
|
|
207
|
+
continue
|
|
208
|
+
if error == "slow_down":
|
|
209
|
+
# RFC 8628 §3.5: increase the polling interval by 5 seconds.
|
|
210
|
+
interval += 5
|
|
211
|
+
continue
|
|
212
|
+
raise APIError(token_resp.status_code, f"Device flow error: {error}")
|
|
213
|
+
|
|
214
|
+
raise APIError(0, "Device code expired before approval. Run login again.")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def refresh_access_token() -> str:
|
|
218
|
+
"""Exchange the stored refresh token for a new pair.
|
|
219
|
+
|
|
220
|
+
Refresh tokens rotate server-side: the response's new refresh token
|
|
221
|
+
replaces the stored one. Returns the new access token, or "" when
|
|
222
|
+
refresh isn't possible (no refresh token / refresh rejected).
|
|
223
|
+
"""
|
|
224
|
+
refresh_token = stored_refresh_token()
|
|
225
|
+
client_id = stored_client_id()
|
|
226
|
+
if not refresh_token or not client_id:
|
|
227
|
+
return ""
|
|
228
|
+
|
|
229
|
+
base = get_base_url()
|
|
230
|
+
try:
|
|
231
|
+
resp = httpx.post(
|
|
232
|
+
f"{base}/o/token/",
|
|
233
|
+
data={
|
|
234
|
+
"grant_type": "refresh_token",
|
|
235
|
+
"refresh_token": refresh_token,
|
|
236
|
+
"client_id": client_id,
|
|
237
|
+
},
|
|
238
|
+
headers={
|
|
239
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
240
|
+
"User-Agent": USER_AGENT,
|
|
241
|
+
},
|
|
242
|
+
timeout=TIMEOUT,
|
|
243
|
+
)
|
|
244
|
+
except httpx.HTTPError:
|
|
245
|
+
return ""
|
|
246
|
+
if resp.status_code != 200:
|
|
247
|
+
return ""
|
|
248
|
+
|
|
249
|
+
payload = resp.json()
|
|
250
|
+
access_token = payload.get("access_token", "")
|
|
251
|
+
if not access_token:
|
|
252
|
+
return ""
|
|
253
|
+
store_tokens(
|
|
254
|
+
access_token,
|
|
255
|
+
payload.get("refresh_token", ""), # rotated refresh token
|
|
256
|
+
kind="oauth",
|
|
257
|
+
client_id=client_id,
|
|
258
|
+
)
|
|
259
|
+
return access_token
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ---------------------------------------------------------------------------
|
|
263
|
+
# Token resolution
|
|
264
|
+
# ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def resolve_token(flag_token: str = "") -> tuple[str, str]:
|
|
268
|
+
"""Resolve (token, source) — source is "flag", "env", or "keyring".
|
|
269
|
+
|
|
270
|
+
Raises :class:`APIError` when no credentials exist anywhere.
|
|
271
|
+
"""
|
|
272
|
+
if flag_token:
|
|
273
|
+
return flag_token, "flag"
|
|
274
|
+
env_token = os.environ.get("WITHFEEDBACK_TOKEN", "")
|
|
275
|
+
if env_token:
|
|
276
|
+
return env_token, "env"
|
|
277
|
+
stored = stored_access_token()
|
|
278
|
+
if stored:
|
|
279
|
+
return stored, "keyring"
|
|
280
|
+
raise APIError(
|
|
281
|
+
0,
|
|
282
|
+
"Not authenticated. Run 'withfeedback login' (device flow), "
|
|
283
|
+
"'withfeedback login --token <PAT>', or set WITHFEEDBACK_TOKEN.",
|
|
284
|
+
)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Tiny internal HTTP client for the withfeedback.com API (CLI edition).
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
|
|
5
|
+
- Base URL resolution (``WITHFEEDBACK_API_URL`` env override, default
|
|
6
|
+
``https://withfeedback.com``).
|
|
7
|
+
- Bearer auth header (token is resolved by :mod:`withfeedback_cli.auth`).
|
|
8
|
+
- 30 second request timeout.
|
|
9
|
+
- One-shot, in-process cached version-compatibility check against
|
|
10
|
+
``GET /.well-known/speedpy.json`` — warns (never fails) when the
|
|
11
|
+
manifest is unreachable or older than we expect.
|
|
12
|
+
- Readable :class:`APIError` messages carrying the API's error detail.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
|
|
25
|
+
DEFAULT_BASE_URL = "https://withfeedback.com"
|
|
26
|
+
MIN_API_VERSION = "1.0.0"
|
|
27
|
+
USER_AGENT = f"withfeedback-cli/{__version__}"
|
|
28
|
+
TIMEOUT = 30.0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class APIError(Exception):
|
|
32
|
+
"""API error with a readable message and the HTTP status (0 = network)."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, status_code: int, message: str):
|
|
35
|
+
self.status_code = status_code
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_base_url() -> str:
|
|
40
|
+
"""Resolve the API base URL (env override, no trailing slash)."""
|
|
41
|
+
return os.environ.get("WITHFEEDBACK_API_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Version compatibility (manifest) check — cached in process, warn-only
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
_manifest_state: dict[str, Any] = {"checked": False, "manifest": None}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_version(value: str) -> tuple[int, ...]:
|
|
52
|
+
return tuple(int(part) for part in value.strip().split("."))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def check_compatibility() -> dict | None:
|
|
56
|
+
"""Fetch ``/.well-known/speedpy.json`` once per process; warn, never fail."""
|
|
57
|
+
if _manifest_state["checked"]:
|
|
58
|
+
return _manifest_state["manifest"]
|
|
59
|
+
_manifest_state["checked"] = True
|
|
60
|
+
|
|
61
|
+
base = get_base_url()
|
|
62
|
+
try:
|
|
63
|
+
resp = httpx.get(
|
|
64
|
+
f"{base}/.well-known/speedpy.json",
|
|
65
|
+
timeout=TIMEOUT,
|
|
66
|
+
headers={"User-Agent": USER_AGENT},
|
|
67
|
+
)
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
manifest = resp.json()
|
|
70
|
+
except Exception:
|
|
71
|
+
print(
|
|
72
|
+
f"warning: could not fetch {base}/.well-known/speedpy.json — "
|
|
73
|
+
"skipping API version compatibility check.",
|
|
74
|
+
file=sys.stderr,
|
|
75
|
+
)
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
_manifest_state["manifest"] = manifest
|
|
79
|
+
api_version = str(manifest.get("service", {}).get("api_version", ""))
|
|
80
|
+
try:
|
|
81
|
+
if api_version and _parse_version(api_version) < _parse_version(MIN_API_VERSION):
|
|
82
|
+
print(
|
|
83
|
+
f"warning: server API version {api_version} is older than the "
|
|
84
|
+
f"minimum this client expects ({MIN_API_VERSION}); some "
|
|
85
|
+
"commands may not work.",
|
|
86
|
+
file=sys.stderr,
|
|
87
|
+
)
|
|
88
|
+
except ValueError:
|
|
89
|
+
pass
|
|
90
|
+
return manifest
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def reset_compatibility_cache() -> None:
|
|
94
|
+
"""Testing hook: forget the cached manifest check."""
|
|
95
|
+
_manifest_state["checked"] = False
|
|
96
|
+
_manifest_state["manifest"] = None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
# Requests
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
_STATUS_HINTS = {
|
|
104
|
+
400: "Validation error.",
|
|
105
|
+
401: "Authentication failed — run 'withfeedback login' or check your token.",
|
|
106
|
+
402: "Payment required — plan quota exceeded or billing blocked.",
|
|
107
|
+
403: (
|
|
108
|
+
"Forbidden — missing scope, insufficient role, or the team's plan "
|
|
109
|
+
"does not include API access."
|
|
110
|
+
),
|
|
111
|
+
404: "Not found — unknown team/project/object, or no access to it.",
|
|
112
|
+
409: "Conflict — invalid state transition.",
|
|
113
|
+
429: "Rate limited — slow down and retry later.",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _error_message(resp: httpx.Response) -> str:
|
|
118
|
+
try:
|
|
119
|
+
body = resp.json()
|
|
120
|
+
except ValueError:
|
|
121
|
+
body = None
|
|
122
|
+
|
|
123
|
+
detail = ""
|
|
124
|
+
if isinstance(body, dict):
|
|
125
|
+
if "detail" in body:
|
|
126
|
+
detail = str(body["detail"])
|
|
127
|
+
if body.get("code"):
|
|
128
|
+
detail = f"[{body['code']}] {detail}"
|
|
129
|
+
else:
|
|
130
|
+
detail = "; ".join(f"{key}: {value}" for key, value in body.items())
|
|
131
|
+
|
|
132
|
+
hint = _STATUS_HINTS.get(resp.status_code, "API error.")
|
|
133
|
+
message = f"HTTP {resp.status_code}: {hint}"
|
|
134
|
+
if detail:
|
|
135
|
+
message = f"{message} {detail}"
|
|
136
|
+
return message
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def request(
|
|
140
|
+
method: str,
|
|
141
|
+
path: str,
|
|
142
|
+
token: str,
|
|
143
|
+
*,
|
|
144
|
+
params: dict | None = None,
|
|
145
|
+
json_body: dict | None = None,
|
|
146
|
+
data: dict | None = None,
|
|
147
|
+
files: dict | None = None,
|
|
148
|
+
) -> Any:
|
|
149
|
+
"""Authenticated request (JSON or multipart). Raises :class:`APIError`."""
|
|
150
|
+
check_compatibility()
|
|
151
|
+
url = f"{get_base_url()}{path}"
|
|
152
|
+
try:
|
|
153
|
+
resp = httpx.request(
|
|
154
|
+
method,
|
|
155
|
+
url,
|
|
156
|
+
params=params,
|
|
157
|
+
json=json_body,
|
|
158
|
+
data=data,
|
|
159
|
+
files=files,
|
|
160
|
+
headers={
|
|
161
|
+
"Authorization": f"Bearer {token}",
|
|
162
|
+
"User-Agent": USER_AGENT,
|
|
163
|
+
},
|
|
164
|
+
timeout=TIMEOUT,
|
|
165
|
+
)
|
|
166
|
+
except httpx.HTTPError as exc:
|
|
167
|
+
raise APIError(
|
|
168
|
+
0, f"Network error contacting {get_base_url()}: {exc}"
|
|
169
|
+
) from exc
|
|
170
|
+
|
|
171
|
+
if resp.status_code >= 400:
|
|
172
|
+
raise APIError(resp.status_code, _error_message(resp))
|
|
173
|
+
if resp.status_code == 204 or not resp.content:
|
|
174
|
+
return None
|
|
175
|
+
return resp.json()
|
withfeedback_cli/main.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""withfeedback — command-line client for withfeedback.com.
|
|
2
|
+
|
|
3
|
+
Human output is plain text tables; pass ``--json`` on any data command for
|
|
4
|
+
machine-readable output. API errors print the API's error detail to stderr
|
|
5
|
+
and exit non-zero (see exit-code table in the README).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
|
|
18
|
+
from . import auth
|
|
19
|
+
from .client import APIError, request
|
|
20
|
+
|
|
21
|
+
EXIT_GENERAL = 1
|
|
22
|
+
EXIT_USAGE = 2
|
|
23
|
+
EXIT_AUTH = 3
|
|
24
|
+
EXIT_FORBIDDEN = 4
|
|
25
|
+
EXIT_NOT_FOUND = 5
|
|
26
|
+
EXIT_VALIDATION = 6
|
|
27
|
+
EXIT_NETWORK = 7
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _exit_code(error: APIError) -> int:
|
|
31
|
+
status = error.status_code
|
|
32
|
+
if status == 401:
|
|
33
|
+
return EXIT_AUTH
|
|
34
|
+
if status in (402, 403):
|
|
35
|
+
return EXIT_FORBIDDEN
|
|
36
|
+
if status == 404:
|
|
37
|
+
return EXIT_NOT_FOUND
|
|
38
|
+
if status in (400, 409):
|
|
39
|
+
return EXIT_VALIDATION
|
|
40
|
+
if status == 0:
|
|
41
|
+
message = str(error)
|
|
42
|
+
if "Network error" in message:
|
|
43
|
+
return EXIT_NETWORK
|
|
44
|
+
if "Not authenticated" in message:
|
|
45
|
+
return EXIT_AUTH
|
|
46
|
+
return EXIT_GENERAL
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _fail(error: APIError) -> None:
|
|
50
|
+
"""Print the API's error detail to stderr and exit non-zero."""
|
|
51
|
+
click.echo(str(error), err=True)
|
|
52
|
+
sys.exit(_exit_code(error))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def api_request(method: str, path: str, *, token_flag: str = "", **kwargs) -> Any:
|
|
56
|
+
"""Authenticated request with a single 401-triggered token refresh.
|
|
57
|
+
|
|
58
|
+
Refresh only applies to keychain-stored OAuth tokens. A rejected PAT is
|
|
59
|
+
never "refreshed" — the stored refresh credentials would belong to a
|
|
60
|
+
different login — so a PAT 401 becomes a clear re-login instruction.
|
|
61
|
+
"""
|
|
62
|
+
token, source = auth.resolve_token(token_flag)
|
|
63
|
+
try:
|
|
64
|
+
return request(method, path, token, **kwargs)
|
|
65
|
+
except APIError as error:
|
|
66
|
+
if error.status_code == 401 and source == "keyring":
|
|
67
|
+
kind = auth.stored_token_kind()
|
|
68
|
+
if kind == "pat":
|
|
69
|
+
raise APIError(
|
|
70
|
+
401,
|
|
71
|
+
"The stored personal access token was rejected (revoked "
|
|
72
|
+
"or expired). Create a new token at /accounts/tokens/ and "
|
|
73
|
+
"run 'withfeedback login --token <PAT>' again.",
|
|
74
|
+
) from error
|
|
75
|
+
new_token = auth.refresh_access_token()
|
|
76
|
+
if new_token:
|
|
77
|
+
return request(method, path, new_token, **kwargs)
|
|
78
|
+
raise
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _emit(data: Any, json_mode: bool, human) -> None:
|
|
82
|
+
"""Print JSON in --json mode, else call the human formatter."""
|
|
83
|
+
if json_mode:
|
|
84
|
+
click.echo(json.dumps(data, indent=2))
|
|
85
|
+
else:
|
|
86
|
+
human(data)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _rows(data: Any) -> list[dict]:
|
|
90
|
+
"""Unwrap DRF pagination envelopes into a plain list."""
|
|
91
|
+
if isinstance(data, dict):
|
|
92
|
+
return data.get("results", [])
|
|
93
|
+
return data or []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def print_table(headers: list[str], rows: list[list[str]]) -> None:
|
|
97
|
+
"""Plain fixed-width table (no external dependencies)."""
|
|
98
|
+
if not rows:
|
|
99
|
+
click.echo("(no results)")
|
|
100
|
+
return
|
|
101
|
+
table = [headers] + [[str(cell) for cell in row] for row in rows]
|
|
102
|
+
widths = [max(len(row[i]) for row in table) for i in range(len(headers))]
|
|
103
|
+
for index, row in enumerate(table):
|
|
104
|
+
click.echo(" ".join(cell.ljust(width) for cell, width in zip(row, widths)).rstrip())
|
|
105
|
+
if index == 0:
|
|
106
|
+
click.echo(" ".join("-" * width for width in widths))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _truncate(text: str, limit: int = 50) -> str:
|
|
110
|
+
text = (text or "").replace("\n", " ")
|
|
111
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Root group + auth commands
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@click.group()
|
|
120
|
+
@click.version_option(package_name="withfeedback", prog_name="withfeedback")
|
|
121
|
+
def cli() -> None:
|
|
122
|
+
"""withfeedback.com — testimonials, feedback, widgets, and NPS.
|
|
123
|
+
|
|
124
|
+
Base URL defaults to https://withfeedback.com; override with the
|
|
125
|
+
WITHFEEDBACK_API_URL environment variable.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@cli.command()
|
|
130
|
+
@click.option("--client-id", envvar="WITHFEEDBACK_CLIENT_ID",
|
|
131
|
+
default=auth.DEFAULT_CLIENT_ID, show_default=True,
|
|
132
|
+
help="OAuth2 device-flow client id (default: the official "
|
|
133
|
+
"withfeedback.com public client).")
|
|
134
|
+
@click.option("--token", default="",
|
|
135
|
+
help="Skip the device flow and store this personal access token instead.")
|
|
136
|
+
@click.option("--scope", default=None, help="Scopes to request (device flow only).")
|
|
137
|
+
def login(client_id: str, token: str, scope: str | None) -> None:
|
|
138
|
+
"""Authenticate and store tokens in the OS keychain.
|
|
139
|
+
|
|
140
|
+
Default: OAuth2 device flow (opens a browser, you enter a code).
|
|
141
|
+
With --token, the personal access token is stored instead.
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
if token:
|
|
145
|
+
auth.store_tokens(token, kind="pat")
|
|
146
|
+
click.echo("Personal access token stored in the OS keychain.")
|
|
147
|
+
return
|
|
148
|
+
if not client_id:
|
|
149
|
+
click.echo(
|
|
150
|
+
"Empty --client-id. Pass a device-flow client id or use "
|
|
151
|
+
"--token to store a personal access token.",
|
|
152
|
+
err=True,
|
|
153
|
+
)
|
|
154
|
+
sys.exit(EXIT_USAGE)
|
|
155
|
+
payload = auth.device_flow(client_id, scope)
|
|
156
|
+
auth.store_tokens(
|
|
157
|
+
payload["access_token"],
|
|
158
|
+
payload.get("refresh_token", ""),
|
|
159
|
+
kind="oauth",
|
|
160
|
+
client_id=client_id,
|
|
161
|
+
)
|
|
162
|
+
click.echo("Logged in. Tokens stored in the OS keychain.")
|
|
163
|
+
except APIError as error:
|
|
164
|
+
_fail(error)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@cli.command()
|
|
168
|
+
def logout() -> None:
|
|
169
|
+
"""Remove stored tokens from the OS keychain."""
|
|
170
|
+
auth.clear_tokens()
|
|
171
|
+
click.echo("Logged out — stored tokens removed.")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Testimonials
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@cli.group()
|
|
180
|
+
def testimonials() -> None:
|
|
181
|
+
"""List, moderate, and create testimonials."""
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@testimonials.command(name="list")
|
|
185
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
186
|
+
@click.option("--project", required=True, type=click.UUID, help="Project UUID.")
|
|
187
|
+
@click.option("--status", default=None,
|
|
188
|
+
type=click.Choice(["pending", "approved", "rejected", "spam"]),
|
|
189
|
+
help="Filter by moderation status.")
|
|
190
|
+
@click.option("--kind", default="testimonial",
|
|
191
|
+
type=click.Choice(["testimonial", "feedback", "survey_response", "all"]),
|
|
192
|
+
show_default=True, help="Submission kind ('all' disables the filter).")
|
|
193
|
+
@click.option("--rating", type=click.IntRange(1, 5), default=None,
|
|
194
|
+
help="Filter by exact star rating.")
|
|
195
|
+
@click.option("--q", "query", default=None, help="Search within the text.")
|
|
196
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
197
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
198
|
+
def testimonials_list(team, project, status, kind, rating, query, json_mode, token_flag):
|
|
199
|
+
"""List a project's testimonials (scope: read:feedback)."""
|
|
200
|
+
params: dict[str, Any] = {}
|
|
201
|
+
if kind != "all":
|
|
202
|
+
params["kind"] = kind
|
|
203
|
+
if status:
|
|
204
|
+
params["status"] = status
|
|
205
|
+
if rating is not None:
|
|
206
|
+
params["rating"] = rating
|
|
207
|
+
if query:
|
|
208
|
+
params["q"] = query
|
|
209
|
+
try:
|
|
210
|
+
data = api_request(
|
|
211
|
+
"GET",
|
|
212
|
+
f"/api/v1/teams/{team}/projects/{project}/submissions/",
|
|
213
|
+
params=params or None,
|
|
214
|
+
token_flag=token_flag,
|
|
215
|
+
)
|
|
216
|
+
except APIError as error:
|
|
217
|
+
_fail(error)
|
|
218
|
+
|
|
219
|
+
def human(data):
|
|
220
|
+
rows = [
|
|
221
|
+
[
|
|
222
|
+
item.get("id", ""),
|
|
223
|
+
item.get("status", ""),
|
|
224
|
+
item.get("rating") if item.get("rating") is not None else "-",
|
|
225
|
+
(item.get("contact") or {}).get("name") or "-",
|
|
226
|
+
(item.get("created_at") or "")[:10],
|
|
227
|
+
_truncate(item.get("text", "")),
|
|
228
|
+
]
|
|
229
|
+
for item in _rows(data)
|
|
230
|
+
]
|
|
231
|
+
print_table(["ID", "STATUS", "RATING", "NAME", "CREATED", "TEXT"], rows)
|
|
232
|
+
if isinstance(data, dict) and data.get("next"):
|
|
233
|
+
click.echo("(more results available — use --json to page with the cursor)")
|
|
234
|
+
|
|
235
|
+
_emit(data, json_mode, human)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _moderation_command(action: str):
|
|
239
|
+
@click.argument("submission_id", type=click.UUID)
|
|
240
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
241
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
242
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
243
|
+
def command(submission_id, team, json_mode, token_flag):
|
|
244
|
+
try:
|
|
245
|
+
data = api_request(
|
|
246
|
+
"POST",
|
|
247
|
+
f"/api/v1/teams/{team}/submissions/{submission_id}/{action}/",
|
|
248
|
+
token_flag=token_flag,
|
|
249
|
+
)
|
|
250
|
+
except APIError as error:
|
|
251
|
+
_fail(error)
|
|
252
|
+
_emit(
|
|
253
|
+
data,
|
|
254
|
+
json_mode,
|
|
255
|
+
lambda d: click.echo(f"{action.capitalize()}d {d.get('id')} (status: {d.get('status')})"),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return command
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
testimonials.command(name="approve", help=(
|
|
262
|
+
"Approve a submission (scope: moderate:submissions)."
|
|
263
|
+
))(_moderation_command("approve"))
|
|
264
|
+
testimonials.command(name="reject", help=(
|
|
265
|
+
"Reject or unpublish a submission (scope: moderate:submissions)."
|
|
266
|
+
))(_moderation_command("reject"))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@testimonials.command(name="create")
|
|
270
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
271
|
+
@click.option("--project", required=True, type=click.UUID, help="Project UUID.")
|
|
272
|
+
@click.option("--text", required=True, help="Testimonial text.")
|
|
273
|
+
@click.option("--rating", type=click.IntRange(1, 5), default=None, help="Star rating (1-5).")
|
|
274
|
+
@click.option("--name", default="", help="Author name.")
|
|
275
|
+
@click.option("--email", default="", help="Author email.")
|
|
276
|
+
@click.option("--title", "job_title", default="", help="Author job title.")
|
|
277
|
+
@click.option("--company", default="", help="Author company.")
|
|
278
|
+
@click.option("--consent/--no-consent", "consent_display", default=False,
|
|
279
|
+
help="Whether display consent was collected.")
|
|
280
|
+
@click.option("--tag", "tags", multiple=True, help="Tag (repeatable).")
|
|
281
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
282
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
283
|
+
def testimonials_create(team, project, text, rating, name, email, job_title,
|
|
284
|
+
company, consent_display, tags, json_mode, token_flag):
|
|
285
|
+
"""Create a pending testimonial (scope: write:testimonials)."""
|
|
286
|
+
body: dict[str, Any] = {
|
|
287
|
+
"kind": "testimonial",
|
|
288
|
+
"text": text,
|
|
289
|
+
"consent_display": consent_display,
|
|
290
|
+
}
|
|
291
|
+
if rating is not None:
|
|
292
|
+
body["rating"] = rating
|
|
293
|
+
if tags:
|
|
294
|
+
body["tags"] = list(tags)
|
|
295
|
+
if email:
|
|
296
|
+
body["contact_email"] = email
|
|
297
|
+
if name:
|
|
298
|
+
body["contact_name"] = name
|
|
299
|
+
if job_title:
|
|
300
|
+
body["contact_title"] = job_title
|
|
301
|
+
if company:
|
|
302
|
+
body["contact_company"] = company
|
|
303
|
+
try:
|
|
304
|
+
data = api_request(
|
|
305
|
+
"POST",
|
|
306
|
+
f"/api/v1/teams/{team}/projects/{project}/submissions/",
|
|
307
|
+
json_body=body,
|
|
308
|
+
token_flag=token_flag,
|
|
309
|
+
)
|
|
310
|
+
except APIError as error:
|
|
311
|
+
_fail(error)
|
|
312
|
+
_emit(
|
|
313
|
+
data,
|
|
314
|
+
json_mode,
|
|
315
|
+
lambda d: click.echo(f"Created {d.get('id')} (status: {d.get('status')})"),
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
# CSV import
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@cli.group(name="import")
|
|
325
|
+
def import_group() -> None:
|
|
326
|
+
"""Bulk imports."""
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@import_group.command(name="csv")
|
|
330
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False))
|
|
331
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
332
|
+
@click.option("--project", required=True, type=click.UUID, help="Project UUID.")
|
|
333
|
+
@click.option("--text-col", required=True, help="CSV column with the testimonial text.")
|
|
334
|
+
@click.option("--rating-col", default=None, help="CSV column with the 1-5 rating.")
|
|
335
|
+
@click.option("--name-col", default=None, help="CSV column with the author name.")
|
|
336
|
+
@click.option("--email-col", default=None, help="CSV column with the author email.")
|
|
337
|
+
@click.option("--consent/--no-consent", default=False,
|
|
338
|
+
help="Whether display consent was collected for these rows.")
|
|
339
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
340
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
341
|
+
def import_csv(file, team, project, text_col, rating_col, name_col, email_col,
|
|
342
|
+
consent, json_mode, token_flag):
|
|
343
|
+
"""Import testimonials from a CSV file (scope: write:feedback).
|
|
344
|
+
|
|
345
|
+
The file is validated locally (headers, row count), then uploaded to the
|
|
346
|
+
server's import endpoint; rows land as pending submissions and are
|
|
347
|
+
processed asynchronously.
|
|
348
|
+
"""
|
|
349
|
+
try:
|
|
350
|
+
with open(file, "r", encoding="utf-8-sig", newline="") as handle:
|
|
351
|
+
content = handle.read()
|
|
352
|
+
except OSError as error:
|
|
353
|
+
click.echo(f"Cannot read {file}: {error}", err=True)
|
|
354
|
+
sys.exit(EXIT_USAGE)
|
|
355
|
+
|
|
356
|
+
reader = csv.DictReader(io.StringIO(content))
|
|
357
|
+
headers = reader.fieldnames or []
|
|
358
|
+
mapping = {"text": text_col}
|
|
359
|
+
if rating_col:
|
|
360
|
+
mapping["rating"] = rating_col
|
|
361
|
+
if name_col:
|
|
362
|
+
mapping["name"] = name_col
|
|
363
|
+
if email_col:
|
|
364
|
+
mapping["email"] = email_col
|
|
365
|
+
|
|
366
|
+
missing = [col for col in mapping.values() if col not in headers]
|
|
367
|
+
if missing:
|
|
368
|
+
click.echo(
|
|
369
|
+
f"Column(s) not found in the CSV header: {', '.join(missing)}. "
|
|
370
|
+
f"Available columns: {', '.join(headers) or '(none)'}",
|
|
371
|
+
err=True,
|
|
372
|
+
)
|
|
373
|
+
sys.exit(EXIT_USAGE)
|
|
374
|
+
|
|
375
|
+
row_count = sum(1 for _ in reader)
|
|
376
|
+
if row_count == 0:
|
|
377
|
+
click.echo("The CSV has a header but no data rows.", err=True)
|
|
378
|
+
sys.exit(EXIT_USAGE)
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
data = api_request(
|
|
382
|
+
"POST",
|
|
383
|
+
f"/api/v1/teams/{team}/projects/{project}/imports/",
|
|
384
|
+
data={
|
|
385
|
+
"mapping": json.dumps(mapping),
|
|
386
|
+
"consent": "true" if consent else "false",
|
|
387
|
+
},
|
|
388
|
+
files={"file": (file.rsplit("/", 1)[-1], content.encode("utf-8"), "text/csv")},
|
|
389
|
+
token_flag=token_flag,
|
|
390
|
+
)
|
|
391
|
+
except APIError as error:
|
|
392
|
+
_fail(error)
|
|
393
|
+
|
|
394
|
+
def human(d):
|
|
395
|
+
click.echo(f"Import accepted: batch {d.get('id')} ({row_count} rows).")
|
|
396
|
+
click.echo(f"Status: {d.get('status')}")
|
|
397
|
+
if d.get("status_url"):
|
|
398
|
+
click.echo(f"Poll: {d['status_url']}")
|
|
399
|
+
|
|
400
|
+
_emit(data, json_mode, human)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# Surveys / NPS
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
@cli.group()
|
|
409
|
+
def surveys() -> None:
|
|
410
|
+
"""Surveys."""
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@surveys.command(name="list")
|
|
414
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
415
|
+
@click.option("--project", required=True, type=click.UUID, help="Project UUID.")
|
|
416
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
417
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
418
|
+
def surveys_list(team, project, json_mode, token_flag):
|
|
419
|
+
"""List a project's surveys (scope: read:surveys)."""
|
|
420
|
+
try:
|
|
421
|
+
data = api_request(
|
|
422
|
+
"GET",
|
|
423
|
+
f"/api/v1/teams/{team}/projects/{project}/surveys/",
|
|
424
|
+
token_flag=token_flag,
|
|
425
|
+
)
|
|
426
|
+
except APIError as error:
|
|
427
|
+
_fail(error)
|
|
428
|
+
|
|
429
|
+
def human(data):
|
|
430
|
+
rows = [
|
|
431
|
+
[
|
|
432
|
+
item.get("id", ""),
|
|
433
|
+
item.get("name", ""),
|
|
434
|
+
item.get("survey_type", ""),
|
|
435
|
+
item.get("status", ""),
|
|
436
|
+
item.get("current_version") if item.get("current_version") is not None else "-",
|
|
437
|
+
]
|
|
438
|
+
for item in _rows(data)
|
|
439
|
+
]
|
|
440
|
+
print_table(["ID", "NAME", "TYPE", "STATUS", "VERSION"], rows)
|
|
441
|
+
|
|
442
|
+
_emit(data, json_mode, human)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
@cli.group()
|
|
446
|
+
def nps() -> None:
|
|
447
|
+
"""NPS surveys."""
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@nps.command(name="summary")
|
|
451
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
452
|
+
@click.option("--project", required=True, type=click.UUID, help="Project UUID.")
|
|
453
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
454
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
455
|
+
def nps_summary(team, project, json_mode, token_flag):
|
|
456
|
+
"""NPS results per NPS survey (scope: read:surveys)."""
|
|
457
|
+
try:
|
|
458
|
+
surveys_data = api_request(
|
|
459
|
+
"GET",
|
|
460
|
+
f"/api/v1/teams/{team}/projects/{project}/surveys/",
|
|
461
|
+
token_flag=token_flag,
|
|
462
|
+
)
|
|
463
|
+
nps_surveys = [
|
|
464
|
+
s for s in _rows(surveys_data) if s.get("survey_type") == "nps"
|
|
465
|
+
]
|
|
466
|
+
summaries = []
|
|
467
|
+
for survey in nps_surveys:
|
|
468
|
+
results = api_request(
|
|
469
|
+
"GET",
|
|
470
|
+
f"/api/v1/teams/{team}/projects/{project}/surveys/"
|
|
471
|
+
f"{survey['id']}/results/",
|
|
472
|
+
token_flag=token_flag,
|
|
473
|
+
)
|
|
474
|
+
summaries.append(
|
|
475
|
+
{
|
|
476
|
+
"survey_id": survey["id"],
|
|
477
|
+
"name": survey.get("name"),
|
|
478
|
+
"status": survey.get("status"),
|
|
479
|
+
"results": results,
|
|
480
|
+
}
|
|
481
|
+
)
|
|
482
|
+
except APIError as error:
|
|
483
|
+
_fail(error)
|
|
484
|
+
|
|
485
|
+
payload = {"project_id": project, "nps_surveys": summaries}
|
|
486
|
+
|
|
487
|
+
def human(payload):
|
|
488
|
+
if not payload["nps_surveys"]:
|
|
489
|
+
click.echo("This project has no NPS surveys.")
|
|
490
|
+
return
|
|
491
|
+
for entry in payload["nps_surveys"]:
|
|
492
|
+
results = entry["results"] or {}
|
|
493
|
+
current = next(
|
|
494
|
+
(v for v in results.get("versions", []) if v.get("is_current")),
|
|
495
|
+
None,
|
|
496
|
+
) or (results.get("versions") or [{}])[-1]
|
|
497
|
+
nps_value = current.get("nps")
|
|
498
|
+
click.echo(f"{entry['name']} ({entry['survey_id']})")
|
|
499
|
+
click.echo(f" NPS: {nps_value if nps_value is not None else 'n/a'}")
|
|
500
|
+
click.echo(f" Promoters: {current.get('promoters', 0)}")
|
|
501
|
+
click.echo(f" Passives: {current.get('passives', 0)}")
|
|
502
|
+
click.echo(f" Detractors: {current.get('detractors', 0)}")
|
|
503
|
+
click.echo(f" Responses: {results.get('total', 0)}")
|
|
504
|
+
|
|
505
|
+
_emit(payload, json_mode, human)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# Widgets + usage
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@cli.group()
|
|
514
|
+
def widgets() -> None:
|
|
515
|
+
"""Embeddable widgets."""
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@widgets.command(name="list")
|
|
519
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
520
|
+
@click.option("--project", required=True, type=click.UUID, help="Project UUID.")
|
|
521
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
522
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
523
|
+
def widgets_list(team, project, json_mode, token_flag):
|
|
524
|
+
"""List a project's widgets (scope: read:widgets)."""
|
|
525
|
+
try:
|
|
526
|
+
data = api_request(
|
|
527
|
+
"GET",
|
|
528
|
+
f"/api/v1/teams/{team}/projects/{project}/widgets/",
|
|
529
|
+
token_flag=token_flag,
|
|
530
|
+
)
|
|
531
|
+
except APIError as error:
|
|
532
|
+
_fail(error)
|
|
533
|
+
|
|
534
|
+
def human(data):
|
|
535
|
+
rows = [
|
|
536
|
+
[
|
|
537
|
+
item.get("id", ""),
|
|
538
|
+
item.get("name", ""),
|
|
539
|
+
item.get("widget_type", ""),
|
|
540
|
+
"yes" if item.get("is_active") else "no",
|
|
541
|
+
]
|
|
542
|
+
for item in _rows(data)
|
|
543
|
+
]
|
|
544
|
+
print_table(["ID", "NAME", "TYPE", "ACTIVE"], rows)
|
|
545
|
+
|
|
546
|
+
_emit(data, json_mode, human)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
@cli.command()
|
|
550
|
+
@click.option("--team", required=True, type=click.UUID, help="Team UUID.")
|
|
551
|
+
@click.option("--json", "json_mode", is_flag=True, help="Output JSON.")
|
|
552
|
+
@click.option("--token", "token_flag", default="", help="PAT override for this call.")
|
|
553
|
+
def usage(team, json_mode, token_flag):
|
|
554
|
+
"""Quota usage vs plan limits (scope: read:feedback)."""
|
|
555
|
+
try:
|
|
556
|
+
data = api_request(
|
|
557
|
+
"GET", f"/api/v1/teams/{team}/usage/", token_flag=token_flag
|
|
558
|
+
)
|
|
559
|
+
except APIError as error:
|
|
560
|
+
_fail(error)
|
|
561
|
+
|
|
562
|
+
def human(data):
|
|
563
|
+
click.echo(f"Plan: {data.get('plan')}")
|
|
564
|
+
rows = []
|
|
565
|
+
for metric, entry in (data.get("usage") or {}).items():
|
|
566
|
+
used = (entry.get("settled") or 0) + (entry.get("reserved") or 0)
|
|
567
|
+
limit = entry.get("limit")
|
|
568
|
+
rows.append(
|
|
569
|
+
[
|
|
570
|
+
metric,
|
|
571
|
+
entry.get("period", ""),
|
|
572
|
+
used,
|
|
573
|
+
"unlimited" if limit is None else limit,
|
|
574
|
+
]
|
|
575
|
+
)
|
|
576
|
+
print_table(["METRIC", "PERIOD", "USED", "LIMIT"], rows)
|
|
577
|
+
|
|
578
|
+
_emit(data, json_mode, human)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
if __name__ == "__main__":
|
|
582
|
+
cli()
|