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.
Files changed (28) hide show
  1. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/.gitignore +4 -0
  2. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/PKG-INFO +44 -3
  3. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/README.md +43 -2
  4. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/pyproject.toml +7 -1
  5. bookai_cli-0.5.0/src/groupsales_cli/__init__.py +1 -0
  6. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/__main__.py +4 -0
  7. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/client.py +3 -0
  8. bookai_cli-0.5.0/src/groupsales_cli/commands/offerings.py +211 -0
  9. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/pricing.py +8 -8
  10. bookai_cli-0.5.0/src/groupsales_cli/commands/search.py +49 -0
  11. bookai_cli-0.5.0/tests/test_offerings.py +188 -0
  12. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_pricing.py +1 -1
  13. bookai_cli-0.5.0/tests/test_search.py +55 -0
  14. bookai_cli-0.2.0/src/groupsales_cli/__init__.py +0 -1
  15. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/__init__.py +0 -0
  16. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/auth.py +0 -0
  17. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/contacts.py +0 -0
  18. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/orders.py +0 -0
  19. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/commands/venues.py +0 -0
  20. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/config.py +0 -0
  21. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/credentials.py +0 -0
  22. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/output.py +0 -0
  23. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/src/groupsales_cli/shell.py +0 -0
  24. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/conftest.py +0 -0
  25. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_auth.py +0 -0
  26. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_contacts.py +0 -0
  27. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_orders.py +0 -0
  28. {bookai_cli-0.2.0 → bookai_cli-0.5.0}/tests/test_venues.py +0 -0
@@ -9,6 +9,10 @@ __pycache__/
9
9
  *.egg-info/
10
10
  /cli/dist/
11
11
  /cli/build/
12
+ /dist/
12
13
  data/
14
+ # committed eval fixtures, not local dev data
15
+ !tests/data/
13
16
  .coverage
14
17
  /coverage.json
18
+ /.eval_results/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bookai-cli
3
- Version: 0.2.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 terminal or a script, instead of
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 terminal or a script, instead of
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
- version = "0.2.0"
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("--event-id", default=None, help="Scope this rule to one event instead of the whole venue."),
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, event_id, source, external_id) -> dict:
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
- "event_id": event_id,
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", "event_id", "source", "external_id"]
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
- event_id, source, external_id):
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, event_id, source, external_id)
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
- event_id, source, external_id):
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, event_id, source, external_id)
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, "event_id": None, "source": "app", "external_id": None,
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