bookai-cli 0.2.0__tar.gz → 0.5.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/.gitignore +4 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/PKG-INFO +44 -3
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/README.md +43 -2
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/pyproject.toml +7 -1
- bookai_cli-0.5.0/src/groupsales_cli/__init__.py +1 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/__main__.py +4 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/client.py +3 -0
- bookai_cli-0.5.0/src/groupsales_cli/commands/offerings.py +211 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/pricing.py +8 -8
- bookai_cli-0.5.0/src/groupsales_cli/commands/search.py +49 -0
- bookai_cli-0.5.0/tests/test_offerings.py +188 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_pricing.py +1 -1
- bookai_cli-0.5.0/tests/test_search.py +55 -0
- bookai_cli-0.2.0/src/groupsales_cli/__init__.py +0 -1
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/__init__.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/auth.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/contacts.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/orders.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/venues.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/config.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/credentials.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/output.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/shell.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/conftest.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_auth.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_contacts.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_orders.py +0 -0
- {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_venues.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bookai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Command-line client for the bookai group-sales back office API
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Requires-Dist: click<9,>=8
|
|
@@ -15,8 +15,8 @@ Description-Content-Type: text/markdown
|
|
|
15
15
|
# bookai-cli
|
|
16
16
|
|
|
17
17
|
Command-line client for the bookai group-sales back office API — manage venue
|
|
18
|
-
pricing and view confirmed orders from the
|
|
19
|
-
clicking through the admin web app.
|
|
18
|
+
pricing, offerings and their showings, and view confirmed orders from the
|
|
19
|
+
terminal or a script, instead of clicking through the admin web app.
|
|
20
20
|
|
|
21
21
|
This is a thin HTTP client: it talks only to the public, authenticated JSON
|
|
22
22
|
API and contains no business logic or backend code.
|
|
@@ -131,10 +131,51 @@ bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max
|
|
|
131
131
|
bookai pricing update <venue_id> <rule_id> --min-group 10 --max-group 50 --min-price 18 --max-price 28
|
|
132
132
|
bookai pricing delete <venue_id> <rule_id>
|
|
133
133
|
|
|
134
|
+
bookai offerings list
|
|
135
|
+
bookai offerings show <offering_id>
|
|
136
|
+
bookai offerings extend <offering_id> --to 2027-03-31 # run it for longer
|
|
137
|
+
bookai offerings extend <offering_id> --by 3m # ...or just push the end date out
|
|
138
|
+
|
|
139
|
+
bookai offerings slots list <offering_id>
|
|
140
|
+
bookai offerings slots add <offering_id> --date 2026-11-19 --time 19:00 --capacity 400
|
|
141
|
+
bookai offerings slots add <offering_id> --date 2026-11-19 --time 19:00 --capacity 400 \
|
|
142
|
+
--duration 4 --repeat-weekly 3 # lay out a four-week run in one go
|
|
143
|
+
bookai offerings slots set <offering_id> <slot_id> --time 14:00 # move it, keeping its length
|
|
144
|
+
bookai offerings slots set <offering_id> <slot_id> --capacity 250
|
|
145
|
+
bookai offerings slots rm <offering_id> <slot_id>
|
|
146
|
+
|
|
134
147
|
bookai orders list
|
|
135
148
|
bookai orders list --page 2
|
|
149
|
+
|
|
150
|
+
bookai search acme # every record at your venue: guests, deals, setup
|
|
151
|
+
bookai search where do coaches park # matches meaning, not just exact words
|
|
152
|
+
bookai search jane@school.org --type offer --type quote
|
|
153
|
+
bookai --json search "school trip" --limit 5 # the API's response, for scripts
|
|
136
154
|
```
|
|
137
155
|
|
|
156
|
+
### Showings
|
|
157
|
+
|
|
158
|
+
`offerings slots` manages the individual showings of a timed offering — the
|
|
159
|
+
dates and times people actually book. `--repeat-weekly` is there because a
|
|
160
|
+
season is usually a repeating shape rather than a list of one-off dates:
|
|
161
|
+
`--repeat-weekly 3` creates the showing plus three weekly copies, four in
|
|
162
|
+
total.
|
|
163
|
+
|
|
164
|
+
The API keeps you from doing the things that would quietly break bookings, and
|
|
165
|
+
the CLI reports its reason rather than swallowing it:
|
|
166
|
+
|
|
167
|
+
- a showing with seats already sold can't be removed, and its capacity can't
|
|
168
|
+
drop below what's sold
|
|
169
|
+
- a showing outside the offering's run is refused, rather than sitting on the
|
|
170
|
+
calendar looking bookable — extend the run first
|
|
171
|
+
- narrowing a run past showings that already exist is refused for the same
|
|
172
|
+
reason: they'd stay on the calendar while quietly dropping out of
|
|
173
|
+
availability
|
|
174
|
+
|
|
175
|
+
`offerings extend` is exactly that "extend the run first" step. It's a plain
|
|
176
|
+
update of the offering's end date — `--to` for an exact date, `--by 3m` (also
|
|
177
|
+
`6w`, `30d`) to push it out from wherever it currently ends.
|
|
178
|
+
|
|
138
179
|
`bookai venues create` is the one command that works with no API key at all —
|
|
139
180
|
it's how a brand-new venue's very first account gets created in the first
|
|
140
181
|
place (same as signing up in the browser). It asks for the venue's details
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# bookai-cli
|
|
2
2
|
|
|
3
3
|
Command-line client for the bookai group-sales back office API — manage venue
|
|
4
|
-
pricing and view confirmed orders from the
|
|
5
|
-
clicking through the admin web app.
|
|
4
|
+
pricing, offerings and their showings, and view confirmed orders from the
|
|
5
|
+
terminal or a script, instead of clicking through the admin web app.
|
|
6
6
|
|
|
7
7
|
This is a thin HTTP client: it talks only to the public, authenticated JSON
|
|
8
8
|
API and contains no business logic or backend code.
|
|
@@ -117,10 +117,51 @@ bookai pricing set <venue_id> --min-group 10 --max-group 50 --min-price 20 --max
|
|
|
117
117
|
bookai pricing update <venue_id> <rule_id> --min-group 10 --max-group 50 --min-price 18 --max-price 28
|
|
118
118
|
bookai pricing delete <venue_id> <rule_id>
|
|
119
119
|
|
|
120
|
+
bookai offerings list
|
|
121
|
+
bookai offerings show <offering_id>
|
|
122
|
+
bookai offerings extend <offering_id> --to 2027-03-31 # run it for longer
|
|
123
|
+
bookai offerings extend <offering_id> --by 3m # ...or just push the end date out
|
|
124
|
+
|
|
125
|
+
bookai offerings slots list <offering_id>
|
|
126
|
+
bookai offerings slots add <offering_id> --date 2026-11-19 --time 19:00 --capacity 400
|
|
127
|
+
bookai offerings slots add <offering_id> --date 2026-11-19 --time 19:00 --capacity 400 \
|
|
128
|
+
--duration 4 --repeat-weekly 3 # lay out a four-week run in one go
|
|
129
|
+
bookai offerings slots set <offering_id> <slot_id> --time 14:00 # move it, keeping its length
|
|
130
|
+
bookai offerings slots set <offering_id> <slot_id> --capacity 250
|
|
131
|
+
bookai offerings slots rm <offering_id> <slot_id>
|
|
132
|
+
|
|
120
133
|
bookai orders list
|
|
121
134
|
bookai orders list --page 2
|
|
135
|
+
|
|
136
|
+
bookai search acme # every record at your venue: guests, deals, setup
|
|
137
|
+
bookai search where do coaches park # matches meaning, not just exact words
|
|
138
|
+
bookai search jane@school.org --type offer --type quote
|
|
139
|
+
bookai --json search "school trip" --limit 5 # the API's response, for scripts
|
|
122
140
|
```
|
|
123
141
|
|
|
142
|
+
### Showings
|
|
143
|
+
|
|
144
|
+
`offerings slots` manages the individual showings of a timed offering — the
|
|
145
|
+
dates and times people actually book. `--repeat-weekly` is there because a
|
|
146
|
+
season is usually a repeating shape rather than a list of one-off dates:
|
|
147
|
+
`--repeat-weekly 3` creates the showing plus three weekly copies, four in
|
|
148
|
+
total.
|
|
149
|
+
|
|
150
|
+
The API keeps you from doing the things that would quietly break bookings, and
|
|
151
|
+
the CLI reports its reason rather than swallowing it:
|
|
152
|
+
|
|
153
|
+
- a showing with seats already sold can't be removed, and its capacity can't
|
|
154
|
+
drop below what's sold
|
|
155
|
+
- a showing outside the offering's run is refused, rather than sitting on the
|
|
156
|
+
calendar looking bookable — extend the run first
|
|
157
|
+
- narrowing a run past showings that already exist is refused for the same
|
|
158
|
+
reason: they'd stay on the calendar while quietly dropping out of
|
|
159
|
+
availability
|
|
160
|
+
|
|
161
|
+
`offerings extend` is exactly that "extend the run first" step. It's a plain
|
|
162
|
+
update of the offering's end date — `--to` for an exact date, `--by 3m` (also
|
|
163
|
+
`6w`, `30d`) to push it out from wherever it currently ends.
|
|
164
|
+
|
|
124
165
|
`bookai venues create` is the one command that works with no API key at all —
|
|
125
166
|
it's how a brand-new venue's very first account gets created in the first
|
|
126
167
|
place (same as signing up in the browser). It asks for the venue's details
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bookai-cli"
|
|
7
|
-
|
|
7
|
+
dynamic = ["version"]
|
|
8
8
|
description = "Command-line client for the bookai group-sales back office API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.9"
|
|
@@ -24,5 +24,11 @@ dev = [
|
|
|
24
24
|
[project.scripts]
|
|
25
25
|
bookai = "groupsales_cli.__main__:cli"
|
|
26
26
|
|
|
27
|
+
# Single source of truth for the version: `bookai --version` reads
|
|
28
|
+
# groupsales_cli.__version__, and this reads the same line, so the two cannot drift
|
|
29
|
+
# apart again (they did: pyproject said 0.3.0 while the CLI reported 0.2.0).
|
|
30
|
+
[tool.hatch.version]
|
|
31
|
+
path = "src/groupsales_cli/__init__.py"
|
|
32
|
+
|
|
27
33
|
[tool.hatch.build.targets.wheel]
|
|
28
34
|
packages = ["src/groupsales_cli"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -7,7 +7,9 @@ from groupsales_cli.client import DEFAULT_BASE_URL, ENV_URLS
|
|
|
7
7
|
from groupsales_cli.commands.auth import login, logout, whoami
|
|
8
8
|
from groupsales_cli.commands.contacts import contacts
|
|
9
9
|
from groupsales_cli.commands.orders import orders
|
|
10
|
+
from groupsales_cli.commands.offerings import offerings
|
|
10
11
|
from groupsales_cli.commands.pricing import pricing
|
|
12
|
+
from groupsales_cli.commands.search import search
|
|
11
13
|
from groupsales_cli.commands.venues import venues
|
|
12
14
|
|
|
13
15
|
|
|
@@ -62,8 +64,10 @@ cli.add_command(logout)
|
|
|
62
64
|
cli.add_command(whoami)
|
|
63
65
|
cli.add_command(venues)
|
|
64
66
|
cli.add_command(contacts)
|
|
67
|
+
cli.add_command(offerings)
|
|
65
68
|
cli.add_command(pricing)
|
|
66
69
|
cli.add_command(orders)
|
|
70
|
+
cli.add_command(search)
|
|
67
71
|
|
|
68
72
|
|
|
69
73
|
if __name__ == "__main__":
|
|
@@ -59,6 +59,9 @@ class Client:
|
|
|
59
59
|
def put(self, path: str, json: dict):
|
|
60
60
|
return self.request("PUT", path, json=json)
|
|
61
61
|
|
|
62
|
+
def patch(self, path: str, json: dict):
|
|
63
|
+
return self.request("PATCH", path, json=json)
|
|
64
|
+
|
|
62
65
|
def delete(self, path: str):
|
|
63
66
|
return self.request("DELETE", path)
|
|
64
67
|
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Offerings and their showings.
|
|
2
|
+
|
|
3
|
+
Thin wrappers over /admin/offerings — every rule (what a valid run is, whether a showing
|
|
4
|
+
can be removed, whether it falls inside the sellable window) lives in the API, so the CLI
|
|
5
|
+
and the back office can't disagree about it.
|
|
6
|
+
|
|
7
|
+
`extend` is the one place the CLI is deliberately friendlier than the endpoint it calls:
|
|
8
|
+
extending a run is just `valid_to` changing, so it PATCHes the offering like anything else,
|
|
9
|
+
but "extend" is how people say it and `--by 3m` is how they think about it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from datetime import date, datetime, timedelta
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from groupsales_cli.client import build_client
|
|
17
|
+
from groupsales_cli.output import render
|
|
18
|
+
|
|
19
|
+
_OFFERING_COLUMNS = ["id", "name", "category", "admission_type", "status",
|
|
20
|
+
"valid_from", "valid_to", "base_price_per_person"]
|
|
21
|
+
_SLOT_COLUMNS = ["id", "start_datetime", "end_datetime", "capacity", "sold", "available"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_day(value: str) -> date:
|
|
25
|
+
try:
|
|
26
|
+
return date.fromisoformat(value)
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise click.BadParameter(f"{value!r} is not a date — use YYYY-MM-DD")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _shift(day: date, by: str) -> date:
|
|
32
|
+
""""3m", "6w", "30d" from a date. Months are 30 days here, deliberately: the exact
|
|
33
|
+
landing date matters less than saying "about three months", and a caller who needs an
|
|
34
|
+
exact day passes --to instead."""
|
|
35
|
+
amount, unit = by[:-1], by[-1:].lower()
|
|
36
|
+
if not amount.isdigit() or unit not in {"d", "w", "m"}:
|
|
37
|
+
raise click.BadParameter(f"{by!r} should look like 30d, 6w or 3m")
|
|
38
|
+
days = {"d": 1, "w": 7, "m": 30}[unit] * int(amount)
|
|
39
|
+
return day + timedelta(days=days)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.group()
|
|
43
|
+
def offerings():
|
|
44
|
+
"""Manage offerings and their showings."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@offerings.command("list")
|
|
48
|
+
@click.pass_context
|
|
49
|
+
def list_offerings(ctx: click.Context):
|
|
50
|
+
"""List this venue's offerings."""
|
|
51
|
+
client = build_client(ctx)
|
|
52
|
+
data = client.get("/admin/offerings")
|
|
53
|
+
render([d["offering"] for d in data], ctx.obj["json"],
|
|
54
|
+
title="Offerings", columns=_OFFERING_COLUMNS)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@offerings.command("show")
|
|
58
|
+
@click.argument("offering_id")
|
|
59
|
+
@click.pass_context
|
|
60
|
+
def show_offering(ctx: click.Context, offering_id: str):
|
|
61
|
+
"""Show one offering, with its showings and pricing tiers."""
|
|
62
|
+
client = build_client(ctx)
|
|
63
|
+
data = client.get(f"/admin/offerings/{offering_id}")
|
|
64
|
+
render(data["offering"], ctx.obj["json"], columns=_OFFERING_COLUMNS)
|
|
65
|
+
if not ctx.obj["json"]:
|
|
66
|
+
render(data.get("slots", []), False, title="Showings", columns=_SLOT_COLUMNS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@offerings.command("extend")
|
|
70
|
+
@click.argument("offering_id")
|
|
71
|
+
@click.option("--to", "to_date", default=None, help="New end date, YYYY-MM-DD.")
|
|
72
|
+
@click.option("--by", default=None, help="Extend past the current end date, e.g. 3m, 6w, 30d.")
|
|
73
|
+
@click.pass_context
|
|
74
|
+
def extend_offering(ctx: click.Context, offering_id: str, to_date: str | None, by: str | None):
|
|
75
|
+
"""Extend how long an offering runs for.
|
|
76
|
+
|
|
77
|
+
Nothing more than setting valid_to, which is why it PATCHes the offering rather than
|
|
78
|
+
calling some dedicated endpoint — but it's the single most common edit, so it gets a
|
|
79
|
+
verb. The API refuses to narrow a run past showings that already exist.
|
|
80
|
+
"""
|
|
81
|
+
if bool(to_date) == bool(by):
|
|
82
|
+
raise click.UsageError("give exactly one of --to or --by")
|
|
83
|
+
client = build_client(ctx)
|
|
84
|
+
|
|
85
|
+
if to_date:
|
|
86
|
+
new_end = _parse_day(to_date)
|
|
87
|
+
else:
|
|
88
|
+
current = client.get(f"/admin/offerings/{offering_id}")["offering"]["valid_to"]
|
|
89
|
+
new_end = _shift(_parse_day(current), by)
|
|
90
|
+
|
|
91
|
+
data = client.patch(f"/admin/offerings/{offering_id}", json={"valid_to": str(new_end)})
|
|
92
|
+
if not ctx.obj["json"]:
|
|
93
|
+
click.echo(f"{data['offering']['name']} now runs until {data['offering']['valid_to']}")
|
|
94
|
+
render(data["offering"], ctx.obj["json"], columns=_OFFERING_COLUMNS)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@offerings.group()
|
|
98
|
+
def slots():
|
|
99
|
+
"""Manage an offering's showings."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@slots.command("list")
|
|
103
|
+
@click.argument("offering_id")
|
|
104
|
+
@click.pass_context
|
|
105
|
+
def list_slots(ctx: click.Context, offering_id: str):
|
|
106
|
+
"""List an offering's showings."""
|
|
107
|
+
client = build_client(ctx)
|
|
108
|
+
data = client.get(f"/admin/offerings/{offering_id}/slots")
|
|
109
|
+
render(data, ctx.obj["json"], title=f"Showings -- {offering_id}", columns=_SLOT_COLUMNS)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@slots.command("add")
|
|
113
|
+
@click.argument("offering_id")
|
|
114
|
+
@click.option("--date", "day", required=True, help="Date of the first showing, YYYY-MM-DD.")
|
|
115
|
+
@click.option("--time", "start", required=True, help="Start time, HH:MM (24h).")
|
|
116
|
+
@click.option("--duration", type=float, default=3.0, show_default=True, help="Length in hours.")
|
|
117
|
+
@click.option("--capacity", type=int, required=True, help="Seats available on each showing.")
|
|
118
|
+
@click.option("--repeat-weekly", type=int, default=0, show_default=True,
|
|
119
|
+
help="Extra weekly copies — 3 gives four showings in total.")
|
|
120
|
+
@click.pass_context
|
|
121
|
+
def add_slot(ctx: click.Context, offering_id: str, day: str, start: str,
|
|
122
|
+
duration: float, capacity: int, repeat_weekly: int):
|
|
123
|
+
"""Add a showing, optionally repeating it weekly.
|
|
124
|
+
|
|
125
|
+
A season is usually a repeating shape rather than a list of one-off dates, so
|
|
126
|
+
--repeat-weekly lays the whole run down in one call.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
starts_at = datetime.combine(_parse_day(day), datetime.strptime(start, "%H:%M").time())
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise click.BadParameter(f"{start!r} is not a time — use HH:MM, e.g. 19:00")
|
|
132
|
+
|
|
133
|
+
body = {
|
|
134
|
+
"slots": [{
|
|
135
|
+
"start_datetime": starts_at.isoformat() + "+00:00",
|
|
136
|
+
"end_datetime": (starts_at + timedelta(hours=duration)).isoformat() + "+00:00",
|
|
137
|
+
"capacity": capacity,
|
|
138
|
+
}],
|
|
139
|
+
"repeat_weekly": repeat_weekly,
|
|
140
|
+
}
|
|
141
|
+
client = build_client(ctx)
|
|
142
|
+
created = client.post(f"/admin/offerings/{offering_id}/slots", json=body)
|
|
143
|
+
if not created and not ctx.obj["json"]:
|
|
144
|
+
click.echo("Nothing added — those showings are already on the calendar.")
|
|
145
|
+
return
|
|
146
|
+
render(created, ctx.obj["json"], title=f"Added {len(created)} showing(s)", columns=_SLOT_COLUMNS)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@slots.command("set")
|
|
150
|
+
@click.argument("offering_id")
|
|
151
|
+
@click.argument("slot_id")
|
|
152
|
+
@click.option("--date", "day", default=None, help="Move it to this date, YYYY-MM-DD.")
|
|
153
|
+
@click.option("--time", "start", default=None, help="Move it to this start time, HH:MM.")
|
|
154
|
+
@click.option("--duration", type=float, default=None, help="New length in hours.")
|
|
155
|
+
@click.option("--capacity", type=int, default=None, help="New seat count.")
|
|
156
|
+
@click.pass_context
|
|
157
|
+
def set_slot(ctx: click.Context, offering_id: str, slot_id: str, day: str | None,
|
|
158
|
+
start: str | None, duration: float | None, capacity: int | None):
|
|
159
|
+
"""Move a showing or change its capacity.
|
|
160
|
+
|
|
161
|
+
The API refuses a capacity below what has already sold — those seats belong to real
|
|
162
|
+
bookings.
|
|
163
|
+
"""
|
|
164
|
+
if not any([day, start, duration, capacity]):
|
|
165
|
+
raise click.UsageError("nothing to change — pass --date, --time, --duration or --capacity")
|
|
166
|
+
|
|
167
|
+
client = build_client(ctx)
|
|
168
|
+
body: dict = {}
|
|
169
|
+
if capacity is not None:
|
|
170
|
+
body["capacity"] = capacity
|
|
171
|
+
|
|
172
|
+
if day or start or duration is not None:
|
|
173
|
+
current = next((s for s in client.get(f"/admin/offerings/{offering_id}/slots")
|
|
174
|
+
if s["id"] == slot_id), None)
|
|
175
|
+
if current is None:
|
|
176
|
+
raise click.ClickException(f"Showing {slot_id} not found on {offering_id}")
|
|
177
|
+
begins = datetime.fromisoformat(current["start_datetime"])
|
|
178
|
+
ends = datetime.fromisoformat(current["end_datetime"])
|
|
179
|
+
length = timedelta(hours=duration) if duration is not None else ends - begins
|
|
180
|
+
if day:
|
|
181
|
+
begins = begins.replace(**_ymd(_parse_day(day)))
|
|
182
|
+
if start:
|
|
183
|
+
try:
|
|
184
|
+
clock = datetime.strptime(start, "%H:%M").time()
|
|
185
|
+
except ValueError:
|
|
186
|
+
raise click.BadParameter(f"{start!r} is not a time — use HH:MM, e.g. 19:00")
|
|
187
|
+
begins = begins.replace(hour=clock.hour, minute=clock.minute)
|
|
188
|
+
body["start_datetime"] = begins.isoformat()
|
|
189
|
+
body["end_datetime"] = (begins + length).isoformat()
|
|
190
|
+
|
|
191
|
+
data = client.patch(f"/admin/offerings/{offering_id}/slots/{slot_id}", json=body)
|
|
192
|
+
render(data, ctx.obj["json"], columns=_SLOT_COLUMNS)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _ymd(day: date) -> dict:
|
|
196
|
+
return {"year": day.year, "month": day.month, "day": day.day}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@slots.command("rm")
|
|
200
|
+
@click.argument("offering_id")
|
|
201
|
+
@click.argument("slot_id")
|
|
202
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
203
|
+
@click.pass_context
|
|
204
|
+
def remove_slot(ctx: click.Context, offering_id: str, slot_id: str, yes: bool):
|
|
205
|
+
"""Remove a showing. Refused by the API once seats have sold against it."""
|
|
206
|
+
if not yes:
|
|
207
|
+
click.confirm(f"Remove showing {slot_id} from {offering_id}?", abort=True)
|
|
208
|
+
client = build_client(ctx)
|
|
209
|
+
client.delete(f"/admin/offerings/{offering_id}/slots/{slot_id}")
|
|
210
|
+
if not ctx.obj["json"]:
|
|
211
|
+
click.echo(f"Removed {slot_id}.")
|
|
@@ -8,7 +8,7 @@ _RULE_OPTIONS = [
|
|
|
8
8
|
click.option("--max-group", type=int, required=True, help="Maximum group size this rule applies to."),
|
|
9
9
|
click.option("--min-price", type=float, required=True, help="Price floor per person."),
|
|
10
10
|
click.option("--max-price", type=float, required=True, help="Price ceiling per person."),
|
|
11
|
-
click.option("--
|
|
11
|
+
click.option("--offering-id", default=None, help="Scope this rule to one offering instead of the whole venue."),
|
|
12
12
|
click.option("--source", default="app", show_default=True, help="Origin tag, e.g. a 3P vendor name."),
|
|
13
13
|
click.option("--external-id", default=None, help="3P upsert key -- ignored while --source is 'app'."),
|
|
14
14
|
]
|
|
@@ -20,19 +20,19 @@ def _add_rule_options(f):
|
|
|
20
20
|
return f
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
def _rule_body(min_group, max_group, min_price, max_price,
|
|
23
|
+
def _rule_body(min_group, max_group, min_price, max_price, offering_id, source, external_id) -> dict:
|
|
24
24
|
return {
|
|
25
25
|
"min_group": min_group,
|
|
26
26
|
"max_group": max_group,
|
|
27
27
|
"min_price": min_price,
|
|
28
28
|
"max_price": max_price,
|
|
29
|
-
"
|
|
29
|
+
"offering_id": offering_id,
|
|
30
30
|
"source": source,
|
|
31
31
|
"external_id": external_id,
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
_RULE_COLUMNS = ["id", "min_group", "max_group", "min_price", "max_price", "
|
|
35
|
+
_RULE_COLUMNS = ["id", "min_group", "max_group", "min_price", "max_price", "offering_id", "source", "external_id"]
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
@click.group()
|
|
@@ -66,10 +66,10 @@ def get_pricing(ctx: click.Context, venue_id: str, rule_id: str):
|
|
|
66
66
|
@_add_rule_options
|
|
67
67
|
@click.pass_context
|
|
68
68
|
def set_pricing(ctx: click.Context, venue_id: str, min_group, max_group, min_price, max_price,
|
|
69
|
-
|
|
69
|
+
offering_id, source, external_id):
|
|
70
70
|
"""Create a new pricing rule."""
|
|
71
71
|
client = build_client(ctx)
|
|
72
|
-
body = _rule_body(min_group, max_group, min_price, max_price,
|
|
72
|
+
body = _rule_body(min_group, max_group, min_price, max_price, offering_id, source, external_id)
|
|
73
73
|
data = client.post(f"/admin/venues/{venue_id}/pricing", json=body)
|
|
74
74
|
render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
|
|
75
75
|
|
|
@@ -80,10 +80,10 @@ def set_pricing(ctx: click.Context, venue_id: str, min_group, max_group, min_pri
|
|
|
80
80
|
@_add_rule_options
|
|
81
81
|
@click.pass_context
|
|
82
82
|
def update_pricing(ctx: click.Context, venue_id: str, rule_id: str, min_group, max_group, min_price, max_price,
|
|
83
|
-
|
|
83
|
+
offering_id, source, external_id):
|
|
84
84
|
"""Replace an existing pricing rule."""
|
|
85
85
|
client = build_client(ctx)
|
|
86
|
-
body = _rule_body(min_group, max_group, min_price, max_price,
|
|
86
|
+
body = _rule_body(min_group, max_group, min_price, max_price, offering_id, source, external_id)
|
|
87
87
|
data = client.put(f"/admin/venues/{venue_id}/pricing/{rule_id}", json=body)
|
|
88
88
|
render(data, ctx.obj["json"], columns=_RULE_COLUMNS)
|
|
89
89
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import click
|
|
2
|
+
|
|
3
|
+
from groupsales_cli.client import build_client
|
|
4
|
+
from groupsales_cli.output import render
|
|
5
|
+
|
|
6
|
+
RECORD_TYPES = (
|
|
7
|
+
"conversation", "offer", "quote", "order", "offering",
|
|
8
|
+
"rule", "pricing_tier", "payment_policy", "contact",
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument("query", nargs=-1, required=True)
|
|
14
|
+
@click.option("--type", "types", multiple=True, type=click.Choice(RECORD_TYPES),
|
|
15
|
+
help="Only this kind of record. Repeat for several.")
|
|
16
|
+
@click.option("--limit", type=click.IntRange(1, 50), default=20, show_default=True)
|
|
17
|
+
@click.pass_context
|
|
18
|
+
def search(ctx: click.Context, query: tuple[str, ...], types: tuple[str, ...], limit: int):
|
|
19
|
+
"""Search every record at your venue: conversations, offers, quotes, orders,
|
|
20
|
+
offerings, policies, pricing tiers, payment terms and contacts.
|
|
21
|
+
|
|
22
|
+
Matches exact words (names, emails, ids) and meaning, so
|
|
23
|
+
`bookai search where do coaches park` finds a guest asking about bus parking.
|
|
24
|
+
"""
|
|
25
|
+
client = build_client(ctx)
|
|
26
|
+
params = {"q": " ".join(query), "limit": limit}
|
|
27
|
+
if types:
|
|
28
|
+
params["types"] = ",".join(types)
|
|
29
|
+
data = client.get("/admin/search", params=params)
|
|
30
|
+
if ctx.obj["json"]:
|
|
31
|
+
render(data, True)
|
|
32
|
+
return
|
|
33
|
+
# The back office UI is served from the same origin as the API, so a result's
|
|
34
|
+
# deep link opens straight in the browser. One block per result rather than a
|
|
35
|
+
# table: snippets and links don't survive being folded into narrow columns.
|
|
36
|
+
base_url = ctx.obj["base_url"].rstrip("/")
|
|
37
|
+
results = data["results"]
|
|
38
|
+
if not results:
|
|
39
|
+
click.echo(f"No results for “{data['query']}”.")
|
|
40
|
+
return
|
|
41
|
+
for r in results:
|
|
42
|
+
kind = r["record_type"].replace("_", " ")
|
|
43
|
+
click.echo(click.style(f"{kind:<14}", fg="cyan") + click.style(r["title"], bold=True))
|
|
44
|
+
if r.get("subtitle"):
|
|
45
|
+
click.echo(f"{'':<14}{r['subtitle']}")
|
|
46
|
+
if r.get("snippet"):
|
|
47
|
+
click.echo(f"{'':<14}“{r['snippet']}”")
|
|
48
|
+
click.echo(f"{'':<14}{base_url}{r['deep_link']}")
|
|
49
|
+
click.echo()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from groupsales_cli.__main__ import cli
|
|
4
|
+
|
|
5
|
+
BASE_URL = "https://test.example"
|
|
6
|
+
_BASE_ARGS = ["--api-key", "k", "--base-url", BASE_URL]
|
|
7
|
+
|
|
8
|
+
_OFFERING = {
|
|
9
|
+
"id": "gala-ocean", "name": "Private Gala", "category": "gala", "admission_type": "timed",
|
|
10
|
+
"status": "active", "valid_from": "2026-09-01", "valid_to": "2026-12-31",
|
|
11
|
+
"base_price_per_person": 80.0, "venue_id": "amnh", "description": "",
|
|
12
|
+
}
|
|
13
|
+
_SLOT = {
|
|
14
|
+
"id": "s1", "offering_id": "gala-ocean", "capacity": 400, "sold": 0, "available": 400,
|
|
15
|
+
"start_datetime": "2026-11-14T19:00:00+00:00", "end_datetime": "2026-11-14T23:00:00+00:00",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_offerings_list_unwraps_the_offering_from_each_detail(runner, respx_mock):
|
|
20
|
+
"""The endpoint returns {offering, slots, pricing_tiers} per row; the table shows the
|
|
21
|
+
offering. Asserted on --json because the rendered table truncates long ids."""
|
|
22
|
+
respx_mock.get(f"{BASE_URL}/admin/offerings").respond(json=[{"offering": _OFFERING, "slots": []}])
|
|
23
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "--json", "offerings", "list"])
|
|
24
|
+
assert result.exit_code == 0, result.output
|
|
25
|
+
assert json.loads(result.output) == [_OFFERING]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_offerings_show_includes_its_showings(runner, respx_mock):
|
|
29
|
+
respx_mock.get(f"{BASE_URL}/admin/offerings/gala-ocean").respond(
|
|
30
|
+
json={"offering": _OFFERING, "slots": [_SLOT], "pricing_tiers": []})
|
|
31
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "show", "gala-ocean"])
|
|
32
|
+
assert result.exit_code == 0, result.output
|
|
33
|
+
assert "Showings" in result.output and "s1" in result.output
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── extend ─────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def test_extend_to_an_exact_date_patches_valid_to(runner, respx_mock):
|
|
39
|
+
"""Extending a run is valid_to changing — so it PATCHes the offering, no special
|
|
40
|
+
endpoint."""
|
|
41
|
+
route = respx_mock.patch(f"{BASE_URL}/admin/offerings/gala-ocean").respond(
|
|
42
|
+
json={"offering": {**_OFFERING, "valid_to": "2027-03-31"}, "slots": []})
|
|
43
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "extend", "gala-ocean",
|
|
44
|
+
"--to", "2027-03-31"])
|
|
45
|
+
assert result.exit_code == 0, result.output
|
|
46
|
+
assert route.called
|
|
47
|
+
assert json.loads(route.calls.last.request.content) == {"valid_to": "2027-03-31"}
|
|
48
|
+
assert "runs until 2027-03-31" in result.output
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_extend_by_a_period_reads_the_current_end_date_first(runner, respx_mock):
|
|
52
|
+
respx_mock.get(f"{BASE_URL}/admin/offerings/gala-ocean").respond(
|
|
53
|
+
json={"offering": _OFFERING, "slots": []})
|
|
54
|
+
route = respx_mock.patch(f"{BASE_URL}/admin/offerings/gala-ocean").respond(
|
|
55
|
+
json={"offering": {**_OFFERING, "valid_to": "2027-03-31"}, "slots": []})
|
|
56
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "extend", "gala-ocean", "--by", "3m"])
|
|
57
|
+
assert result.exit_code == 0, result.output
|
|
58
|
+
# 2026-12-31 + 90 days
|
|
59
|
+
assert json.loads(route.calls.last.request.content) == {"valid_to": "2027-03-31"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_extend_needs_exactly_one_of_to_or_by(runner, respx_mock):
|
|
63
|
+
neither = runner.invoke(cli, [*_BASE_ARGS, "offerings", "extend", "gala-ocean"])
|
|
64
|
+
both = runner.invoke(cli, [*_BASE_ARGS, "offerings", "extend", "gala-ocean",
|
|
65
|
+
"--to", "2027-01-01", "--by", "3m"])
|
|
66
|
+
assert neither.exit_code != 0 and both.exit_code != 0
|
|
67
|
+
assert "exactly one of --to or --by" in neither.output
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_extend_rejects_a_nonsense_period(runner, respx_mock):
|
|
71
|
+
respx_mock.get(f"{BASE_URL}/admin/offerings/gala-ocean").respond(
|
|
72
|
+
json={"offering": _OFFERING, "slots": []})
|
|
73
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "extend", "gala-ocean", "--by", "soon"])
|
|
74
|
+
assert result.exit_code != 0
|
|
75
|
+
assert "30d, 6w or 3m" in result.output
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_narrowing_a_run_surfaces_the_api_refusal(runner, respx_mock):
|
|
79
|
+
"""The API refuses to strand existing showings; the CLI must relay that, not swallow it."""
|
|
80
|
+
respx_mock.patch(f"{BASE_URL}/admin/offerings/gala-ocean").respond(
|
|
81
|
+
status_code=409, json={"detail": "2 existing showing(s) would fall outside the run"})
|
|
82
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "extend", "gala-ocean",
|
|
83
|
+
"--to", "2026-09-05"])
|
|
84
|
+
assert result.exit_code != 0
|
|
85
|
+
assert "would fall outside the run" in result.output
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ── slots ──────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def test_slots_list(runner, respx_mock):
|
|
91
|
+
respx_mock.get(f"{BASE_URL}/admin/offerings/gala-ocean/slots").respond(json=[_SLOT])
|
|
92
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "list", "gala-ocean"])
|
|
93
|
+
assert result.exit_code == 0, result.output
|
|
94
|
+
assert "s1" in result.output
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_slots_add_builds_the_window_from_date_time_and_duration(runner, respx_mock):
|
|
98
|
+
route = respx_mock.post(f"{BASE_URL}/admin/offerings/gala-ocean/slots").respond(
|
|
99
|
+
status_code=201, json=[_SLOT])
|
|
100
|
+
result = runner.invoke(cli, [
|
|
101
|
+
*_BASE_ARGS, "offerings", "slots", "add", "gala-ocean",
|
|
102
|
+
"--date", "2026-11-14", "--time", "19:00", "--duration", "4", "--capacity", "400",
|
|
103
|
+
])
|
|
104
|
+
assert result.exit_code == 0, result.output
|
|
105
|
+
body = json.loads(route.calls.last.request.content)
|
|
106
|
+
assert body["slots"] == [{
|
|
107
|
+
"start_datetime": "2026-11-14T19:00:00+00:00",
|
|
108
|
+
"end_datetime": "2026-11-14T23:00:00+00:00",
|
|
109
|
+
"capacity": 400,
|
|
110
|
+
}]
|
|
111
|
+
assert body["repeat_weekly"] == 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_slots_add_passes_the_repeat_through(runner, respx_mock):
|
|
115
|
+
route = respx_mock.post(f"{BASE_URL}/admin/offerings/gala-ocean/slots").respond(
|
|
116
|
+
status_code=201, json=[_SLOT] * 4)
|
|
117
|
+
result = runner.invoke(cli, [
|
|
118
|
+
*_BASE_ARGS, "offerings", "slots", "add", "gala-ocean",
|
|
119
|
+
"--date", "2026-11-14", "--time", "19:00", "--capacity", "400", "--repeat-weekly", "3",
|
|
120
|
+
])
|
|
121
|
+
assert result.exit_code == 0, result.output
|
|
122
|
+
assert json.loads(route.calls.last.request.content)["repeat_weekly"] == 3
|
|
123
|
+
assert "Added 4 showing(s)" in result.output
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_slots_add_says_so_when_everything_already_existed(runner, respx_mock):
|
|
127
|
+
respx_mock.post(f"{BASE_URL}/admin/offerings/gala-ocean/slots").respond(status_code=201, json=[])
|
|
128
|
+
result = runner.invoke(cli, [
|
|
129
|
+
*_BASE_ARGS, "offerings", "slots", "add", "gala-ocean",
|
|
130
|
+
"--date", "2026-11-14", "--time", "19:00", "--capacity", "400",
|
|
131
|
+
])
|
|
132
|
+
assert result.exit_code == 0, result.output
|
|
133
|
+
assert "already on the calendar" in result.output
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_slots_add_rejects_a_bad_time(runner, respx_mock):
|
|
137
|
+
result = runner.invoke(cli, [
|
|
138
|
+
*_BASE_ARGS, "offerings", "slots", "add", "gala-ocean",
|
|
139
|
+
"--date", "2026-11-14", "--time", "7pm", "--capacity", "400",
|
|
140
|
+
])
|
|
141
|
+
assert result.exit_code != 0
|
|
142
|
+
assert "use HH:MM" in result.output
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def test_slots_set_capacity_only_does_not_need_the_current_showing(runner, respx_mock):
|
|
146
|
+
route = respx_mock.patch(f"{BASE_URL}/admin/offerings/gala-ocean/slots/s1").respond(
|
|
147
|
+
json={**_SLOT, "capacity": 120, "available": 120})
|
|
148
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "set", "gala-ocean", "s1",
|
|
149
|
+
"--capacity", "120"])
|
|
150
|
+
assert result.exit_code == 0, result.output
|
|
151
|
+
assert json.loads(route.calls.last.request.content) == {"capacity": 120}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_slots_set_time_keeps_the_existing_duration(runner, respx_mock):
|
|
155
|
+
"""Moving a 4-hour showing to a new time must keep it 4 hours long."""
|
|
156
|
+
respx_mock.get(f"{BASE_URL}/admin/offerings/gala-ocean/slots").respond(json=[_SLOT])
|
|
157
|
+
route = respx_mock.patch(f"{BASE_URL}/admin/offerings/gala-ocean/slots/s1").respond(json=_SLOT)
|
|
158
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "set", "gala-ocean", "s1",
|
|
159
|
+
"--time", "14:00"])
|
|
160
|
+
assert result.exit_code == 0, result.output
|
|
161
|
+
body = json.loads(route.calls.last.request.content)
|
|
162
|
+
assert body["start_datetime"].endswith("14:00:00+00:00")
|
|
163
|
+
assert body["end_datetime"].endswith("18:00:00+00:00")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_slots_set_needs_something_to_change(runner, respx_mock):
|
|
167
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "set", "gala-ocean", "s1"])
|
|
168
|
+
assert result.exit_code != 0
|
|
169
|
+
assert "nothing to change" in result.output
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_slots_rm_asks_first(runner, respx_mock):
|
|
173
|
+
route = respx_mock.delete(f"{BASE_URL}/admin/offerings/gala-ocean/slots/s1").respond(status_code=204)
|
|
174
|
+
declined = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "rm", "gala-ocean", "s1"],
|
|
175
|
+
input="n\n")
|
|
176
|
+
assert declined.exit_code != 0 and not route.called
|
|
177
|
+
|
|
178
|
+
accepted = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "rm", "gala-ocean", "s1", "--yes"])
|
|
179
|
+
assert accepted.exit_code == 0, accepted.output
|
|
180
|
+
assert route.called
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def test_slots_rm_relays_the_refusal_when_seats_are_sold(runner, respx_mock):
|
|
184
|
+
respx_mock.delete(f"{BASE_URL}/admin/offerings/gala-ocean/slots/s1").respond(
|
|
185
|
+
status_code=409, json={"detail": "20 seat(s) already sold on this showing"})
|
|
186
|
+
result = runner.invoke(cli, [*_BASE_ARGS, "offerings", "slots", "rm", "gala-ocean", "s1", "--yes"])
|
|
187
|
+
assert result.exit_code != 0
|
|
188
|
+
assert "already sold" in result.output
|
|
@@ -5,7 +5,7 @@ _BASE_ARGS = ["--api-key", "k", "--base-url", BASE_URL]
|
|
|
5
5
|
|
|
6
6
|
_RULE = {
|
|
7
7
|
"id": "r1", "venue_id": "amnh", "min_group": 5, "max_group": 20,
|
|
8
|
-
"min_price": 10.0, "max_price": 20.0, "
|
|
8
|
+
"min_price": 10.0, "max_price": 20.0, "offering_id": None, "source": "app", "external_id": None,
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from groupsales_cli.__main__ import cli
|
|
2
|
+
|
|
3
|
+
BASE_URL = "https://test.example"
|
|
4
|
+
|
|
5
|
+
RESULTS = {
|
|
6
|
+
"version": 1, "query": "coaches park", "types": [],
|
|
7
|
+
"results": [{
|
|
8
|
+
"record_type": "conversation", "record_id": "s1", "title": "brave-otter-12",
|
|
9
|
+
"subtitle": "Dana Lee · 12 messages", "snippet": "Where can the coaches park?",
|
|
10
|
+
"deep_link": "/conversations/s1", "facets": {}, "updated_at": None, "score": 0.03,
|
|
11
|
+
}],
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_search_joins_words_and_prints_a_browser_link(runner, respx_mock):
|
|
16
|
+
route = respx_mock.get(f"{BASE_URL}/admin/search", params={"q": "coaches park", "limit": "20"}).respond(json=RESULTS)
|
|
17
|
+
result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "search", "coaches", "park"])
|
|
18
|
+
assert result.exit_code == 0, result.output
|
|
19
|
+
assert route.called
|
|
20
|
+
assert "brave-otter-12" in result.output
|
|
21
|
+
assert "Where can the coaches park?" in result.output
|
|
22
|
+
assert "https://test.example/conversations/s1" in result.output
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_search_passes_types_and_limit(runner, respx_mock):
|
|
26
|
+
route = respx_mock.get(
|
|
27
|
+
f"{BASE_URL}/admin/search", params={"q": "acme", "limit": "5", "types": "offer,quote"}
|
|
28
|
+
).respond(json={**RESULTS, "query": "acme", "results": []})
|
|
29
|
+
result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "search", "acme",
|
|
30
|
+
"--type", "offer", "--type", "quote", "--limit", "5"])
|
|
31
|
+
assert result.exit_code == 0, result.output
|
|
32
|
+
assert route.called
|
|
33
|
+
assert "No results for “acme”." in result.output
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_search_json_returns_the_api_response_unchanged(runner, respx_mock):
|
|
37
|
+
respx_mock.get(f"{BASE_URL}/admin/search").respond(json=RESULTS)
|
|
38
|
+
result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "--json", "search", "coaches"])
|
|
39
|
+
assert result.exit_code == 0
|
|
40
|
+
assert '"deep_link": "/conversations/s1"' in result.output
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_search_rejects_an_unknown_type(runner):
|
|
44
|
+
result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "search", "x", "--type", "invoices"])
|
|
45
|
+
assert result.exit_code != 0
|
|
46
|
+
assert "invoices" in result.output
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_search_surfaces_the_api_error(runner, respx_mock):
|
|
50
|
+
respx_mock.get(f"{BASE_URL}/admin/search").respond(
|
|
51
|
+
status_code=503, json={"detail": "Search is still being set up. Try again in a minute."}
|
|
52
|
+
)
|
|
53
|
+
result = runner.invoke(cli, ["--api-key", "k", "--base-url", BASE_URL, "search", "coaches"])
|
|
54
|
+
assert result.exit_code != 0
|
|
55
|
+
assert "still being set up" in result.output
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|