drinkzen-admin-cli 0.2.4__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.
- drinkzen_admin/__init__.py +3 -0
- drinkzen_admin/cli.py +547 -0
- drinkzen_admin/client.py +90 -0
- drinkzen_admin/config.py +130 -0
- drinkzen_admin/formatting.py +34 -0
- drinkzen_admin/menu_options.py +76 -0
- drinkzen_admin_cli-0.2.4.dist-info/METADATA +181 -0
- drinkzen_admin_cli-0.2.4.dist-info/RECORD +13 -0
- drinkzen_admin_cli-0.2.4.dist-info/WHEEL +5 -0
- drinkzen_admin_cli-0.2.4.dist-info/entry_points.txt +2 -0
- drinkzen_admin_cli-0.2.4.dist-info/top_level.txt +2 -0
- tests/test_config.py +93 -0
- tests/test_standalone_cli.py +113 -0
drinkzen_admin/cli.py
ADDED
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
"""CLI entry point for DrinkZen Admin operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .client import AdminCLIClient
|
|
12
|
+
from .config import cmd_config_path, cmd_config_set, cmd_config_show
|
|
13
|
+
from .formatting import format_cli_output
|
|
14
|
+
from .menu_options import normalize_menu_option_config
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ----------------------------------------------------------------------
|
|
18
|
+
# Brand Commands
|
|
19
|
+
# ----------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def cmd_brand_list(
|
|
23
|
+
client: AdminCLIClient,
|
|
24
|
+
search: str | None = None,
|
|
25
|
+
active_only: bool = False,
|
|
26
|
+
is_json: bool = False,
|
|
27
|
+
) -> int:
|
|
28
|
+
params: dict[str, Any] = {}
|
|
29
|
+
if search:
|
|
30
|
+
params["search"] = search
|
|
31
|
+
if active_only:
|
|
32
|
+
params["is_active"] = "true"
|
|
33
|
+
|
|
34
|
+
status, data = client.request("GET", "/api/admin/brands", params=params)
|
|
35
|
+
if status != 200:
|
|
36
|
+
print(format_cli_output({"error": data}, is_json))
|
|
37
|
+
return 1
|
|
38
|
+
|
|
39
|
+
print(format_cli_output(data, is_json))
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def cmd_brand_show(
|
|
44
|
+
client: AdminCLIClient, brand_id_or_name: str, is_json: bool = False
|
|
45
|
+
) -> int:
|
|
46
|
+
status, l_data = client.request("GET", "/api/admin/brands", params={"search": brand_id_or_name})
|
|
47
|
+
if status == 200 and isinstance(l_data, list):
|
|
48
|
+
matched = [b for b in l_data if b.get("id") == brand_id_or_name or b.get("name") == brand_id_or_name]
|
|
49
|
+
if matched:
|
|
50
|
+
print(format_cli_output(matched[0], is_json))
|
|
51
|
+
return 0
|
|
52
|
+
if l_data:
|
|
53
|
+
print(format_cli_output(l_data[0], is_json))
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
status, data = client.request("GET", f"/api/admin/brands/{brand_id_or_name}")
|
|
57
|
+
if status != 200:
|
|
58
|
+
print(format_cli_output({"error": f"Brand '{brand_id_or_name}' not found", "details": data}, is_json))
|
|
59
|
+
return 1
|
|
60
|
+
|
|
61
|
+
print(format_cli_output(data, is_json))
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_brand_update(
|
|
66
|
+
client: AdminCLIClient,
|
|
67
|
+
brand_id_or_name: str,
|
|
68
|
+
name: str | None = None,
|
|
69
|
+
aliases: list[str] | None = None,
|
|
70
|
+
is_active: bool | None = None,
|
|
71
|
+
dry_run: bool = False,
|
|
72
|
+
is_json: bool = False,
|
|
73
|
+
) -> int:
|
|
74
|
+
status, current = client.request("GET", f"/api/admin/brands/{brand_id_or_name}")
|
|
75
|
+
if status == 404 and not brand_id_or_name.startswith("brand-"):
|
|
76
|
+
l_status, l_data = client.request("GET", "/api/admin/brands", params={"search": brand_id_or_name})
|
|
77
|
+
if l_status == 200 and isinstance(l_data, list) and l_data:
|
|
78
|
+
current = l_data[0]
|
|
79
|
+
status = 200
|
|
80
|
+
|
|
81
|
+
if status != 200:
|
|
82
|
+
print(format_cli_output({"error": f"Brand '{brand_id_or_name}' not found", "details": current}, is_json))
|
|
83
|
+
return 1
|
|
84
|
+
|
|
85
|
+
brand_id = current["id"]
|
|
86
|
+
payload: dict[str, Any] = {}
|
|
87
|
+
if name is not None:
|
|
88
|
+
payload["name"] = name
|
|
89
|
+
if aliases is not None:
|
|
90
|
+
payload["aliases"] = aliases
|
|
91
|
+
if is_active is not None:
|
|
92
|
+
payload["is_active"] = is_active
|
|
93
|
+
|
|
94
|
+
if not payload:
|
|
95
|
+
print(format_cli_output({"message": "No updates specified"}, is_json))
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
if dry_run:
|
|
99
|
+
result = {
|
|
100
|
+
"dry_run": True,
|
|
101
|
+
"brand_id": brand_id,
|
|
102
|
+
"current": {k: current.get(k) for k in payload},
|
|
103
|
+
"proposed": payload,
|
|
104
|
+
}
|
|
105
|
+
print(format_cli_output(result, is_json))
|
|
106
|
+
return 0
|
|
107
|
+
|
|
108
|
+
u_status, u_data = client.request("PATCH", f"/api/admin/brands/{brand_id}", json_data=payload)
|
|
109
|
+
if u_status not in {200, 204}:
|
|
110
|
+
u_status, u_data = client.request("PUT", f"/api/admin/brands/{brand_id}", json_data=payload)
|
|
111
|
+
if u_status not in {200, 204}:
|
|
112
|
+
print(format_cli_output({"error": u_data}, is_json))
|
|
113
|
+
return 1
|
|
114
|
+
|
|
115
|
+
print(format_cli_output(u_data, is_json))
|
|
116
|
+
return 0
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def cmd_brand_set_logo(
|
|
120
|
+
client: AdminCLIClient,
|
|
121
|
+
brand_id_or_name: str,
|
|
122
|
+
file_path: str,
|
|
123
|
+
source_name: str | None = None,
|
|
124
|
+
source_url: str | None = None,
|
|
125
|
+
confidence: str | None = None,
|
|
126
|
+
dry_run: bool = False,
|
|
127
|
+
is_json: bool = False,
|
|
128
|
+
) -> int:
|
|
129
|
+
path = Path(file_path)
|
|
130
|
+
if not path.is_file():
|
|
131
|
+
print(format_cli_output({"error": f"File not found: {file_path}"}, is_json))
|
|
132
|
+
return 1
|
|
133
|
+
|
|
134
|
+
status, current = client.request("GET", f"/api/admin/brands/{brand_id_or_name}")
|
|
135
|
+
if status == 404 and not brand_id_or_name.startswith("brand-"):
|
|
136
|
+
l_status, l_data = client.request("GET", "/api/admin/brands", params={"search": brand_id_or_name})
|
|
137
|
+
if l_status == 200 and isinstance(l_data, list) and l_data:
|
|
138
|
+
current = l_data[0]
|
|
139
|
+
status = 200
|
|
140
|
+
|
|
141
|
+
if status != 200:
|
|
142
|
+
print(format_cli_output({"error": f"Brand '{brand_id_or_name}' not found"}, is_json))
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
brand_id = current["id"]
|
|
146
|
+
content = path.read_bytes()
|
|
147
|
+
|
|
148
|
+
if dry_run:
|
|
149
|
+
result = {
|
|
150
|
+
"dry_run": True,
|
|
151
|
+
"brand_id": brand_id,
|
|
152
|
+
"file": str(path),
|
|
153
|
+
"file_size": len(content),
|
|
154
|
+
"source_name": source_name,
|
|
155
|
+
"source_url": source_url,
|
|
156
|
+
"confidence": confidence,
|
|
157
|
+
}
|
|
158
|
+
print(format_cli_output(result, is_json))
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
params = {}
|
|
162
|
+
if source_name:
|
|
163
|
+
params["source_name"] = source_name
|
|
164
|
+
if source_url:
|
|
165
|
+
params["source_url"] = source_url
|
|
166
|
+
if confidence:
|
|
167
|
+
params["confidence"] = confidence
|
|
168
|
+
|
|
169
|
+
files = {"file": (path.name, content, "image/png")}
|
|
170
|
+
u_status, u_data = client.request("POST", f"/api/admin/brands/{brand_id}/logo", params=params, files=files)
|
|
171
|
+
if u_status != 200:
|
|
172
|
+
print(format_cli_output({"error": u_data}, is_json))
|
|
173
|
+
return 1
|
|
174
|
+
|
|
175
|
+
print(format_cli_output(u_data, is_json))
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ----------------------------------------------------------------------
|
|
180
|
+
# Menu-Template Commands
|
|
181
|
+
# ----------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def cmd_menu_template_export(
|
|
185
|
+
client: AdminCLIClient,
|
|
186
|
+
brand_id_or_name: str,
|
|
187
|
+
out_file: str | None = None,
|
|
188
|
+
is_json: bool = False,
|
|
189
|
+
) -> int:
|
|
190
|
+
status, current = client.request("GET", f"/api/admin/brands/{brand_id_or_name}")
|
|
191
|
+
if status == 404 and not brand_id_or_name.startswith("brand-"):
|
|
192
|
+
l_status, l_data = client.request("GET", "/api/admin/brands", params={"search": brand_id_or_name})
|
|
193
|
+
if l_status == 200 and isinstance(l_data, list) and l_data:
|
|
194
|
+
current = l_data[0]
|
|
195
|
+
status = 200
|
|
196
|
+
|
|
197
|
+
if status != 200:
|
|
198
|
+
print(format_cli_output({"error": f"Brand '{brand_id_or_name}' not found"}, is_json))
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
template = current.get("menu_option_template") or {"optionGroups": []}
|
|
202
|
+
|
|
203
|
+
if out_file:
|
|
204
|
+
out_path = Path(out_file)
|
|
205
|
+
out_path.write_text(json.dumps(template, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
206
|
+
result = {"exported_to": str(out_path), "brand_id": current["id"], "template": template}
|
|
207
|
+
print(format_cli_output(result, is_json))
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
print(format_cli_output(template, is_json))
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def cmd_menu_template_validate(
|
|
215
|
+
template_data_or_file: str, is_json: bool = False
|
|
216
|
+
) -> int:
|
|
217
|
+
try:
|
|
218
|
+
path = Path(template_data_or_file)
|
|
219
|
+
if path.is_file():
|
|
220
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
221
|
+
else:
|
|
222
|
+
raw = json.loads(template_data_or_file)
|
|
223
|
+
except Exception as err:
|
|
224
|
+
print(format_cli_output({"error": f"Invalid JSON data: {err}"}, is_json))
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
normalized = normalize_menu_option_config(raw)
|
|
229
|
+
result = {"valid": True, "errors": [], "normalized": normalized}
|
|
230
|
+
print(format_cli_output(result, is_json))
|
|
231
|
+
return 0
|
|
232
|
+
except ValueError as err:
|
|
233
|
+
result = {"valid": False, "errors": [str(err)], "normalized": None}
|
|
234
|
+
print(format_cli_output(result, is_json))
|
|
235
|
+
return 1
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def cmd_menu_template_apply(
|
|
239
|
+
client: AdminCLIClient,
|
|
240
|
+
brand_id_or_name: str,
|
|
241
|
+
template_file: str,
|
|
242
|
+
dry_run: bool = False,
|
|
243
|
+
is_json: bool = False,
|
|
244
|
+
) -> int:
|
|
245
|
+
path = Path(template_file)
|
|
246
|
+
if not path.is_file():
|
|
247
|
+
print(format_cli_output({"error": f"File not found: {template_file}"}, is_json))
|
|
248
|
+
return 1
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
template_raw = json.loads(path.read_text(encoding="utf-8"))
|
|
252
|
+
except Exception as err:
|
|
253
|
+
print(format_cli_output({"error": f"Invalid JSON file: {err}"}, is_json))
|
|
254
|
+
return 1
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
normalized = normalize_menu_option_config(template_raw)
|
|
258
|
+
except ValueError as err:
|
|
259
|
+
print(format_cli_output({"error": "Template validation failed", "errors": [str(err)]}, is_json))
|
|
260
|
+
return 1
|
|
261
|
+
|
|
262
|
+
status, current = client.request("GET", f"/api/admin/brands/{brand_id_or_name}")
|
|
263
|
+
if status == 404 and not brand_id_or_name.startswith("brand-"):
|
|
264
|
+
l_status, l_data = client.request("GET", "/api/admin/brands", params={"search": brand_id_or_name})
|
|
265
|
+
if l_status == 200 and isinstance(l_data, list) and l_data:
|
|
266
|
+
current = l_data[0]
|
|
267
|
+
status = 200
|
|
268
|
+
|
|
269
|
+
if status != 200:
|
|
270
|
+
print(format_cli_output({"error": f"Brand '{brand_id_or_name}' not found"}, is_json))
|
|
271
|
+
return 1
|
|
272
|
+
|
|
273
|
+
brand_id = current["id"]
|
|
274
|
+
|
|
275
|
+
if dry_run:
|
|
276
|
+
result = {
|
|
277
|
+
"dry_run": True,
|
|
278
|
+
"brand_id": brand_id,
|
|
279
|
+
"current_template": current.get("menu_option_template"),
|
|
280
|
+
"proposed_template": normalized,
|
|
281
|
+
}
|
|
282
|
+
print(format_cli_output(result, is_json))
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
payload = {"menu_option_template": normalized}
|
|
286
|
+
u_status, u_data = client.request("PATCH", f"/api/admin/brands/{brand_id}", json_data=payload)
|
|
287
|
+
if u_status not in {200, 204}:
|
|
288
|
+
u_status, u_data = client.request("PUT", f"/api/admin/brands/{brand_id}", json_data=payload)
|
|
289
|
+
if u_status not in {200, 204}:
|
|
290
|
+
print(format_cli_output({"error": u_data}, is_json))
|
|
291
|
+
return 1
|
|
292
|
+
|
|
293
|
+
print(format_cli_output(u_data, is_json))
|
|
294
|
+
return 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ----------------------------------------------------------------------
|
|
298
|
+
# Review Commands
|
|
299
|
+
# ----------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def cmd_review_list(
|
|
303
|
+
client: AdminCLIClient,
|
|
304
|
+
status_filter: str | None = None,
|
|
305
|
+
limit: int = 20,
|
|
306
|
+
is_json: bool = False,
|
|
307
|
+
) -> int:
|
|
308
|
+
params: dict[str, Any] = {"limit": limit}
|
|
309
|
+
if status_filter:
|
|
310
|
+
params["status"] = status_filter
|
|
311
|
+
|
|
312
|
+
status, data = client.request("GET", "/api/admin/review-tasks", params=params)
|
|
313
|
+
if status != 200:
|
|
314
|
+
print(format_cli_output({"error": data}, is_json))
|
|
315
|
+
return 1
|
|
316
|
+
|
|
317
|
+
print(format_cli_output(data, is_json))
|
|
318
|
+
return 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def cmd_review_show(
|
|
322
|
+
client: AdminCLIClient, task_id: str, is_json: bool = False
|
|
323
|
+
) -> int:
|
|
324
|
+
status, data = client.request("GET", f"/api/admin/review-tasks/{task_id}/submission-context")
|
|
325
|
+
if status != 200:
|
|
326
|
+
print(format_cli_output({"error": data}, is_json))
|
|
327
|
+
return 1
|
|
328
|
+
|
|
329
|
+
print(format_cli_output(data, is_json))
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def cmd_review_resolve(
|
|
334
|
+
client: AdminCLIClient,
|
|
335
|
+
task_id: str,
|
|
336
|
+
action: str,
|
|
337
|
+
target_product_id: str | None = None,
|
|
338
|
+
reason: str | None = None,
|
|
339
|
+
dry_run: bool = False,
|
|
340
|
+
is_json: bool = False,
|
|
341
|
+
) -> int:
|
|
342
|
+
action_map = {
|
|
343
|
+
"create": "approve_new",
|
|
344
|
+
"merge": "merge_existing",
|
|
345
|
+
"supplement": "accept_contribution",
|
|
346
|
+
"reject": "reject",
|
|
347
|
+
}
|
|
348
|
+
resolved_action = action_map.get(action, action)
|
|
349
|
+
|
|
350
|
+
payload: dict[str, Any] = {"action": resolved_action}
|
|
351
|
+
if target_product_id:
|
|
352
|
+
payload["target_product_id"] = target_product_id
|
|
353
|
+
if reason:
|
|
354
|
+
payload["resolution_note"] = reason
|
|
355
|
+
|
|
356
|
+
if dry_run:
|
|
357
|
+
ctx_status, ctx_data = client.request("GET", f"/api/admin/review-tasks/{task_id}/submission-context")
|
|
358
|
+
result = {
|
|
359
|
+
"dry_run": True,
|
|
360
|
+
"task_id": task_id,
|
|
361
|
+
"proposed_payload": payload,
|
|
362
|
+
"context": ctx_data if ctx_status == 200 else None,
|
|
363
|
+
}
|
|
364
|
+
print(format_cli_output(result, is_json))
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
r_status, r_data = client.request("POST", f"/api/admin/review-tasks/{task_id}/resolve", json_data=payload)
|
|
368
|
+
if r_status != 200:
|
|
369
|
+
print(format_cli_output({"error": r_data}, is_json))
|
|
370
|
+
return 1
|
|
371
|
+
|
|
372
|
+
print(format_cli_output(r_data, is_json))
|
|
373
|
+
return 0
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ----------------------------------------------------------------------
|
|
377
|
+
# CLI Main Parser
|
|
378
|
+
# ----------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
382
|
+
common_parser = argparse.ArgumentParser(add_help=False)
|
|
383
|
+
common_parser.add_argument("--url", default=None, help="DrinkZen Admin API Base URL")
|
|
384
|
+
common_parser.add_argument("--token", default=None, help="DrinkZen Admin Token")
|
|
385
|
+
common_parser.add_argument("--json", action="store_true", help="Output JSON format")
|
|
386
|
+
common_parser.add_argument("--dry-run", action="store_true", help="Preview modifications without writing")
|
|
387
|
+
|
|
388
|
+
parser = argparse.ArgumentParser(
|
|
389
|
+
prog="drinkzen-admin",
|
|
390
|
+
description="DrinkZen Admin CLI for brand, menu template, review, and configuration workflows.",
|
|
391
|
+
parents=[common_parser],
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
subparsers = parser.add_subparsers(dest="subcommand", required=True)
|
|
395
|
+
|
|
396
|
+
# config
|
|
397
|
+
config_parser = subparsers.add_parser("config", help="Configuration and secret management", parents=[common_parser])
|
|
398
|
+
config_sub = config_parser.add_subparsers(dest="config_action", required=True)
|
|
399
|
+
|
|
400
|
+
c_set = config_sub.add_parser("set", help="Save server URL and secret token to ~/.drinkzen/admin.json", parents=[common_parser])
|
|
401
|
+
c_set.add_argument("--set-url", dest="config_url", default=None, help="Server Base URL to save")
|
|
402
|
+
c_set.add_argument("--set-token", dest="config_token", default=None, help="Admin token to save")
|
|
403
|
+
|
|
404
|
+
c_show = config_sub.add_parser("show", help="Show active configuration with masked secrets", parents=[common_parser])
|
|
405
|
+
c_path = config_sub.add_parser("path", help="Print configuration file path", parents=[common_parser])
|
|
406
|
+
|
|
407
|
+
# brand
|
|
408
|
+
brand_parser = subparsers.add_parser("brand", help="Brand operations", parents=[common_parser])
|
|
409
|
+
brand_sub = brand_parser.add_subparsers(dest="brand_action", required=True)
|
|
410
|
+
|
|
411
|
+
b_list = brand_sub.add_parser("list", help="List brands", parents=[common_parser])
|
|
412
|
+
b_list.add_argument("--search", default=None, help="Filter by brand name or alias")
|
|
413
|
+
b_list.add_argument("--active-only", action="store_true", help="Filter active brands only")
|
|
414
|
+
|
|
415
|
+
b_show = brand_sub.add_parser("show", help="Show brand details", parents=[common_parser])
|
|
416
|
+
b_show.add_argument("brand", help="Brand ID or name")
|
|
417
|
+
|
|
418
|
+
b_update = brand_sub.add_parser("update", help="Update brand information", parents=[common_parser])
|
|
419
|
+
b_update.add_argument("brand", help="Brand ID or name")
|
|
420
|
+
b_update.add_argument("--name", default=None, help="New brand name")
|
|
421
|
+
b_update.add_argument("--alias", action="append", dest="aliases", default=None, help="Brand alias")
|
|
422
|
+
b_update.add_argument("--active", type=lambda v: v.lower() == "true", dest="is_active", default=None, help="Active status")
|
|
423
|
+
|
|
424
|
+
b_logo = brand_sub.add_parser("set-logo", help="Upload and set brand logo", parents=[common_parser])
|
|
425
|
+
b_logo.add_argument("brand", help="Brand ID or name")
|
|
426
|
+
b_logo.add_argument("file", help="Path to logo image file")
|
|
427
|
+
b_logo.add_argument("--source-name", default=None, help="Provenance source name")
|
|
428
|
+
b_logo.add_argument("--source-url", default=None, help="Provenance source URL")
|
|
429
|
+
b_logo.add_argument("--confidence", choices=["verified", "high", "medium", "low"], default=None)
|
|
430
|
+
|
|
431
|
+
# menu-template
|
|
432
|
+
menu_parser = subparsers.add_parser("menu-template", help="Menu template operations", parents=[common_parser])
|
|
433
|
+
menu_sub = menu_parser.add_subparsers(dest="menu_action", required=True)
|
|
434
|
+
|
|
435
|
+
m_export = menu_sub.add_parser("export", help="Export brand menu template", parents=[common_parser])
|
|
436
|
+
m_export.add_argument("brand", help="Brand ID or name")
|
|
437
|
+
m_export.add_argument("--out", default=None, help="Output file path")
|
|
438
|
+
|
|
439
|
+
m_validate = menu_sub.add_parser("validate", help="Validate menu template syntax", parents=[common_parser])
|
|
440
|
+
m_validate.add_argument("template", help="JSON string or file path")
|
|
441
|
+
|
|
442
|
+
m_apply = menu_sub.add_parser("apply", help="Apply menu template to brand", parents=[common_parser])
|
|
443
|
+
m_apply.add_argument("brand", help="Brand ID or name")
|
|
444
|
+
m_apply.add_argument("file", help="Template JSON file path")
|
|
445
|
+
|
|
446
|
+
# review
|
|
447
|
+
review_parser = subparsers.add_parser("review", help="Review workflow operations", parents=[common_parser])
|
|
448
|
+
review_sub = review_parser.add_subparsers(dest="review_action", required=True)
|
|
449
|
+
|
|
450
|
+
r_list = review_sub.add_parser("list", help="List review tasks", parents=[common_parser])
|
|
451
|
+
r_list.add_argument("--status", choices=["pending", "done", "rejected"], default=None)
|
|
452
|
+
r_list.add_argument("--limit", type=int, default=20)
|
|
453
|
+
|
|
454
|
+
r_show = review_sub.add_parser("show", help="Show review task context", parents=[common_parser])
|
|
455
|
+
r_show.add_argument("task_id", help="Review task ID")
|
|
456
|
+
|
|
457
|
+
r_resolve = review_sub.add_parser("resolve", help="Resolve review task", parents=[common_parser])
|
|
458
|
+
r_resolve.add_argument("task_id", help="Review task ID")
|
|
459
|
+
r_resolve.add_argument("action", choices=["create", "merge", "supplement", "reject"])
|
|
460
|
+
r_resolve.add_argument("--target-product-id", default=None)
|
|
461
|
+
r_resolve.add_argument("--reason", default=None)
|
|
462
|
+
|
|
463
|
+
return parser
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def main(args: list[str] | None = None) -> int:
|
|
467
|
+
parser = build_parser()
|
|
468
|
+
parsed = parser.parse_args(args)
|
|
469
|
+
|
|
470
|
+
is_json = getattr(parsed, "json", False)
|
|
471
|
+
dry_run = getattr(parsed, "dry_run", False)
|
|
472
|
+
|
|
473
|
+
# Config commands
|
|
474
|
+
if parsed.subcommand == "config":
|
|
475
|
+
if parsed.config_action == "set":
|
|
476
|
+
url = parsed.config_url or parsed.url
|
|
477
|
+
token = parsed.config_token or parsed.token
|
|
478
|
+
return cmd_config_set(url=url, token=token, is_json=is_json)
|
|
479
|
+
if parsed.config_action == "show":
|
|
480
|
+
return cmd_config_show(is_json=is_json)
|
|
481
|
+
if parsed.config_action == "path":
|
|
482
|
+
return cmd_config_path(is_json=is_json)
|
|
483
|
+
|
|
484
|
+
client = AdminCLIClient(base_url=parsed.url, token=parsed.token)
|
|
485
|
+
|
|
486
|
+
if parsed.subcommand == "brand":
|
|
487
|
+
if parsed.brand_action == "list":
|
|
488
|
+
return cmd_brand_list(client, search=parsed.search, active_only=parsed.active_only, is_json=is_json)
|
|
489
|
+
if parsed.brand_action == "show":
|
|
490
|
+
return cmd_brand_show(client, brand_id_or_name=parsed.brand, is_json=is_json)
|
|
491
|
+
if parsed.brand_action == "update":
|
|
492
|
+
return cmd_brand_update(
|
|
493
|
+
client,
|
|
494
|
+
brand_id_or_name=parsed.brand,
|
|
495
|
+
name=parsed.name,
|
|
496
|
+
aliases=parsed.aliases,
|
|
497
|
+
is_active=parsed.is_active,
|
|
498
|
+
dry_run=dry_run,
|
|
499
|
+
is_json=is_json,
|
|
500
|
+
)
|
|
501
|
+
if parsed.brand_action == "set-logo":
|
|
502
|
+
return cmd_brand_set_logo(
|
|
503
|
+
client,
|
|
504
|
+
brand_id_or_name=parsed.brand,
|
|
505
|
+
file_path=parsed.file,
|
|
506
|
+
source_name=parsed.source_name,
|
|
507
|
+
source_url=parsed.source_url,
|
|
508
|
+
confidence=parsed.confidence,
|
|
509
|
+
dry_run=dry_run,
|
|
510
|
+
is_json=is_json,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
if parsed.subcommand == "menu-template":
|
|
514
|
+
if parsed.menu_action == "export":
|
|
515
|
+
return cmd_menu_template_export(client, brand_id_or_name=parsed.brand, out_file=parsed.out, is_json=is_json)
|
|
516
|
+
if parsed.menu_action == "validate":
|
|
517
|
+
return cmd_menu_template_validate(template_data_or_file=parsed.template, is_json=is_json)
|
|
518
|
+
if parsed.menu_action == "apply":
|
|
519
|
+
return cmd_menu_template_apply(
|
|
520
|
+
client,
|
|
521
|
+
brand_id_or_name=parsed.brand,
|
|
522
|
+
template_file=parsed.file,
|
|
523
|
+
dry_run=dry_run,
|
|
524
|
+
is_json=is_json,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
if parsed.subcommand == "review":
|
|
528
|
+
if parsed.review_action == "list":
|
|
529
|
+
return cmd_review_list(client, status_filter=parsed.status, limit=parsed.limit, is_json=is_json)
|
|
530
|
+
if parsed.review_action == "show":
|
|
531
|
+
return cmd_review_show(client, task_id=parsed.task_id, is_json=is_json)
|
|
532
|
+
if parsed.review_action == "resolve":
|
|
533
|
+
return cmd_review_resolve(
|
|
534
|
+
client,
|
|
535
|
+
task_id=parsed.task_id,
|
|
536
|
+
action=parsed.action,
|
|
537
|
+
target_product_id=parsed.target_product_id,
|
|
538
|
+
reason=parsed.reason,
|
|
539
|
+
dry_run=dry_run,
|
|
540
|
+
is_json=is_json,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
return 0
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
if __name__ == "__main__":
|
|
547
|
+
sys.exit(main())
|
drinkzen_admin/client.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""HTTP client for DrinkZen Admin API (Python Standard Library only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
import uuid
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .config import load_config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AdminCLIClient:
|
|
17
|
+
"""Client for performing HTTP requests against DrinkZen Admin API."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, base_url: str | None = None, token: str | None = None) -> None:
|
|
20
|
+
file_conf = load_config()
|
|
21
|
+
resolved_url = (
|
|
22
|
+
base_url
|
|
23
|
+
or os.environ.get("DRINKZEN_API_BASE_URL")
|
|
24
|
+
or file_conf.get("url")
|
|
25
|
+
or "http://localhost:8000"
|
|
26
|
+
)
|
|
27
|
+
resolved_token = (
|
|
28
|
+
token
|
|
29
|
+
or os.environ.get("DRINKZEN_ADMIN_TOKEN")
|
|
30
|
+
or file_conf.get("token")
|
|
31
|
+
or "local-admin-token"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
self.base_url = resolved_url.rstrip("/")
|
|
35
|
+
self.token = resolved_token
|
|
36
|
+
|
|
37
|
+
def request(
|
|
38
|
+
self,
|
|
39
|
+
method: str,
|
|
40
|
+
path: str,
|
|
41
|
+
params: dict[str, Any] | None = None,
|
|
42
|
+
json_data: dict[str, Any] | list[Any] | None = None,
|
|
43
|
+
files: dict[str, Any] | None = None,
|
|
44
|
+
) -> tuple[int, Any]:
|
|
45
|
+
"""Perform an HTTP request returning (status_code, data_or_dict)."""
|
|
46
|
+
headers = {"X-Admin-Token": self.token}
|
|
47
|
+
|
|
48
|
+
quoted_path = urllib.parse.quote(path, safe="/:?&=#+%,@_~")
|
|
49
|
+
url = f"{self.base_url}{quoted_path}"
|
|
50
|
+
if params:
|
|
51
|
+
query = urllib.parse.urlencode({k: v for k, v in params.items() if v is not None})
|
|
52
|
+
if query:
|
|
53
|
+
url = f"{url}?{query}" if "?" not in url else f"{url}&{query}"
|
|
54
|
+
|
|
55
|
+
body = None
|
|
56
|
+
if files:
|
|
57
|
+
boundary = f"----WebKitFormBoundary{uuid.uuid4().hex}"
|
|
58
|
+
headers["Content-Type"] = f"multipart/form-data; boundary={boundary}"
|
|
59
|
+
body_parts = []
|
|
60
|
+
for field, file_info in files.items():
|
|
61
|
+
filename, content, mime_type = file_info
|
|
62
|
+
body_parts.append(f"--{boundary}\r\n".encode("utf-8"))
|
|
63
|
+
body_parts.append(
|
|
64
|
+
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'.encode("utf-8")
|
|
65
|
+
)
|
|
66
|
+
body_parts.append(f"Content-Type: {mime_type}\r\n\r\n".encode("utf-8"))
|
|
67
|
+
body_parts.append(content if isinstance(content, bytes) else content.encode("utf-8"))
|
|
68
|
+
body_parts.append(b"\r\n")
|
|
69
|
+
body_parts.append(f"--{boundary}--\r\n".encode("utf-8"))
|
|
70
|
+
body = b"".join(body_parts)
|
|
71
|
+
elif json_data is not None:
|
|
72
|
+
body = json.dumps(json_data).encode("utf-8")
|
|
73
|
+
headers["Content-Type"] = "application/json"
|
|
74
|
+
|
|
75
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
76
|
+
try:
|
|
77
|
+
with urllib.request.urlopen(req) as resp:
|
|
78
|
+
status = resp.status
|
|
79
|
+
raw = resp.read().decode("utf-8")
|
|
80
|
+
data = json.loads(raw) if raw else {}
|
|
81
|
+
return status, data
|
|
82
|
+
except urllib.error.HTTPError as err:
|
|
83
|
+
try:
|
|
84
|
+
raw = err.read().decode("utf-8")
|
|
85
|
+
data = json.loads(raw) if raw else {"detail": str(err)}
|
|
86
|
+
except Exception:
|
|
87
|
+
data = {"detail": str(err)}
|
|
88
|
+
return err.code, data
|
|
89
|
+
except urllib.error.URLError as err:
|
|
90
|
+
return 503, {"detail": f"Failed to connect to API at {self.base_url}: {err.reason}"}
|
drinkzen_admin/config.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Configuration and secret storage management for DrinkZen Admin CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from .formatting import format_cli_output
|
|
12
|
+
|
|
13
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".drinkzen"
|
|
14
|
+
DEFAULT_CONFIG_FILE = DEFAULT_CONFIG_DIR / "admin.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_config_path() -> Path:
|
|
18
|
+
"""Return the absolute path to the admin configuration file."""
|
|
19
|
+
custom = os.environ.get("DRINKZEN_ADMIN_CONFIG_PATH")
|
|
20
|
+
if custom:
|
|
21
|
+
return Path(custom).expanduser().resolve()
|
|
22
|
+
return DEFAULT_CONFIG_FILE
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_config() -> dict[str, str]:
|
|
26
|
+
"""Load configuration from ~/.drinkzen/admin.json if it exists."""
|
|
27
|
+
path = get_config_path()
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
return {}
|
|
30
|
+
try:
|
|
31
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
32
|
+
if isinstance(data, dict):
|
|
33
|
+
return {
|
|
34
|
+
"url": str(data.get("url", "")).strip(),
|
|
35
|
+
"token": str(data.get("token", "")).strip(),
|
|
36
|
+
}
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def save_config(url: str | None = None, token: str | None = None) -> dict[str, str]:
|
|
43
|
+
"""Save URL and Token to ~/.drinkzen/admin.json with chmod 600 permissions."""
|
|
44
|
+
path = get_config_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
current = load_config()
|
|
48
|
+
if url is not None:
|
|
49
|
+
current["url"] = url.strip()
|
|
50
|
+
if token is not None:
|
|
51
|
+
current["token"] = token.strip()
|
|
52
|
+
|
|
53
|
+
# Clean empty values
|
|
54
|
+
clean = {k: v for k, v in current.items() if v}
|
|
55
|
+
content = json.dumps(clean, ensure_ascii=False, indent=2) + "\n"
|
|
56
|
+
|
|
57
|
+
# Write file
|
|
58
|
+
path.write_text(content, encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
# Secure file permissions (POSIX: 0600 - user read/write only)
|
|
61
|
+
if sys.platform != "win32":
|
|
62
|
+
try:
|
|
63
|
+
os.chmod(path, 0o600)
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
return clean
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mask_token(token: str) -> str:
|
|
71
|
+
"""Mask secret token for safe terminal output."""
|
|
72
|
+
if not token:
|
|
73
|
+
return ""
|
|
74
|
+
if len(token) <= 8:
|
|
75
|
+
return "****"
|
|
76
|
+
return f"{token[:4]}****{token[-4:]}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def cmd_config_set(
|
|
80
|
+
url: str | None = None,
|
|
81
|
+
token: str | None = None,
|
|
82
|
+
is_json: bool = False,
|
|
83
|
+
) -> int:
|
|
84
|
+
"""Save configuration to admin.json."""
|
|
85
|
+
if url is None and token is None:
|
|
86
|
+
print(format_cli_output({"error": "Please provide --url or --token to save"}, is_json))
|
|
87
|
+
return 1
|
|
88
|
+
|
|
89
|
+
saved = save_config(url=url, token=token)
|
|
90
|
+
result = {
|
|
91
|
+
"message": "Configuration saved successfully",
|
|
92
|
+
"path": str(get_config_path()),
|
|
93
|
+
"url": saved.get("url", ""),
|
|
94
|
+
"token": mask_token(saved.get("token", "")),
|
|
95
|
+
}
|
|
96
|
+
print(format_cli_output(result, is_json))
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_config_show(is_json: bool = False) -> int:
|
|
101
|
+
"""Show current active configuration and secret masking."""
|
|
102
|
+
path = get_config_path()
|
|
103
|
+
file_conf = load_config()
|
|
104
|
+
|
|
105
|
+
env_url = os.environ.get("DRINKZEN_API_BASE_URL")
|
|
106
|
+
env_token = os.environ.get("DRINKZEN_ADMIN_TOKEN")
|
|
107
|
+
|
|
108
|
+
active_url = env_url or file_conf.get("url") or "http://localhost:8000"
|
|
109
|
+
active_token = env_token or file_conf.get("token") or "local-admin-token"
|
|
110
|
+
|
|
111
|
+
result = {
|
|
112
|
+
"config_file": str(path),
|
|
113
|
+
"file_exists": path.is_file(),
|
|
114
|
+
"active_url": active_url,
|
|
115
|
+
"url_source": "environment" if env_url else ("file" if file_conf.get("url") else "default"),
|
|
116
|
+
"active_token": mask_token(active_token),
|
|
117
|
+
"token_source": "environment" if env_token else ("file" if file_conf.get("token") else "default"),
|
|
118
|
+
}
|
|
119
|
+
print(format_cli_output(result, is_json))
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def cmd_config_path(is_json: bool = False) -> int:
|
|
124
|
+
"""Print configuration file path."""
|
|
125
|
+
path = get_config_path()
|
|
126
|
+
if is_json:
|
|
127
|
+
print(json.dumps({"path": str(path), "exists": path.is_file()}))
|
|
128
|
+
else:
|
|
129
|
+
print(str(path))
|
|
130
|
+
return 0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Formatting helpers for DrinkZen Admin CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def format_cli_output(data: Any, is_json: bool) -> str:
|
|
10
|
+
"""Format data as JSON or formatted text."""
|
|
11
|
+
if is_json:
|
|
12
|
+
return json.dumps(data, ensure_ascii=False, indent=2)
|
|
13
|
+
|
|
14
|
+
if isinstance(data, list):
|
|
15
|
+
lines = [f"Total: {len(data)}"]
|
|
16
|
+
for idx, item in enumerate(data, 1):
|
|
17
|
+
if isinstance(item, dict):
|
|
18
|
+
name = item.get("name") or item.get("id") or f"Item {idx}"
|
|
19
|
+
desc = f" (ID: {item.get('id', 'N/A')})" if "id" in item else ""
|
|
20
|
+
lines.append(f" {idx}. {name}{desc}")
|
|
21
|
+
else:
|
|
22
|
+
lines.append(f" {idx}. {item}")
|
|
23
|
+
return "\n".join(lines)
|
|
24
|
+
|
|
25
|
+
if isinstance(data, dict):
|
|
26
|
+
lines = []
|
|
27
|
+
for k, v in data.items():
|
|
28
|
+
if isinstance(v, (dict, list)):
|
|
29
|
+
lines.append(f"{k}: {json.dumps(v, ensure_ascii=False)}")
|
|
30
|
+
else:
|
|
31
|
+
lines.append(f"{k}: {v}")
|
|
32
|
+
return "\n".join(lines)
|
|
33
|
+
|
|
34
|
+
return str(data)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Standalone validator for brand menu option templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
VALID_SELECTION_TYPES = {"single", "multiple"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def normalize_menu_option_config(value: dict[str, Any] | None) -> dict[str, Any]:
|
|
12
|
+
"""Validate and normalize a brand menu option template.
|
|
13
|
+
|
|
14
|
+
Raises ValueError with descriptive message if validation fails.
|
|
15
|
+
"""
|
|
16
|
+
if not value:
|
|
17
|
+
return {}
|
|
18
|
+
if not isinstance(value, dict):
|
|
19
|
+
raise ValueError("Menu option configuration must be an object")
|
|
20
|
+
|
|
21
|
+
normalized = deepcopy(value)
|
|
22
|
+
groups = normalized.get("optionGroups", [])
|
|
23
|
+
if not isinstance(groups, list):
|
|
24
|
+
raise ValueError("optionGroups must be an array")
|
|
25
|
+
|
|
26
|
+
seen: set[str] = set()
|
|
27
|
+
for group in groups:
|
|
28
|
+
if not isinstance(group, dict):
|
|
29
|
+
raise ValueError("Each option group must be an object")
|
|
30
|
+
|
|
31
|
+
key = str(group.get("key", "")).strip()
|
|
32
|
+
label = str(group.get("label", "")).strip()
|
|
33
|
+
values = group.get("values", [])
|
|
34
|
+
|
|
35
|
+
if not key or not label:
|
|
36
|
+
raise ValueError("Each option group requires key and label")
|
|
37
|
+
|
|
38
|
+
if key in seen:
|
|
39
|
+
raise ValueError(f"Duplicate option group key: {key}")
|
|
40
|
+
seen.add(key)
|
|
41
|
+
|
|
42
|
+
if not isinstance(values, list) or not all(
|
|
43
|
+
isinstance(item, str) and item.strip() for item in values
|
|
44
|
+
):
|
|
45
|
+
raise ValueError(f"Option group {key} requires non-empty string values")
|
|
46
|
+
|
|
47
|
+
clean_values = list(dict.fromkeys(item.strip() for item in values))
|
|
48
|
+
selection_type = group.get("selectionType", "single")
|
|
49
|
+
if selection_type not in VALID_SELECTION_TYPES:
|
|
50
|
+
raise ValueError(f"Invalid selectionType for {key}")
|
|
51
|
+
|
|
52
|
+
defaults = group.get("defaultValues", [])
|
|
53
|
+
if not isinstance(defaults, list) or any(item not in clean_values for item in defaults):
|
|
54
|
+
raise ValueError(f"defaultValues must exist in values for {key}")
|
|
55
|
+
if selection_type == "single" and bool(group.get("required", False)):
|
|
56
|
+
defaults = clean_values[:1]
|
|
57
|
+
elif selection_type == "multiple" and not bool(group.get("required", False)):
|
|
58
|
+
defaults = []
|
|
59
|
+
|
|
60
|
+
minimum = group.get("minSelections")
|
|
61
|
+
maximum = group.get("maxSelections")
|
|
62
|
+
if minimum is not None and (not isinstance(minimum, int) or minimum < 0):
|
|
63
|
+
raise ValueError(f"Invalid minSelections for {key}")
|
|
64
|
+
if maximum is not None and (not isinstance(maximum, int) or maximum < 1):
|
|
65
|
+
raise ValueError(f"Invalid maxSelections for {key}")
|
|
66
|
+
if minimum is not None and maximum is not None and minimum > maximum:
|
|
67
|
+
raise ValueError(f"minSelections cannot exceed maxSelections for {key}")
|
|
68
|
+
|
|
69
|
+
group["key"] = key
|
|
70
|
+
group["label"] = label
|
|
71
|
+
group["values"] = clean_values
|
|
72
|
+
group["selectionType"] = selection_type
|
|
73
|
+
group["defaultValues"] = defaults
|
|
74
|
+
|
|
75
|
+
normalized["optionGroups"] = groups
|
|
76
|
+
return normalized
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: drinkzen-admin-cli
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Standalone CLI client for DrinkZen Admin operations
|
|
5
|
+
Author: DrinkZen Team
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://drinkzen.cn
|
|
8
|
+
Project-URL: Source, https://github.com/xiaolinstar/drinkzen
|
|
9
|
+
Project-URL: Issues, https://github.com/xiaolinstar/drinkzen/issues
|
|
10
|
+
Keywords: drinkzen,admin,beverage,operations,cli
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# drinkzen-admin-cli
|
|
24
|
+
|
|
25
|
+
`drinkzen-admin-cli` 是奶茶仙人(DrinkZen)运营管理后台的轻量级、零外部依赖 Python 命令行工具。
|
|
26
|
+
可由管理员直接在终端使用,也可挂载至用户自己的 AI Agent(如 Antigravity / Claude / Cursor 等)进行安全的辅助审核与菜单数据治理。
|
|
27
|
+
|
|
28
|
+
> 💡 **命令定位说明**:
|
|
29
|
+
>
|
|
30
|
+
> - `drinkzen-admin`:管理员与数据运营专属 CLI,用于配置管理、品牌管理、菜单模板配置、Logo 上传与用户贡献审核。
|
|
31
|
+
> - `drinkzen`:(规划中)普通用户专属 CLI,用于查询饮品热量、记录饮用与管理每日预算。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 快速安装与分发方式
|
|
36
|
+
|
|
37
|
+
### 方式 1:通过 PyPI 安装(正式发布后推荐)
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pipx install drinkzen-admin-cli
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### 方式 2:通过 Git URL 一键直接安装(预发布或源码验证)
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install "git+https://github.com/xiaolinstar/drinkzen.git#subdirectory=packages/admin-cli"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 方式 3:通过 pipx 隔离环境运行 Git 源码(免污染系统 Python)
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pipx run --spec "git+https://github.com/xiaolinstar/drinkzen.git#subdirectory=packages/admin-cli" drinkzen-admin config show
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 方式 4:在 Monorepo 本地源码开发运行
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
# 可编辑安装
|
|
59
|
+
pip install -e packages/admin-cli
|
|
60
|
+
|
|
61
|
+
# 或直接通过 Python 模块运行
|
|
62
|
+
PYTHONPATH=packages/admin-cli python3 -m drinkzen_admin.cli config show
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## 密钥与持久化配置管理 (`config`)
|
|
68
|
+
|
|
69
|
+
`drinkzen-admin` 提供了内置的密钥持久化管理,避免每次在终端手动输入敏感 Token 或将其留在 Bash 历史记录中。
|
|
70
|
+
|
|
71
|
+
### 1. 一键保存服务端地址与 Secret
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
# 保存远程生产 API 与 Admin Token
|
|
75
|
+
drinkzen-admin config set --set-url "https://api.drinkzen.cn" --set-token "your-secret-admin-token"
|
|
76
|
+
|
|
77
|
+
# 保存本地开发 API
|
|
78
|
+
drinkzen-admin config set --set-url "http://api.drinkzen.localhost:8000" --set-token "local-admin-token"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
配置文件将保存在 `~/.drinkzen/admin.json`,且在 Unix/macOS 系统上自动设置 **`chmod 600`** 权限(仅当前系统用户可读写)。
|
|
82
|
+
|
|
83
|
+
### 2. 查看当前生效的配置与 Secret 脱敏展示
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
drinkzen-admin config show --json
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
输出示例:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"config_file": "/Users/admin/.drinkzen/admin.json",
|
|
94
|
+
"file_exists": true,
|
|
95
|
+
"active_url": "https://api.drinkzen.cn",
|
|
96
|
+
"url_source": "file",
|
|
97
|
+
"active_token": "secr****1234",
|
|
98
|
+
"token_source": "file"
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### 3. 配置加载优先级
|
|
103
|
+
|
|
104
|
+
当执行命令时,配置按如下优先级依次生效:
|
|
105
|
+
|
|
106
|
+
1. **显式命令行参数**:`--url "..."` / `--token "..."`
|
|
107
|
+
2. **系统环境变量**:`DRINKZEN_API_BASE_URL` / `DRINKZEN_ADMIN_TOKEN`
|
|
108
|
+
3. **用户配置文件**:`~/.drinkzen/admin.json`
|
|
109
|
+
4. **内置默认值**:`http://localhost:8000` / `local-admin-token`
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## 常用业务命令速查
|
|
114
|
+
|
|
115
|
+
配置好 Secret 后,日常执行命令无需再附带 URL 和 Token:
|
|
116
|
+
|
|
117
|
+
### 1. 品牌管理 (`brand`)
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# 查看品牌列表(支持 --search 与 --active-only)
|
|
121
|
+
drinkzen-admin brand list --search "霸王" --json
|
|
122
|
+
|
|
123
|
+
# 查看具体品牌详情
|
|
124
|
+
drinkzen-admin brand show 霸王茶姬 --json
|
|
125
|
+
|
|
126
|
+
# 更新品牌名称/别名/启用状态(--dry-run 仅预览变更,不真正写库)
|
|
127
|
+
drinkzen-admin brand update 霸王茶姬 --name "霸王茶姬 CHAGEE" --alias "CHAGEE" --dry-run --json
|
|
128
|
+
|
|
129
|
+
# 上传品牌 Logo 并标注数据置信度
|
|
130
|
+
drinkzen-admin brand set-logo 霸王茶姬 ./logo.png --confidence verified --source-name "官方小程序"
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### 2. 菜单模板管理 (`menu-template`)
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# 导出品牌菜单模板到本地文件
|
|
137
|
+
drinkzen-admin menu-template export 霸王茶姬 --out chagee.json
|
|
138
|
+
|
|
139
|
+
# 本地离线校验 JSON 模板合法性
|
|
140
|
+
drinkzen-admin menu-template validate chagee.json --json
|
|
141
|
+
|
|
142
|
+
# 预览应用新模板到指定品牌
|
|
143
|
+
drinkzen-admin menu-template apply 霸王茶姬 chagee.json --dry-run --json
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### 3. 审核任务管理 (`review`)
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
# 列出待审核任务
|
|
150
|
+
drinkzen-admin review list --status pending --limit 10 --json
|
|
151
|
+
|
|
152
|
+
# 查看某个审核任务上下文与用户提交数据
|
|
153
|
+
drinkzen-admin review show 102 --json
|
|
154
|
+
|
|
155
|
+
# 审核处理(动作支持 create / merge / supplement / reject)
|
|
156
|
+
drinkzen-admin review resolve 102 create --reason "官方菜单确认无误,同意入库" --dry-run --json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## 安全设计原则
|
|
162
|
+
|
|
163
|
+
1. **零外部第三方依赖**:仅使用 Python 标准库,保证极致的安全与跨平台兼容性。
|
|
164
|
+
2. **不直连数据库**:所有写操作通过带有权限校验与审计追踪的 RESTful API 完成。
|
|
165
|
+
3. **Secret 安全隔离**:配置文件自动进行 0600 文件权限保护,`show` 输出自动脱敏,防止日志/录屏泄露。
|
|
166
|
+
4. **全命令支持 `--dry-run` 与 `--json`**:便于 AI Agent 输出结构化建议并在人工确认后再执行真正变更。
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 发布维护说明
|
|
171
|
+
|
|
172
|
+
发布由 GitHub Actions 的 **Publish Admin CLI** workflow 完成,使用 PyPI Trusted Publishing(OIDC),不使用长期 PyPI Token。
|
|
173
|
+
|
|
174
|
+
首次发布前,项目维护者需要在 PyPI 和 TestPyPI 分别配置对应的 Trusted Publisher:
|
|
175
|
+
|
|
176
|
+
- Owner:`xiaolinstar`
|
|
177
|
+
- Repository:`drinkzen`
|
|
178
|
+
- Workflow:`publish-admin-cli.yml`
|
|
179
|
+
- Environment:TestPyPI 使用 `testpypi`,正式 PyPI 使用 `pypi`
|
|
180
|
+
|
|
181
|
+
先通过 workflow 选择 `testpypi` 并填写当前包版本完成安装验证,再选择 `pypi` 发布正式版本。版本不可覆盖;每次发布前必须先提升 `packages/admin-cli/pyproject.toml` 中的版本号。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
drinkzen_admin/__init__.py,sha256=oHTfEs6W0YKWJF6Pox7o9CsRqk0zOk3FQrloBqYKSAs,57
|
|
2
|
+
drinkzen_admin/cli.py,sha256=RFXmrAk6BOr31Q9pMXFTkxSxNFXgeD0av0tZTtP_LT0,20568
|
|
3
|
+
drinkzen_admin/client.py,sha256=XRl3bQ0VSqBcy3XWh3NFncxo63iKhD-Oj3Sz4fIISgA,3458
|
|
4
|
+
drinkzen_admin/config.py,sha256=cmTQ9Pjep-L-SPw06tyF1c15nBR0i9KOh04grmVBYC8,3896
|
|
5
|
+
drinkzen_admin/formatting.py,sha256=B2iAN9kyhyP_Yw8_jFABzexDQkFIjgE6_SrahZnZaWY,1089
|
|
6
|
+
drinkzen_admin/menu_options.py,sha256=RDYIugpxB8wu9TbEcIgODZOwSa1MjmRHVt_LH29pDIo,2994
|
|
7
|
+
tests/test_config.py,sha256=3wIgqT-Zj3yEm6IDhnvvXu9HJvvLKTAk9BedsFNaEnU,3239
|
|
8
|
+
tests/test_standalone_cli.py,sha256=kXdYy941pvFXTsRVConygqVe4IorMx3HUM-j5Mf-qb8,4259
|
|
9
|
+
drinkzen_admin_cli-0.2.4.dist-info/METADATA,sha256=rDvt6kPj5wTAXMRJQKUnVtHcx_pR9SxjBwaL8xc1cns,6324
|
|
10
|
+
drinkzen_admin_cli-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
drinkzen_admin_cli-0.2.4.dist-info/entry_points.txt,sha256=0EjRf2gLI4MKsXoTXgHzOjLlVftpq5vNnUL4q_MvjmA,59
|
|
12
|
+
drinkzen_admin_cli-0.2.4.dist-info/top_level.txt,sha256=k_JFdt5MAKU_c2_yjnHXKsNB-6FSFi3CexPyrNMLYKg,21
|
|
13
|
+
drinkzen_admin_cli-0.2.4.dist-info/RECORD,,
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import stat
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
from contextlib import redirect_stdout
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from drinkzen_admin.cli import main
|
|
12
|
+
from drinkzen_admin.config import (
|
|
13
|
+
cmd_config_path,
|
|
14
|
+
cmd_config_set,
|
|
15
|
+
cmd_config_show,
|
|
16
|
+
get_config_path,
|
|
17
|
+
load_config,
|
|
18
|
+
mask_token,
|
|
19
|
+
save_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestAdminConfig(unittest.TestCase):
|
|
24
|
+
def setUp(self):
|
|
25
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
26
|
+
self.config_path = Path(self.temp_dir.name) / "admin.json"
|
|
27
|
+
self.env_patch = patch.dict("os.environ", {"DRINKZEN_ADMIN_CONFIG_PATH": str(self.config_path)})
|
|
28
|
+
self.env_patch.start()
|
|
29
|
+
|
|
30
|
+
def tearDown(self):
|
|
31
|
+
self.env_patch.stop()
|
|
32
|
+
self.temp_dir.cleanup()
|
|
33
|
+
|
|
34
|
+
def test_mask_token(self):
|
|
35
|
+
self.assertEqual(mask_token(""), "")
|
|
36
|
+
self.assertEqual(mask_token("1234"), "****")
|
|
37
|
+
self.assertEqual(mask_token("sample_token_value_1234"), "samp****1234")
|
|
38
|
+
|
|
39
|
+
def test_save_and_load_config(self):
|
|
40
|
+
saved = save_config(url="http://api.test:8000", token="dummy-token-xyz")
|
|
41
|
+
self.assertEqual(saved["url"], "http://api.test:8000")
|
|
42
|
+
self.assertEqual(saved["token"], "dummy-token-xyz")
|
|
43
|
+
|
|
44
|
+
loaded = load_config()
|
|
45
|
+
self.assertEqual(loaded["url"], "http://api.test:8000")
|
|
46
|
+
self.assertEqual(loaded["token"], "dummy-token-xyz")
|
|
47
|
+
|
|
48
|
+
# Check POSIX file permission
|
|
49
|
+
if sys.platform != "win32":
|
|
50
|
+
mode = stat.S_IMODE(self.config_path.stat().st_mode)
|
|
51
|
+
self.assertEqual(mode, 0o600)
|
|
52
|
+
|
|
53
|
+
def test_config_set_cli(self):
|
|
54
|
+
buf = io.StringIO()
|
|
55
|
+
with patch("sys.argv", ["drinkzen-admin", "config", "set", "--set-url", "https://api.drinkzen.cn", "--set-token", "dummy-auth-key", "--json"]):
|
|
56
|
+
with redirect_stdout(buf):
|
|
57
|
+
exit_code = main()
|
|
58
|
+
|
|
59
|
+
self.assertEqual(exit_code, 0)
|
|
60
|
+
output = json.loads(buf.getvalue())
|
|
61
|
+
self.assertEqual(output["url"], "https://api.drinkzen.cn")
|
|
62
|
+
self.assertEqual(output["token"], "dumm****-key")
|
|
63
|
+
|
|
64
|
+
loaded = load_config()
|
|
65
|
+
self.assertEqual(loaded["url"], "https://api.drinkzen.cn")
|
|
66
|
+
self.assertEqual(loaded["token"], "dummy-auth-key")
|
|
67
|
+
|
|
68
|
+
def test_config_show_cli(self):
|
|
69
|
+
save_config(url="https://api.drinkzen.cn", token="dummy-user-auth-1234")
|
|
70
|
+
buf = io.StringIO()
|
|
71
|
+
with patch("sys.argv", ["drinkzen-admin", "config", "show", "--json"]):
|
|
72
|
+
with redirect_stdout(buf):
|
|
73
|
+
exit_code = main()
|
|
74
|
+
|
|
75
|
+
self.assertEqual(exit_code, 0)
|
|
76
|
+
output = json.loads(buf.getvalue())
|
|
77
|
+
self.assertTrue(output["file_exists"])
|
|
78
|
+
self.assertEqual(output["active_url"], "https://api.drinkzen.cn")
|
|
79
|
+
self.assertEqual(output["active_token"], "dumm****1234")
|
|
80
|
+
|
|
81
|
+
def test_config_path_cli(self):
|
|
82
|
+
buf = io.StringIO()
|
|
83
|
+
with patch("sys.argv", ["drinkzen-admin", "config", "path", "--json"]):
|
|
84
|
+
with redirect_stdout(buf):
|
|
85
|
+
exit_code = main()
|
|
86
|
+
|
|
87
|
+
self.assertEqual(exit_code, 0)
|
|
88
|
+
output = json.loads(buf.getvalue())
|
|
89
|
+
self.assertEqual(output["path"], str(self.config_path.resolve()))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
if __name__ == "__main__":
|
|
93
|
+
unittest.main()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from contextlib import redirect_stdout
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
from drinkzen_admin.cli import main
|
|
10
|
+
from drinkzen_admin.client import AdminCLIClient
|
|
11
|
+
from drinkzen_admin.formatting import format_cli_output
|
|
12
|
+
from drinkzen_admin.menu_options import normalize_menu_option_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestStandaloneCLI(unittest.TestCase):
|
|
16
|
+
def test_format_cli_output(self):
|
|
17
|
+
data = [{"id": "b-1", "name": "霸王茶姬"}]
|
|
18
|
+
json_out = format_cli_output(data, is_json=True)
|
|
19
|
+
self.assertIn('"name": "霸王茶姬"', json_out)
|
|
20
|
+
|
|
21
|
+
txt_out = format_cli_output(data, is_json=False)
|
|
22
|
+
self.assertIn("霸王茶姬", txt_out)
|
|
23
|
+
|
|
24
|
+
def test_normalize_menu_option_config(self):
|
|
25
|
+
valid = {
|
|
26
|
+
"optionGroups": [
|
|
27
|
+
{
|
|
28
|
+
"key": "sweetness",
|
|
29
|
+
"label": "甜度",
|
|
30
|
+
"values": ["标准糖", "少糖"],
|
|
31
|
+
"selectionType": "single",
|
|
32
|
+
"defaultValues": ["少糖"],
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
res = normalize_menu_option_config(valid)
|
|
37
|
+
self.assertEqual(res["optionGroups"][0]["key"], "sweetness")
|
|
38
|
+
|
|
39
|
+
invalid = {"optionGroups": "invalid_array"}
|
|
40
|
+
with self.assertRaises(ValueError):
|
|
41
|
+
normalize_menu_option_config(invalid)
|
|
42
|
+
|
|
43
|
+
@patch("drinkzen_admin.cli.AdminCLIClient")
|
|
44
|
+
def test_brand_list_command(self, mock_client_cls):
|
|
45
|
+
mock_client = MagicMock()
|
|
46
|
+
mock_client.request.return_value = (200, [{"id": "b-1", "name": "霸王茶姬"}])
|
|
47
|
+
mock_client_cls.return_value = mock_client
|
|
48
|
+
|
|
49
|
+
buf = io.StringIO()
|
|
50
|
+
with patch("sys.argv", ["drinkzen-admin", "brand", "list", "--json"]):
|
|
51
|
+
with redirect_stdout(buf):
|
|
52
|
+
exit_code = main()
|
|
53
|
+
|
|
54
|
+
self.assertEqual(exit_code, 0)
|
|
55
|
+
output = json.loads(buf.getvalue())
|
|
56
|
+
self.assertEqual(len(output), 1)
|
|
57
|
+
|
|
58
|
+
@patch("drinkzen_admin.cli.AdminCLIClient")
|
|
59
|
+
def test_brand_update_dry_run(self, mock_client_cls):
|
|
60
|
+
mock_client = MagicMock()
|
|
61
|
+
mock_client.request.return_value = (200, {"id": "brand-chagee", "name": "霸王茶姬"})
|
|
62
|
+
mock_client_cls.return_value = mock_client
|
|
63
|
+
|
|
64
|
+
buf = io.StringIO()
|
|
65
|
+
with patch("sys.argv", ["drinkzen-admin", "brand", "update", "霸王茶姬", "--name", "新名称", "--dry-run", "--json"]):
|
|
66
|
+
with redirect_stdout(buf):
|
|
67
|
+
exit_code = main()
|
|
68
|
+
|
|
69
|
+
self.assertEqual(exit_code, 0)
|
|
70
|
+
output = json.loads(buf.getvalue())
|
|
71
|
+
self.assertTrue(output.get("dry_run"))
|
|
72
|
+
self.assertEqual(output["proposed"]["name"], "新名称")
|
|
73
|
+
|
|
74
|
+
def test_menu_template_validate_command(self):
|
|
75
|
+
valid_template = json.dumps({
|
|
76
|
+
"optionGroups": [
|
|
77
|
+
{
|
|
78
|
+
"key": "ice",
|
|
79
|
+
"label": "温度",
|
|
80
|
+
"values": ["正常冰", "少冰"],
|
|
81
|
+
"selectionType": "single",
|
|
82
|
+
}
|
|
83
|
+
]
|
|
84
|
+
})
|
|
85
|
+
buf = io.StringIO()
|
|
86
|
+
with patch("sys.argv", ["drinkzen-admin", "menu-template", "validate", valid_template, "--json"]):
|
|
87
|
+
with redirect_stdout(buf):
|
|
88
|
+
exit_code = main()
|
|
89
|
+
|
|
90
|
+
self.assertEqual(exit_code, 0)
|
|
91
|
+
output = json.loads(buf.getvalue())
|
|
92
|
+
self.assertTrue(output["valid"])
|
|
93
|
+
|
|
94
|
+
@patch("drinkzen_admin.cli.AdminCLIClient")
|
|
95
|
+
def test_review_resolve_dry_run(self, mock_client_cls):
|
|
96
|
+
mock_client = MagicMock()
|
|
97
|
+
mock_client.request.return_value = (200, {"task_id": 10, "submission_id": "sub-1"})
|
|
98
|
+
mock_client_cls.return_value = mock_client
|
|
99
|
+
|
|
100
|
+
buf = io.StringIO()
|
|
101
|
+
with patch("sys.argv", ["drinkzen-admin", "review", "resolve", "10", "create", "--reason", "测试入库", "--dry-run", "--json"]):
|
|
102
|
+
with redirect_stdout(buf):
|
|
103
|
+
exit_code = main()
|
|
104
|
+
|
|
105
|
+
self.assertEqual(exit_code, 0)
|
|
106
|
+
output = json.loads(buf.getvalue())
|
|
107
|
+
self.assertTrue(output.get("dry_run"))
|
|
108
|
+
self.assertEqual(output["proposed_payload"]["action"], "approve_new")
|
|
109
|
+
self.assertEqual(output["proposed_payload"]["resolution_note"], "测试入库")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
unittest.main()
|