bookai-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bookai_cli-0.1.0.dist-info/METADATA +73 -0
- bookai_cli-0.1.0.dist-info/RECORD +12 -0
- bookai_cli-0.1.0.dist-info/WHEEL +4 -0
- bookai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- groupsales_cli/__init__.py +1 -0
- groupsales_cli/__main__.py +32 -0
- groupsales_cli/client.py +75 -0
- groupsales_cli/commands/__init__.py +0 -0
- groupsales_cli/commands/orders.py +21 -0
- groupsales_cli/commands/pricing.py +99 -0
- groupsales_cli/commands/venues.py +18 -0
- groupsales_cli/output.py +59 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: bookai-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line client for the bookai group-sales back office API
|
|
5
|
+
Requires-Python: >=3.9
|
|
6
|
+
Requires-Dist: click<9,>=8
|
|
7
|
+
Requires-Dist: httpx<1,>=0.27
|
|
8
|
+
Requires-Dist: rich>=13
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest<9,>=8; extra == 'dev'
|
|
11
|
+
Requires-Dist: respx<1,>=0.21; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# bookai-cli
|
|
15
|
+
|
|
16
|
+
Command-line client for the bookai group-sales back office API — manage venue
|
|
17
|
+
pricing and view confirmed orders from the terminal or a script, instead of
|
|
18
|
+
clicking through the admin web app.
|
|
19
|
+
|
|
20
|
+
This is a thin HTTP client: it talks only to the public, authenticated JSON
|
|
21
|
+
API and contains no business logic or backend code.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install bookai-cli
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Authenticate
|
|
30
|
+
|
|
31
|
+
Ask a venue admin to create an API key for you from the back office (Settings
|
|
32
|
+
→ API Keys), then set it as an environment variable:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
export GROUPSALES_API_KEY=gsk_...
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or pass it per-command with `--api-key`. By default the CLI talks to
|
|
39
|
+
`https://b2b.bookai.now`; override with `--base-url` or `GROUPSALES_BASE_URL`
|
|
40
|
+
if you're pointed at a different environment.
|
|
41
|
+
|
|
42
|
+
Global options (`--api-key`, `--base-url`, `--json`) go **before** the
|
|
43
|
+
subcommand: `bookai --json venues list`, not `bookai venues list --json`.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
bookai venues list
|
|
49
|
+
|
|
50
|
+
bookai pricing list <venue_id>
|
|
51
|
+
bookai pricing get <venue_id> <rule_id>
|
|
52
|
+
bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max-price 30
|
|
53
|
+
bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max-price 30 \
|
|
54
|
+
--source acme-isv --external-id acme-rule-42 # tag a rule as synced from an external system
|
|
55
|
+
bookai pricing update <venue_id> <rule_id> --min-group 10 --max-group 50 --min-price 18 --max-price 28
|
|
56
|
+
bookai pricing delete <venue_id> <rule_id>
|
|
57
|
+
|
|
58
|
+
bookai orders list
|
|
59
|
+
bookai orders list --page 2
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Add `--json` anywhere for machine-readable output instead of a table — useful
|
|
63
|
+
for piping into `jq` or scripting in CI.
|
|
64
|
+
|
|
65
|
+
## Development
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pip install -e ".[dev]"
|
|
69
|
+
pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Tests are fully offline (HTTP is mocked via `respx`) — no server or database
|
|
73
|
+
required.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
groupsales_cli/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
groupsales_cli/__main__.py,sha256=ikSFgOBVrGD7Zmz6noNhKucKtmAoWgiIqDR3LeRTf9w,1194
|
|
3
|
+
groupsales_cli/client.py,sha256=OhhYThXudxmL0e_C6DBVA9HzOyXARBqdmcvyfSRgcK0,2601
|
|
4
|
+
groupsales_cli/output.py,sha256=ygODLIn36tFnbSsb3bmIPnwaW7TGJ65Uev6AKAwwpTA,2173
|
|
5
|
+
groupsales_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
groupsales_cli/commands/orders.py,sha256=SIfjo8gDAWMmJupbH1UVJWj-vD-du40e-Jf43YU6-ro,666
|
|
7
|
+
groupsales_cli/commands/pricing.py,sha256=AKtBihsPTwNMKludRLU1X6Ocn5Ch5r8RzOMr7qCj0dQ,3659
|
|
8
|
+
groupsales_cli/commands/venues.py,sha256=zcIh74wmE2pl-E0kVRNLJoCpC0UzuQ1xfZicZv0DF3M,475
|
|
9
|
+
bookai_cli-0.1.0.dist-info/METADATA,sha256=AmH_BcQ9f-3otdFydCkEGcnhj6v-3okB9NyEXNMN-Vs,2162
|
|
10
|
+
bookai_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
11
|
+
bookai_cli-0.1.0.dist-info/entry_points.txt,sha256=0svn_dXDJ6nH3LJenLaETCM3WKBLKRsMJiMYWNHRDAo,55
|
|
12
|
+
bookai_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""bookai -- CLI client for the bookai group-sales back office API."""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from groupsales_cli import __version__
|
|
6
|
+
from groupsales_cli.client import DEFAULT_BASE_URL
|
|
7
|
+
from groupsales_cli.commands.orders import orders
|
|
8
|
+
from groupsales_cli.commands.pricing import pricing
|
|
9
|
+
from groupsales_cli.commands.venues import venues
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.group()
|
|
13
|
+
@click.option("--api-key", envvar="GROUPSALES_API_KEY", default=None,
|
|
14
|
+
help="API key (or set GROUPSALES_API_KEY).")
|
|
15
|
+
@click.option("--base-url", envvar="GROUPSALES_BASE_URL", default=DEFAULT_BASE_URL, show_default=True,
|
|
16
|
+
help="Back office API base URL (or set GROUPSALES_BASE_URL).")
|
|
17
|
+
@click.option("--json", "as_json", is_flag=True, default=False,
|
|
18
|
+
help="Print raw JSON instead of a table.")
|
|
19
|
+
@click.version_option(__version__, prog_name="bookai")
|
|
20
|
+
@click.pass_context
|
|
21
|
+
def cli(ctx: click.Context, api_key: str | None, base_url: str, as_json: bool):
|
|
22
|
+
"""bookai: manage venues, pricing, and orders from the command line."""
|
|
23
|
+
ctx.obj = {"api_key": api_key, "base_url": base_url, "json": as_json}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
cli.add_command(venues)
|
|
27
|
+
cli.add_command(pricing)
|
|
28
|
+
cli.add_command(orders)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
cli()
|
groupsales_cli/client.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Thin HTTP client for the bookai group-sales back office API.
|
|
2
|
+
|
|
3
|
+
Talks only to the public, authenticated JSON API over `Authorization: Bearer
|
|
4
|
+
<api key>` -- this package never imports anything from the private backend
|
|
5
|
+
repo (services/models/routers), which is what makes it safe to publish this
|
|
6
|
+
CLI publicly while the backend stays closed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
DEFAULT_BASE_URL = "https://b2b.bookai.now"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ApiError(click.ClickException):
|
|
16
|
+
"""click prints .format_message() to stderr and exits 1 -- integrators
|
|
17
|
+
see a clean message, never a Python traceback."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, status_code: int, detail: str):
|
|
20
|
+
super().__init__(f"API error {status_code}: {detail}")
|
|
21
|
+
self.status_code = status_code
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Client:
|
|
25
|
+
def __init__(self, base_url: str, api_key: str):
|
|
26
|
+
self._http = httpx.Client(
|
|
27
|
+
base_url=base_url,
|
|
28
|
+
headers={"Authorization": f"Bearer {api_key}"},
|
|
29
|
+
timeout=10,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def request(self, method: str, path: str, **kwargs):
|
|
33
|
+
try:
|
|
34
|
+
resp = self._http.request(method, path, **kwargs)
|
|
35
|
+
except httpx.RequestError as exc:
|
|
36
|
+
raise click.ClickException(f"Could not reach {self._http.base_url}: {exc}") from exc
|
|
37
|
+
if resp.status_code >= 400:
|
|
38
|
+
raise ApiError(resp.status_code, _error_detail(resp))
|
|
39
|
+
if resp.status_code == 204 or not resp.content:
|
|
40
|
+
return None
|
|
41
|
+
return resp.json()
|
|
42
|
+
|
|
43
|
+
def get(self, path: str, params: dict | None = None):
|
|
44
|
+
return self.request("GET", path, params=params)
|
|
45
|
+
|
|
46
|
+
def post(self, path: str, json: dict):
|
|
47
|
+
return self.request("POST", path, json=json)
|
|
48
|
+
|
|
49
|
+
def put(self, path: str, json: dict):
|
|
50
|
+
return self.request("PUT", path, json=json)
|
|
51
|
+
|
|
52
|
+
def delete(self, path: str):
|
|
53
|
+
return self.request("DELETE", path)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _error_detail(resp: httpx.Response) -> str:
|
|
57
|
+
try:
|
|
58
|
+
data = resp.json()
|
|
59
|
+
except ValueError:
|
|
60
|
+
return resp.text or resp.reason_phrase
|
|
61
|
+
detail = data.get("detail") if isinstance(data, dict) else None
|
|
62
|
+
if isinstance(detail, str):
|
|
63
|
+
return detail
|
|
64
|
+
if isinstance(detail, list): # FastAPI 422 validation errors
|
|
65
|
+
return "; ".join(f"{'.'.join(str(p) for p in d.get('loc', []))}: {d.get('msg')}" for d in detail)
|
|
66
|
+
return str(data)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def build_client(ctx: click.Context) -> Client:
|
|
70
|
+
api_key = ctx.obj["api_key"]
|
|
71
|
+
if not api_key:
|
|
72
|
+
raise click.ClickException(
|
|
73
|
+
"No API key set. Pass --api-key or set the GROUPSALES_API_KEY environment variable."
|
|
74
|
+
)
|
|
75
|
+
return Client(ctx.obj["base_url"], api_key)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from groupsales_cli.client import build_client
|
|
4
|
+
from groupsales_cli.output import render
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
def orders():
|
|
9
|
+
"""View confirmed orders."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@orders.command("list")
|
|
13
|
+
@click.option("--page", type=int, default=1, show_default=True)
|
|
14
|
+
@click.pass_context
|
|
15
|
+
def list_orders(ctx: click.Context, page: int):
|
|
16
|
+
"""List confirmed orders (paginated)."""
|
|
17
|
+
client = build_client(ctx)
|
|
18
|
+
data = client.get("/admin/orders", params={"page": page})
|
|
19
|
+
columns = ["id", "group_name", "contact_email", "group_size", "channel", "total_price", "status",
|
|
20
|
+
"created_at_local"]
|
|
21
|
+
render(data, ctx.obj["json"], title="Orders", columns=columns)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from groupsales_cli.client import build_client
|
|
4
|
+
from groupsales_cli.output import render
|
|
5
|
+
|
|
6
|
+
_RULE_OPTIONS = [
|
|
7
|
+
click.option("--min-group", type=int, required=True, help="Minimum group size this rule applies to."),
|
|
8
|
+
click.option("--max-group", type=int, required=True, help="Maximum group size this rule applies to."),
|
|
9
|
+
click.option("--min-price", type=float, required=True, help="Price floor per person."),
|
|
10
|
+
click.option("--max-price", type=float, required=True, help="Price ceiling per person."),
|
|
11
|
+
click.option("--event-id", default=None, help="Scope this rule to one event instead of the whole venue."),
|
|
12
|
+
click.option("--source", default="app", show_default=True, help="Origin tag, e.g. a 3P vendor name."),
|
|
13
|
+
click.option("--external-id", default=None, help="3P upsert key -- ignored while --source is 'app'."),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _add_rule_options(f):
|
|
18
|
+
for option in reversed(_RULE_OPTIONS):
|
|
19
|
+
f = option(f)
|
|
20
|
+
return f
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _rule_body(min_group, max_group, min_price, max_price, event_id, source, external_id) -> dict:
|
|
24
|
+
return {
|
|
25
|
+
"min_group": min_group,
|
|
26
|
+
"max_group": max_group,
|
|
27
|
+
"min_price": min_price,
|
|
28
|
+
"max_price": max_price,
|
|
29
|
+
"event_id": event_id,
|
|
30
|
+
"source": source,
|
|
31
|
+
"external_id": external_id,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_RULE_COLUMNS = ["id", "min_group", "max_group", "min_price", "max_price", "event_id", "source", "external_id"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@click.group()
|
|
39
|
+
def pricing():
|
|
40
|
+
"""Manage per-venue pricing rules."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pricing.command("list")
|
|
44
|
+
@click.argument("venue_id")
|
|
45
|
+
@click.pass_context
|
|
46
|
+
def list_pricing(ctx: click.Context, venue_id: str):
|
|
47
|
+
"""List pricing rules for a venue."""
|
|
48
|
+
client = build_client(ctx)
|
|
49
|
+
data = client.get(f"/admin/venues/{venue_id}/pricing")
|
|
50
|
+
render(data, ctx.obj["json"], title=f"Pricing rules -- {venue_id}", columns=_RULE_COLUMNS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pricing.command("get")
|
|
54
|
+
@click.argument("venue_id")
|
|
55
|
+
@click.argument("rule_id")
|
|
56
|
+
@click.pass_context
|
|
57
|
+
def get_pricing(ctx: click.Context, venue_id: str, rule_id: str):
|
|
58
|
+
"""Show one pricing rule."""
|
|
59
|
+
client = build_client(ctx)
|
|
60
|
+
data = client.get(f"/admin/venues/{venue_id}/pricing/{rule_id}")
|
|
61
|
+
render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pricing.command("set")
|
|
65
|
+
@click.argument("venue_id")
|
|
66
|
+
@_add_rule_options
|
|
67
|
+
@click.pass_context
|
|
68
|
+
def set_pricing(ctx: click.Context, venue_id: str, min_group, max_group, min_price, max_price,
|
|
69
|
+
event_id, source, external_id):
|
|
70
|
+
"""Create a new pricing rule."""
|
|
71
|
+
client = build_client(ctx)
|
|
72
|
+
body = _rule_body(min_group, max_group, min_price, max_price, event_id, source, external_id)
|
|
73
|
+
data = client.post(f"/admin/venues/{venue_id}/pricing", json=body)
|
|
74
|
+
render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@pricing.command("update")
|
|
78
|
+
@click.argument("venue_id")
|
|
79
|
+
@click.argument("rule_id")
|
|
80
|
+
@_add_rule_options
|
|
81
|
+
@click.pass_context
|
|
82
|
+
def update_pricing(ctx: click.Context, venue_id: str, rule_id: str, min_group, max_group, min_price, max_price,
|
|
83
|
+
event_id, source, external_id):
|
|
84
|
+
"""Replace an existing pricing rule."""
|
|
85
|
+
client = build_client(ctx)
|
|
86
|
+
body = _rule_body(min_group, max_group, min_price, max_price, event_id, source, external_id)
|
|
87
|
+
data = client.put(f"/admin/venues/{venue_id}/pricing/{rule_id}", json=body)
|
|
88
|
+
render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@pricing.command("delete")
|
|
92
|
+
@click.argument("venue_id")
|
|
93
|
+
@click.argument("rule_id")
|
|
94
|
+
@click.pass_context
|
|
95
|
+
def delete_pricing(ctx: click.Context, venue_id: str, rule_id: str):
|
|
96
|
+
"""Delete a pricing rule."""
|
|
97
|
+
client = build_client(ctx)
|
|
98
|
+
client.delete(f"/admin/venues/{venue_id}/pricing/{rule_id}")
|
|
99
|
+
click.echo(f"Deleted {rule_id}")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from groupsales_cli.client import build_client
|
|
4
|
+
from groupsales_cli.output import render
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@click.group()
|
|
8
|
+
def venues():
|
|
9
|
+
"""Manage venues."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@venues.command("list")
|
|
13
|
+
@click.pass_context
|
|
14
|
+
def list_venues(ctx: click.Context):
|
|
15
|
+
"""List venues visible to this API key's account."""
|
|
16
|
+
client = build_client(ctx)
|
|
17
|
+
data = client.get("/admin/venues")
|
|
18
|
+
render(data, ctx.obj["json"], title="Venues", columns=["id", "name", "city_name", "state", "country"])
|
groupsales_cli/output.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Rendering: a rich table for humans, raw JSON for --json / scripts -- the
|
|
2
|
+
CLI's integrators range from non-technical staff to CI pipelines, so both
|
|
3
|
+
need to be first-class, not one bolted onto the other."""
|
|
4
|
+
|
|
5
|
+
import json as json_lib
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
_console = Console()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def render(data, as_json: bool, title: str | None = None, columns: list[str] | None = None) -> None:
|
|
15
|
+
"""columns curates which fields show in table view (order matters) --
|
|
16
|
+
without it every JSON key becomes a column, which is unreadable once a
|
|
17
|
+
resource has a nested/long field (e.g. Venue.branding, Order.lines).
|
|
18
|
+
--json always returns the full record regardless of columns."""
|
|
19
|
+
if as_json:
|
|
20
|
+
click.echo(json_lib.dumps(data, indent=2, default=str))
|
|
21
|
+
return
|
|
22
|
+
if data is None:
|
|
23
|
+
click.echo("OK")
|
|
24
|
+
return
|
|
25
|
+
if isinstance(data, list):
|
|
26
|
+
_render_list(data, title, columns)
|
|
27
|
+
elif isinstance(data, dict) and isinstance(data.get("items"), list):
|
|
28
|
+
_render_list(data["items"], title, columns)
|
|
29
|
+
click.echo(f"page {data.get('page')}/{data.get('total_pages')} ({data.get('total')} total)")
|
|
30
|
+
else:
|
|
31
|
+
_render_list([data], title, columns)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _render_list(rows: list, title: str | None, columns: list[str] | None) -> None:
|
|
35
|
+
if not rows:
|
|
36
|
+
click.echo("(none)")
|
|
37
|
+
return
|
|
38
|
+
cols = columns or list(rows[0].keys())
|
|
39
|
+
table = Table(title=title)
|
|
40
|
+
for col in cols:
|
|
41
|
+
if col == "id":
|
|
42
|
+
# Never truncate or wrap the row's own id -- it's the handle a
|
|
43
|
+
# follow-up get/update/delete command needs verbatim, unlike
|
|
44
|
+
# every other column here, where losing a few characters to an
|
|
45
|
+
# ellipsis is a fine, normal CLI table tradeoff.
|
|
46
|
+
table.add_column(col, no_wrap=True)
|
|
47
|
+
else:
|
|
48
|
+
table.add_column(col, overflow="fold")
|
|
49
|
+
for row in rows:
|
|
50
|
+
table.add_row(*(_cell(row.get(col)) for col in cols))
|
|
51
|
+
_console.print(table)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cell(value) -> str:
|
|
55
|
+
if value is None:
|
|
56
|
+
return ""
|
|
57
|
+
if isinstance(value, (dict, list)):
|
|
58
|
+
return json_lib.dumps(value)
|
|
59
|
+
return str(value)
|