AP blueprints (activitypub.py, ap_social.py) were querying federation tables (ap_actor_profiles etc.) on g.s which points to the app's own DB after the per-app split. Now uses g._ap_s backed by get_federation_session() for non-federation apps. Also hardens Ghost sync before_app_serving to catch/rollback on failure instead of crashing the Hypercorn worker. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
150 lines
6.9 KiB
Markdown
150 lines
6.9 KiB
Markdown
# Ticket UX Improvements: +/- Buttons, Sold Count, Cart Grouping
|
||
|
||
## Context
|
||
The entry page currently uses a numeric input + "Buy Tickets" button, which replaces itself with a confirmation after purchase. The cart lists each ticket individually. The user wants the ticket UX to match the product pattern: +/- buttons, "in basket" count, tickets grouped by event on cart.
|
||
|
||
## Requirements
|
||
1. **Entry page**: Show tickets sold count + current user's "in basket" count
|
||
2. **Entry page**: Replace qty input with "Add to basket" / +/- buttons (product pattern)
|
||
3. **Entry page**: Keep form active after adding (don't replace with confirmation)
|
||
4. **Cart page**: Group tickets by event (entry_id + ticket_type), show quantity with +/- buttons
|
||
|
||
---
|
||
|
||
## 1. Add `ticket_type_id` to TicketDTO
|
||
|
||
**File**: `shared/contracts/dtos.py`
|
||
- Add `ticket_type_id: int | None = None` field to `TicketDTO`
|
||
|
||
**File**: `shared/services/calendar_impl.py`
|
||
- In `_ticket_to_dto()`, populate `ticket_type_id=ticket.ticket_type_id`
|
||
|
||
**Sync**: Copy to all 4 app submodule copies.
|
||
|
||
## 2. New ticket service functions
|
||
|
||
**File**: `events/bp/tickets/services/tickets.py`
|
||
- Add `get_user_reserved_count(session, entry_id, user_id, session_id, ticket_type_id=None) -> int`
|
||
- Counts reserved tickets for this user+entry+type
|
||
- Add `get_sold_ticket_count(session, entry_id) -> int`
|
||
- Counts all non-cancelled tickets for this entry
|
||
- Add `cancel_latest_reserved_ticket(session, entry_id, user_id, session_id, ticket_type_id=None) -> bool`
|
||
- Finds the most recently created reserved ticket for this user+entry+type, sets state='cancelled'. Returns True if one was cancelled.
|
||
|
||
## 3. Add `adjust_quantity` route to events tickets blueprint
|
||
|
||
**File**: `events/bp/tickets/routes.py`
|
||
- New route: `POST /tickets/adjust/`
|
||
- Form fields: `entry_id`, `ticket_type_id` (optional), `count` (target quantity)
|
||
- Logic:
|
||
- Get current user reserved count for this entry/type
|
||
- If count > current: create `(count - current)` tickets via `create_ticket()`
|
||
- If count < current: cancel `(current - count)` tickets via `cancel_latest_reserved_ticket()` in a loop
|
||
- If count == 0: cancel all
|
||
- Check availability before adding (like existing `buy_tickets`)
|
||
- Response: re-render `_buy_form.html` (HTMX swap replaces form, keeps it active)
|
||
- Include OOB cart-mini update: `{{ mini(oob='true') }}`
|
||
|
||
## 4. Inject ticket counts into entry page context
|
||
|
||
**File**: `events/bp/calendar_entry/routes.py` — `inject_root` context processor
|
||
- Add `ticket_sold_count`: total non-cancelled tickets for entry (via `get_sold_ticket_count`)
|
||
- Add `user_ticket_count`: current user's reserved count (via `get_user_reserved_count`)
|
||
- For multi-type entries, add `user_ticket_counts_by_type`: dict mapping ticket_type_id → count
|
||
|
||
## 5. Rewrite entry page buy form
|
||
|
||
**File**: `events/templates/_types/tickets/_buy_form.html`
|
||
- Show "X sold" (from `ticket_sold_count`) alongside "X remaining"
|
||
- Show "X in basket" for current user
|
||
|
||
**For single-price entries (no ticket types)**:
|
||
- If `user_ticket_count == 0`: show "Add to basket" button (posts to `/tickets/adjust/` with count=1)
|
||
- If `user_ticket_count > 0`: show `[-]` [count badge] `[+]` buttons
|
||
- Minus: posts count=user_ticket_count-1
|
||
- Plus: posts count=user_ticket_count+1
|
||
- All forms: `hx-post`, `hx-target="#ticket-buy-{{ entry.id }}"`, `hx-swap="outerHTML"`
|
||
|
||
**For multi-type entries**:
|
||
- Same pattern per ticket type row, using `user_ticket_counts_by_type[tt.id]`
|
||
|
||
Style: match product pattern exactly — emerald circular buttons, w-8 h-8, cart icon with badge.
|
||
|
||
## 6. Add ticket quantity route to cart app
|
||
|
||
**File**: `cart/bp/cart/global_routes.py`
|
||
- New route: `POST /cart/ticket-quantity/`
|
||
- Form fields: `entry_id`, `ticket_type_id` (optional), `count` (target quantity)
|
||
- Logic: call into CalendarService or directly use ticket functions
|
||
- Since cart app uses service contracts, add `adjust_ticket_quantity` to CalendarService protocol
|
||
|
||
**File**: `shared/contracts/protocols.py` — CalendarService
|
||
- Add: `adjust_ticket_quantity(session, entry_id, count, *, user_id, session_id, ticket_type_id=None) -> int`
|
||
|
||
**File**: `shared/services/calendar_impl.py`
|
||
- Implement `adjust_ticket_quantity`:
|
||
- Same logic as events adjust route (create/cancel to match target count)
|
||
- Return new count
|
||
|
||
**File**: `shared/services/stubs.py`
|
||
- Add stub: returns 0
|
||
|
||
Response: `HX-Refresh: true` (same as product quantity route).
|
||
|
||
## 7. Cart page: group tickets by event with +/- buttons
|
||
|
||
**File**: `cart/templates/_types/cart/_cart.html` — ticket section (lines 63-95)
|
||
- Replace individual ticket list with grouped display
|
||
- Group `ticket_cart_entries` by `(entry_id, ticket_type_id)`:
|
||
- Use Jinja `groupby` on `entry_id` first, then sub-group by `ticket_type_name`
|
||
- Or pre-group in the route handler and pass as a dict
|
||
|
||
**Approach**: Pre-group in the route handler for cleaner templates.
|
||
|
||
**File**: `cart/bp/cart/page_routes.py` — `page_view`
|
||
- After getting `page_tickets`, group them into a list of dicts:
|
||
```
|
||
[{"entry_name": ..., "entry_id": ..., "ticket_type_name": ..., "ticket_type_id": ...,
|
||
"entry_start_at": ..., "entry_end_at": ..., "price": ..., "quantity": N}]
|
||
```
|
||
- Pass as `ticket_groups` to template
|
||
|
||
**File**: `cart/bp/cart/global_routes.py` — overview/checkout routes
|
||
- Same grouping for global cart view if tickets appear there
|
||
|
||
**Cart ticket group template**: Each group shows:
|
||
- Event name + ticket type (if any)
|
||
- Date/time
|
||
- Price per ticket
|
||
- `-` [qty] `+` buttons (posting to `/cart/ticket-quantity/`)
|
||
- Line total (price × qty)
|
||
|
||
Match product `cart_item` macro style (article card with quantity controls).
|
||
|
||
## 8. Cart summary update
|
||
|
||
**File**: `cart/templates/_types/cart/_cart.html` — `summary` macro
|
||
- Update Items count: include ticket quantities in total (currently just product quantities)
|
||
|
||
## Files to modify (summary)
|
||
- `shared/contracts/dtos.py` — add ticket_type_id to TicketDTO
|
||
- `shared/contracts/protocols.py` — add adjust_ticket_quantity to CalendarService
|
||
- `shared/services/calendar_impl.py` — implement adjust_ticket_quantity, update _ticket_to_dto
|
||
- `shared/services/stubs.py` — add stub
|
||
- `events/bp/tickets/services/tickets.py` — add count/cancel functions
|
||
- `events/bp/tickets/routes.py` — add adjust route
|
||
- `events/bp/calendar_entry/routes.py` — inject sold/user counts
|
||
- `events/templates/_types/tickets/_buy_form.html` — rewrite with +/- pattern
|
||
- `cart/bp/cart/global_routes.py` — add ticket-quantity route
|
||
- `cart/bp/cart/page_routes.py` — group tickets
|
||
- `cart/templates/_types/cart/_cart.html` — grouped ticket display with +/-
|
||
- All 4 app `shared/` submodule copies synced
|
||
|
||
## Verification
|
||
1. Visit entry page → see "X sold", "X in basket", "Add to basket" button
|
||
2. Click "Add to basket" → form stays, shows `-` [1] `+`, basket count shows "1 in basket"
|
||
3. Click `+` → count increases, sold count increases
|
||
4. Click `-` → count decreases, ticket cancelled
|
||
5. Visit cart page → tickets grouped by event, +/- buttons work
|
||
6. Checkout flow still works (existing tests)
|