bookai-cli 0.2.0__tar.gz → 0.4.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 (26) hide show
  1. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/.gitignore +2 -0
  2. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/PKG-INFO +39 -3
  3. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/README.md +38 -2
  4. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/pyproject.toml +7 -1
  5. bookai_cli-0.4.0/src/groupsales_cli/__init__.py +1 -0
  6. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/__main__.py +2 -0
  7. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/client.py +3 -0
  8. bookai_cli-0.4.0/src/groupsales_cli/commands/offerings.py +211 -0
  9. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/commands/pricing.py +8 -8
  10. bookai_cli-0.4.0/tests/test_offerings.py +188 -0
  11. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/tests/test_pricing.py +1 -1
  12. bookai_cli-0.2.0/src/groupsales_cli/__init__.py +0 -1
  13. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/commands/__init__.py +0 -0
  14. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/commands/auth.py +0 -0
  15. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/commands/contacts.py +0 -0
  16. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/commands/orders.py +0 -0
  17. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/commands/venues.py +0 -0
  18. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/config.py +0 -0
  19. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/credentials.py +0 -0
  20. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/output.py +0 -0
  21. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/src/groupsales_cli/shell.py +0 -0
  22. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/tests/conftest.py +0 -0
  23. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/tests/test_auth.py +0 -0
  24. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/tests/test_contacts.py +0 -0
  25. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/tests/test_orders.py +0 -0
  26. {bookai_cli-0.2.0 → bookai_cli-0.4.0}/tests/test_venues.py +0 -0
@@ -9,6 +9,8 @@ __pycache__/
9
9
  *.egg-info/
10
10
  /cli/dist/
11
11
  /cli/build/
12
+ /dist/
12
13
  data/
13
14
  .coverage
14
15
  /coverage.json
16
+ /.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.4.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,46 @@ 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
136
149
  ```
137
150
 
151
+ ### Showings
152
+
153
+ `offerings slots` manages the individual showings of a timed offering — the
154
+ dates and times people actually book. `--repeat-weekly` is there because a
155
+ season is usually a repeating shape rather than a list of one-off dates:
156
+ `--repeat-weekly 3` creates the showing plus three weekly copies, four in
157
+ total.
158
+
159
+ The API keeps you from doing the things that would quietly break bookings, and
160
+ the CLI reports its reason rather than swallowing it:
161
+
162
+ - a showing with seats already sold can't be removed, and its capacity can't
163
+ drop below what's sold
164
+ - a showing outside the offering's run is refused, rather than sitting on the
165
+ calendar looking bookable — extend the run first
166
+ - narrowing a run past showings that already exist is refused for the same
167
+ reason: they'd stay on the calendar while quietly dropping out of
168
+ availability
169
+
170
+ `offerings extend` is exactly that "extend the run first" step. It's a plain
171
+ update of the offering's end date — `--to` for an exact date, `--by 3m` (also
172
+ `6w`, `30d`) to push it out from wherever it currently ends.
173
+
138
174
  `bookai venues create` is the one command that works with no API key at all —
139
175
  it's how a brand-new venue's very first account gets created in the first
140
176
  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,46 @@ 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
122
135
  ```
123
136
 
137
+ ### Showings
138
+
139
+ `offerings slots` manages the individual showings of a timed offering — the
140
+ dates and times people actually book. `--repeat-weekly` is there because a
141
+ season is usually a repeating shape rather than a list of one-off dates:
142
+ `--repeat-weekly 3` creates the showing plus three weekly copies, four in
143
+ total.
144
+
145
+ The API keeps you from doing the things that would quietly break bookings, and
146
+ the CLI reports its reason rather than swallowing it:
147
+
148
+ - a showing with seats already sold can't be removed, and its capacity can't
149
+ drop below what's sold
150
+ - a showing outside the offering's run is refused, rather than sitting on the
151
+ calendar looking bookable — extend the run first
152
+ - narrowing a run past showings that already exist is refused for the same
153
+ reason: they'd stay on the calendar while quietly dropping out of
154
+ availability
155
+
156
+ `offerings extend` is exactly that "extend the run first" step. It's a plain
157
+ update of the offering's end date — `--to` for an exact date, `--by 3m` (also
158
+ `6w`, `30d`) to push it out from wherever it currently ends.
159
+
124
160
  `bookai venues create` is the one command that works with no API key at all —
125
161
  it's how a brand-new venue's very first account gets created in the first
126
162
  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.4.0"
@@ -7,6 +7,7 @@ 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
11
12
  from groupsales_cli.commands.venues import venues
12
13
 
@@ -62,6 +63,7 @@ cli.add_command(logout)
62
63
  cli.add_command(whoami)
63
64
  cli.add_command(venues)
64
65
  cli.add_command(contacts)
66
+ cli.add_command(offerings)
65
67
  cli.add_command(pricing)
66
68
  cli.add_command(orders)
67
69
 
@@ -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,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
 
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
File without changes