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.
@@ -0,0 +1,3 @@
1
+ """DrinkZen Admin CLI package."""
2
+
3
+ __version__ = "0.2.4"
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())
@@ -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}"}
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ drinkzen-admin = drinkzen_admin.cli:main
@@ -0,0 +1,2 @@
1
+ drinkzen_admin
2
+ tests
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()