Compare commits

142 Commits

Author SHA1 Message Date
giles
ec65b9fea8 Fix circular fragment fetching (shared submodule update)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:21:04 +00:00
giles
7169378e41 Sync shared: fragment failures now raise by default
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 18:04:36 +00:00
giles
1b7cc5849b trigger rebuild
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
2026-02-24 18:01:59 +00:00
giles
0f8dd636af Remove cross-domain template copies, use shared macros
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
- Cart mini: use macros/cart_icon.html for ticket OOB updates
- Post header: use blog_url() instead of url_for('blog.post.post_detail')

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:33:14 +00:00
giles
bc4d332157 Sync shared submodule (bound DB connection pool)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m11s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 17:08:19 +00:00
giles
77c5fc716a Own events domain templates (Phase 6)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m6s
Calendar, day, entry, slot, ticket templates + date macro moved
from shared to events/templates/. Cross-domain copies: post header,
post admin entries, cart mini.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 16:55:50 +00:00
giles
2a723af201 Add account-nav-item and account-page fragment handlers (Phase 5)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Events now serves tickets/bookings nav links and page panels as
fragments for the account app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:58:08 +00:00
giles
503f7ca7d8 Phase 4: add container-nav/cards fragment handlers, use market fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Events provides container-nav (calendar entries + links) and
container-cards (batch entries for blog listing) as fragments.
Day and entry routes fetch market container-nav via fragment instead
of widget registry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 13:33:25 +00:00
giles
de80c393e4 Restore menu_items fallback for nav, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Keep get_navigation_tree() as fallback when nav-tree fragment fetch
fails. Update shared submodule with fixed app slug URLs in nav.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:57:51 +00:00
giles
1602b14c3c Fetch nav-tree fragment from blog, drop local menu_items query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Navigation is now rendered by blog as an HTML fragment. This app
fetches it with its own app_name and path for correct highlighting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:39:39 +00:00
giles
95af55da39 Replace stale coop_url with blog_url in events templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
coop_url was the old blog URL helper, never registered as a Jinja
global. All 8 references across post/entry nav templates updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 11:02:55 +00:00
giles
226d50a980 Update shared submodule (product_slug rename in templates)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:30:14 +00:00
giles
4d5dd7b86e Update shared submodule (fragment auth skip for internal paths)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 09:11:57 +00:00
giles
ec2a91a401 Add fragment blueprint + sync shared: micro-frontend infrastructure
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 08:27:50 +00:00
giles
db78bd395e Sync shared: instant logout detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 01:30:38 +00:00
giles
83d3898aa4 Sync shared submodule: external delivery handler
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:41:19 +00:00
giles
e6fb4e8cd4 Sync shared: add artdag_url() helper
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 23:26:52 +00:00
giles
d634724a44 Sync shared: per-domain delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
2026-02-23 21:54:18 +00:00
giles
46ce430831 Update shared: backfill only current posts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:36:50 +00:00
giles
d57917b9c6 Update shared: rewrite object URLs for per-app AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:06:07 +00:00
giles
6eedc5b9b2 Update shared: fix activity ID domain mismatch in AP delivery
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:38:13 +00:00
giles
94ab2e0545 Update shared submodule: exempt AP paths from auth redirect
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:29:08 +00:00
giles
b2d692ae9c Update shared submodule: AP delivery fixes + sentinel
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:31:34 +00:00
giles
cf79734aff Update shared submodule: per-app AP actors
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:26 +00:00
giles
a425439df1 Update shared submodule (blog.home → blog.index template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m0s
2026-02-23 16:55:39 +00:00
giles
e55bbcc091 Retrigger CI (Docker Hub image now cached)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-02-23 16:45:49 +00:00
giles
bbdf7b7d08 Update shared submodule (at-least-once + delivery log)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1s
2026-02-23 16:21:17 +00:00
giles
aa2f0e2733 Update shared submodule (NOTIFY/LISTEN event processor)
Some checks failed
Build and Deploy / build-and-deploy (push) Failing after 1s
2026-02-23 16:05:21 +00:00
giles
af8340d6d0 Update shared submodule (add device_id migration)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
2026-02-23 15:26:53 +00:00
giles
40d83b2e90 Update shared: blog_did = account_did, one device identity
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:12:28 +00:00
giles
b7c3fa2ec7 Update shared: device-id SSO with account_did + Redis login signal
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 15:01:51 +00:00
giles
fa2ec6ef2f Sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:41:35 +00:00
giles
15057a1a22 Update shared: add aiohttp dependency
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:05:50 +00:00
giles
17296c4114 Update shared: device cookie auth state detection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:57:18 +00:00
giles
d02d45c468 Update shared: grant-based session revocation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:30:27 +00:00
giles
34ed7b6705 Iframe-based SSO logout (tolerates dead apps)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m8s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:21:48 +00:00
giles
6876a83f3b Update shared: remove sso_hint, add sso-clear logout chain
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:17:47 +00:00
giles
fb2c1e63c7 Update shared: SSO revocation clears local session on logout
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:16:07 +00:00
giles
81144ddbfc Update shared submodule: account is now OAuth server
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:01:33 +00:00
giles
63ce51bf31 Add /auth/clear to reset stale cookies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:45:30 +00:00
giles
32a6296093 Logout through federation sso-logout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:32:14 +00:00
giles
837dee33e0 Silent SSO via sso_hint cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:24:56 +00:00
giles
8de467245a Fix logout redirect to blog home
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:15:33 +00:00
giles
dae207927b Fix logout to use local /auth/logout/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:44 +00:00
giles
edbc11d956 Sign-in → account, clear old shared cookie
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:57:17 +00:00
giles
cfefa1bc91 Trigger rebuild: per-app cookies + OAuth SSO
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:45:23 +00:00
giles
8bb09e21e4 Fix OAuth authorize URL prefix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 10:26:01 +00:00
giles
4c131cd293 Update shared submodule: OAuth SSO + account app support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:59:07 +00:00
giles
690924a1f9 Update shared submodule (fix root top-bar account link)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:07:54 +00:00
giles
67c065fdc8 Update shared submodule (account URLs → federation)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 09:01:17 +00:00
giles
a70e0b81f0 Update shared: auth routes to federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:42:37 +00:00
giles
af47498cc0 Rename coop config keys to blog/market, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 08:33:28 +00:00
giles
53e79f0d44 Update COOP_DIR to /root/rose-ash in CI workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Infra files (.env, docker-compose.yml, _config) moved from ~/coop to ~/rose-ash.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:44:25 +00:00
giles
751e959dba Update shared submodule — add list_marketplaces
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:35:18 +00:00
giles
424c267110 Add global all-events view at / and scope page summary to single page
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Root / shows all upcoming events across all pages with page badges.
/<slug>/ reverted to show only that page's events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:13:14 +00:00
giles
dad53fd1b5 Show all events across all pages with page badges
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m1s
Page summary now loads all upcoming events globally, not just the
current page's. Each card shows an amber page badge when the event
belongs to a different page. Links use the correct page slug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:05:11 +00:00
giles
39f500c41c Inline ticket +/- updates without full page refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 58s
Extract shared _ticket_widget.html with stable #page-ticket-{id} target.
Adjust route returns re-rendered widget + OOB cart-mini swap, same
pattern as the entry detail page's ticket adjust.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:59:27 +00:00
giles
b8724eaf66 Add page-local ticket adjust route that returns HX-Refresh
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
The cards were posting to tickets.adjust_quantity which returns the
entry detail buy form — wrong context for the page summary. New
page_summary.adjust_ticket route calls the service and returns
HX-Refresh: true so the whole listing refreshes with correct counts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:56:42 +00:00
giles
f1b3093d94 Fix ticket cart URLs to use events app's own adjust route
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Was using cart_url (cross-app) which causes invalid path for HTMX.
Use url_for('tickets.adjust_quantity') — the events-local route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:52:11 +00:00
giles
1dbf8f479e Fix ticket config update returning full page instead of fragment
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
The update_tickets route was rendering index.html (full page) but the
HTMX form targets #entry-tickets with innerHTML swap. Return just the
_tickets.html fragment instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:48:14 +00:00
giles
e1f96f02b1 Event name links to entry, date header links to day view
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:45:31 +00:00
giles
9f46520b45 Fix day view links to use correct /day/<Y>/<M>/<D>/ URL format
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:41:16 +00:00
giles
e5ab555359 Add village hall page summary with infinite scroll
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
New /<slug>/ route shows upcoming confirmed events across all calendars
for a container. Features list/tile view toggle, date-grouped cards,
ticket +/- cart widgets, and infinite scroll pagination.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:28:30 +00:00
giles
995503480b Update shared: AP_DOMAIN default to federation.rose-ash.com
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 21:12:53 +00:00
giles
f8c99d3044 Update shared: origin_app isolation for EventProcessor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m4s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:59:46 +00:00
giles
748a13a369 Update shared: fix AP re-publish versioned object IDs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 20:04:20 +00:00
giles
fb82fca10e Update shared submodule — restore deleted templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 19:29:49 +00:00
giles
fef13b9f94 Update shared submodule (remove dead code)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:11:38 +00:00
giles
60d7cf03c2 Update shared submodule (remove dead cart template)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:05:33 +00:00
giles
bd25a09e0d Update shared submodule (cart_sid in login URL)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:46:46 +00:00
giles
09de64be99 Update shared submodule (cart sign-in fix)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:38:14 +00:00
giles
4dd51feb92 Update shared submodule to unified AP activity bus
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:20:14 +00:00
giles
8f4d733d12 Tech debt cleanup: update README, fix comments, sync shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 15:35:58 +00:00
giles
e7e8d69b7a Update shared: add fediverse social tables and protocols
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:16:02 +00:00
giles
8da241f60b Update shared: fix duplicate AP posts + stable object IDs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:18:25 +00:00
giles
ddd599a2f4 Update shared: fix AP Delete Tombstone id mismatch
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:26:00 +00:00
giles
7b642b3430 Widget Phase 2: events app consumes container_nav widgets
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 54s
Day view and entry detail now load and render registered container_nav
widgets (e.g. market links) from the same container page — matching how
blog post pages work. Calendar-domain widgets are skipped since we're
already on a calendar page. Adds /w/<widget_domain>/ paginate route for
infinite scroll support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 09:14:18 +00:00
giles
012bb868e6 Update shared: fix AP object id domain for Mastodon
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:53:21 +00:00
giles
c1314f7f7d Update shared: inline federation publish + AP delivery fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 08:28:12 +00:00
giles
446bbf74b4 Inline federation publication for calendar entries
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Replace emit_event("calendar_entry.created") with direct try_publish().
Update shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 07:55:52 +00:00
giles
4c8d038156 Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:27:26 +00:00
giles
6e2bbe73be Wire real SqlFederationService instead of stub
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 23:19:25 +00:00
giles
f4be5b47e6 Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:53:57 +00:00
giles
5b8e0df990 Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:47:08 +00:00
giles
6731790c9d Update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 22:33:51 +00:00
giles
dc357daae8 Update shared submodule: fix adopt_entries login bug
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 21:20:52 +00:00
giles
ef273a7311 Update shared submodule + emit calendar entry events for federation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
- Emit calendar_entry.created events when entries are added
- Updated shared with federation handlers, delivery, anchoring

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:59:59 +00:00
giles
a7b70569c9 Wire federation service stub and update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m3s
- Register StubFederationService in services/__init__.py
- Add federation to CI sibling list
- Add federation URL to app-config.yaml
- Update shared submodule with federation models/contracts/services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 15:11:23 +00:00
giles
90c918595c Fix OOB cart-mini clearing cart icon when adjusting tickets
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
The mini macro was imported without context, so cart_count was invisible
to the macro. Pass count explicitly in _adjust_response.html and add
'with context' to _buy_result.html. Update shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:43:06 +00:00
giles
f445d39d22 Fix cart-mini OOB: compute cart_count explicitly in adjust route
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
Pass cart_count directly to the template instead of relying on the
context processor, which may not resolve correctly for the /tickets/
URL prefix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:12:59 +00:00
giles
4aaaf2c7f1 Fix: remove OOB cart-mini from ticket adjust response
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 52s
The OOB swap was replacing the cart display with empty/wrong content.
Cart count updates naturally on next page navigation instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 09:09:17 +00:00
giles
256eb390b0 Replace ticket qty input with +/- buttons, show sold/basket counts
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
- Entry page shows tickets sold count, remaining, and "in basket" count
- Replace numeric input + Buy button with add-to-basket / +/- controls
- New POST /tickets/adjust/ route creates/cancels tickets to target count
- Keep buy form active after adding (no confirmation replacement)
- New service functions: get_sold_ticket_count, get_user_reserved_count,
  cancel_latest_reserved_ticket

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 08:53:12 +00:00
giles
13064c3772 Decoupling audit: remove events API, update shared submodule
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 3m30s
- Delete events_api.py (dead internal API endpoint)
- Remove registration from app.py
- Update shared submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 11:15:33 +00:00
giles
44d089ced4 Update cart badge on ticket purchase via OOB swap
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 57s
The buy_tickets response now includes an OOB swap of #cart-mini
so the cart badge count updates immediately when tickets are reserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:11:53 +00:00
giles
e098422fff Include ticket counts in cart badge totals
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 22:01:34 +00:00
giles
df77f3d2a5 Update shared submodule to latest widget registry
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 20:04:14 +00:00
giles
3c8f231e19 Update shared submodule: widget registry
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 18:08:50 +00:00
giles
9932209b7d Update shared submodule: tickets & bookings account pages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:07:32 +00:00
giles
c16092e984 Update shared submodule: fix category selector highlighting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:29:09 +00:00
giles
0e0359c84d Update shared submodule: select_colours Jinja global
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 15:17:27 +00:00
giles
5f63c9b1f3 Update shared submodule: fix menu item highlighting
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 13:57:13 +00:00
giles
3873f22bf2 Update shared submodule: delete button + quantity clamp in cart_item
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:43:44 +00:00
giles
2f8a62e4a1 Update shared submodule: cart_quantity_url template support
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:32:39 +00:00
giles
2527c854cb Replace HTTP API and MarketPlace imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Calendar entries route now uses services.cart.cart_summary() instead of
internal HTTP API call to cart app. Market CRUD delegates to
services.market.create_marketplace() / soft_delete_marketplace().
Updates shared submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 06:07:20 +00:00
giles
a03fa90463 Update shared submodule: DTO template compatibility fixes
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 05:05:45 +00:00
giles
f2ce575699 Update shared submodule: revert extend_existing workaround
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:52:06 +00:00
giles
f289ee0dcd Fix NameError: import services registry in create_app scope
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
The services singleton was used in before_request closures but the
import was removed when refactoring to domain_services_fn.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:45:38 +00:00
giles
cec9a3296f Remove glue submodule: models moved to shared/
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
The glue layer's models (MenuNode, ContainerRelation), services
(navigation, relationships), and event handlers have been absorbed
into shared/. The glue submodule caused duplicate SQLAlchemy table
registration for 'menu_nodes'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:41:18 +00:00
giles
14836e0ea3 Update shared submodule: fix duplicate table error for MenuNode
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:34:51 +00:00
giles
f1b5aeac53 Domain isolation: replace cross-domain imports with service calls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 55s
Replace direct Post, MarketPlace, Calendar model queries and HTTP API
calls with typed service calls. Events registers all 4 services via
domain_services_fn with has() guards.

Key changes:
- app.py: use domain_services_fn, Post/Calendar/MarketPlace queries
  → services.blog/calendar/market, HTTP cart API → services.cart
- calendars/markets services: Post → services.blog
- post_associations: Post → services.blog, direct queries → services
- markets routes: remove unused MarketPlace import
- glue imports → shared imports throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 04:30:20 +00:00
giles
7b15f37686 Update shared submodule: fix ticket_types lazy-load
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:02:18 +00:00
giles
d2195c0969 Fix cart badge: include calendar entries + OOB update on add
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 50s
- Context processor now sums product + calendar counts from cart API
- add_entry route returns OOB cart-mini swap with correct count,
  querying pending entries from local session (sees uncommitted entry)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:49:45 +00:00
giles
b153203fc0 Fix CalendarEntryPost join: add explicit ON clause for polymorphic content_id
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m47s
content_id is a loose reference (no FK to posts.id), so SQLAlchemy
cannot infer the join condition automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 21:24:39 +00:00
giles
ba456dca4c Decouple events: use shared.models for all cross-app imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
- Replace all imports from blog.models and cart.models
  with shared.models equivalents
- Convert events/models/calendars.py to re-export stub
- Update shared + glue submodule pointers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:58:14 +00:00
giles
6e9c973572 Remove 41 identical events template overrides of shared templates
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 20:03:53 +00:00
giles
7c84933bc8 Update shared submodule: fix orders link htmx interception
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 39s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:31:00 +00:00
giles
a9d42cdd39 Update shared submodule: use coop_url for auth links
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 19:18:44 +00:00
giles
f9b9f2d10f Update shared submodule: fix market nav link
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:57:12 +00:00
giles
9fc97b8f6d Update shared submodule: add page_config to get_checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:38:21 +00:00
giles
19a6b7318d Update shared submodule: market_product_url for correct product URLs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 18:19:58 +00:00
giles
6b022a444f Update shared submodule: add page_config to SumUp checkout
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 17:52:20 +00:00
giles
f7c5c7ea88 Update shared submodule: fix doubled URLs in |host filter
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 11:46:09 +00:00
giles
d2521e81aa Update shared submodule pointer (README addition)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 38s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:16:49 +00:00
giles
ca8288e28d Update glue submodule pointer (README addition)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:49:56 +00:00
giles
ad3c8a637e README: replace vague cross-app section with actual code dependencies
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
List specific model imports, glue services, and internal APIs
that events code actually references. Remove descriptions of what
other apps do to events data (that belongs in those apps' docs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:48:00 +00:00
giles
ddbd04b67f Rewrite README for post-decoupling architecture
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
Document all 12 blueprints, all 6 models with table, submodules,
cross-app integration via glue services, and migration ownership.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:29:09 +00:00
giles
88347222d8 Phase 5: Update shared + glue submodule pointers
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 38s
shared: migration to drop cross-domain FK constraints
glue: order lifecycle services, cart adoption, login/order handlers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:11:58 +00:00
giles
bbde3c1f3f Phase 5: Remove cross-domain FKs and relationships from calendar models
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
- CalendarEntry.order_id: plain Integer column (no FK to orders)
- CalendarEntry: remove order relationship
- Ticket.order_id: plain Integer column (no FK to orders)
- Ticket: remove order relationship

Cross-domain bridging now handled by glue services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 17:35:31 +00:00
giles
fca0950cd1 Add attach/detach glue calls to calendar and market CRUD
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
Wire up ContainerRelation tracking via attach_child/detach_child in:
- calendars: create (including revive), soft_delete
- markets: create (including revive), soft_delete

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 09:52:13 +00:00
giles
ee93832db0 Update shared submodule to include glue layer + MenuItem fix
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 08:03:32 +00:00
giles
0a8d1391f6 Add glue layer: replace /internal/menu-items API with direct DB query
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 43s
- Context processor: get_navigation_tree() replaces api_get("coop", "/internal/menu-items")
- Add glue submodule

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 23:37:54 +00:00
giles
4a0041efd5 ci: clean all sibling dirs before copying to fix stale table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m20s
Previous runs left self-copies (e.g. events/events/) that caused
'Table already defined' errors. Split into two loops: first rm -rf
all sibling dirs, then copy only non-self siblings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:31:59 +00:00
giles
b2aa657d70 CI: skip copying own models to avoid duplicate SQLAlchemy table defs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m12s
Each app has its own models/ at the root (imported as bare `models.X`).
The CI copy was also creating {app}/models/ (imported as `{app}.models.X`),
causing SQLAlchemy to see the same table defined twice.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:15:08 +00:00
giles
dd827541ee Update shared submodule: import all model packages at startup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 16:01:46 +00:00
giles
b26b47169a CI: use git archive for sibling models (atomic, race-safe)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
The cp approach failed when sibling repos were mid-update from
their own CI runs. git archive reads directly from git objects,
and git fetch ensures origin/decoupling is available even if the
sibling working tree is on a different branch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:11:02 +00:00
giles
05867ff7f5 CI: copy sibling app models into build context for cross-domain imports
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m18s
Phases 1-3 split models by domain ownership, but cross-app imports
still exist (e.g. cart imports market.models.CartItem). In Docker
each app only has its own code. The CI step now copies sibling app
model packages into the build context before docker build.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 15:02:07 +00:00
giles
b188bb8f20 Update shared submodule: merge diverged alembic heads
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:27:40 +00:00
giles
387af7faa7 Update shared submodule (adds missing alembic.ini)
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m49s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:20:25 +00:00
giles
541dd2ccd7 Add PYTHONPATH=/app so Hypercorn spawn workers find app module
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m58s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:01:50 +00:00
giles
cac3c12241 Update shared submodule: rename logging → log_config
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Fixes stdlib logging shadow that caused circular import in Docker.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:56:09 +00:00
giles
b35abdeda8 Replace shared_lib submodule with shared for decoupling deploy
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m22s
- Swap shared_lib submodule → shared (tracking decoupling branch)
- Dockerfile: shared_lib/ → shared/, remove bp symlink hack
- CI: trigger on decoupling branch, use dynamic ref_name

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 13:29:32 +00:00
giles
154f968296 feat: decouple events from shared_lib, add app-owned models
Phase 1-3 of decoupling:
- path_setup.py adds project root to sys.path
- Events-owned models in events/models/ (calendars with all related models)
- All imports updated: shared.infrastructure, shared.db, shared.browser, etc.
- Calendar uses container_type/container_id instead of post_id FK
- CalendarEntryPost uses content_type/content_id (generic content refs)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:46:36 +00:00
82 changed files with 2054 additions and 569 deletions

View File

@@ -2,13 +2,13 @@ name: Build and Deploy
on: on:
push: push:
branches: [main] branches: [main, decoupling]
env: env:
REGISTRY: registry.rose-ash.com:5000 REGISTRY: registry.rose-ash.com:5000
IMAGE: events IMAGE: events
REPO_DIR: /root/rose-ash/events REPO_DIR: /root/rose-ash/events
COOP_DIR: /root/coop COOP_DIR: /root/rose-ash
jobs: jobs:
build-and-deploy: build-and-deploy:
@@ -36,9 +36,23 @@ jobs:
run: | run: |
ssh "root@$DEPLOY_HOST" " ssh "root@$DEPLOY_HOST" "
cd ${{ env.REPO_DIR }} cd ${{ env.REPO_DIR }}
git fetch origin main git fetch origin ${{ github.ref_name }}
git reset --hard origin/main git reset --hard origin/${{ github.ref_name }}
git submodule update --init --recursive git submodule update --init --recursive
# Clean ALL sibling dirs (including stale self-copies from previous runs)
for sibling in blog market cart events federation; do
rm -rf \$sibling
done
# Copy non-self sibling models for cross-domain imports
for sibling in blog market cart events federation; do
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
repo=/root/rose-ash/\$sibling
if [ -d \$repo/.git ]; then
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
mkdir -p \$sibling
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
fi
done
" "
- name: Build and push image - name: Build and push image

5
.gitmodules vendored
View File

@@ -1,3 +1,4 @@
[submodule "shared_lib"] [submodule "shared"]
path = shared_lib path = shared
url = https://git.rose-ash.com/coop/shared.git url = https://git.rose-ash.com/coop/shared.git
branch = decoupling

View File

@@ -4,6 +4,7 @@ FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \ ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \ PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \ PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \ APP_PORT=8000 \
APP_MODULE=app:app APP_MODULE=app:app
@@ -16,14 +17,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client \ postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY shared_lib/requirements.txt ./requirements.txt COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt RUN pip install -r requirements.txt
COPY . . COPY . .
# Link app blueprints into the shared library's namespace
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh

View File

@@ -1,48 +1,78 @@
# Events App # Events App
Calendar and event booking service for the Rose Ash cooperative platform. Calendar and event booking service for the Rose Ash cooperative platform. Manages calendars, time slots, calendar entries (bookings), tickets, and ticket types.
## Overview ## Architecture
The events app provides calendar-based event booking with flexible slot management. One of five Quart microservices sharing a single PostgreSQL database:
It runs as a standalone Quart microservice, part of the multi-app coop architecture.
| App | Port | Domain |
|-----|------|--------|
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
| market | 8001 | Product browsing, Suma scraping |
| cart | 8002 | Shopping cart, checkout, orders |
| **events** | 8003 | Calendars, bookings, tickets |
| federation | 8004 | ActivityPub, fediverse social |
## Structure ## Structure
``` ```
app.py # Application factory and entry point app.py # Application factory (create_base_app + blueprints)
events_api.py # Internal JSON API (server-to-server, CSRF-exempt) path_setup.py # Adds project root + app dir to sys.path
bp/ # Blueprints config/app-config.yaml # App URLs, feature flags
calendars/ # Calendar listing models/ # Events-domain models
calendar/ # Single calendar view and admin calendars.py # Calendar, CalendarEntry, CalendarSlot,
calendar_entries/ # Calendar entries listing # TicketType, Ticket, CalendarEntryPost
calendar_entry/ # Single entry view and admin bp/ # Blueprints
day/ # Day view and admin calendars/ # Calendar listing
slots/ # Slot listing calendar/ # Single calendar view and admin
slot/ # Single slot management calendar_entries/ # Calendar entries listing
ticket_types/ # Ticket type listing calendar_entry/ # Single entry view and admin
ticket_type/ # Single ticket type management day/ # Day view and admin
templates/ # Jinja2 templates slots/ # Slot listing
_types/ # Feature-specific templates slot/ # Single slot management
ticket_types/ # Ticket type listing
ticket_type/ # Single ticket type management
tickets/ # Ticket listing
ticket_admin/ # Ticket administration
markets/ # Page-scoped marketplace views
payments/ # Payment-related views
services/ # register_domain_services() — wires calendar + market + cart
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
``` ```
## Models
All events-domain models live in `models/calendars.py`:
| Model | Description |
|-------|-------------|
| **Calendar** | Container for entries, scoped to a page via `container_type + container_id` |
| **CalendarEntry** | A bookable event/time slot. Has `state` (pending/ordered/provisional), `cost`, ownership (`user_id`/`session_id`), and `order_id` (plain integer, no FK) |
| **CalendarSlot** | Recurring time bands (day-of-week + time range) within a calendar |
| **TicketType** | Named ticket categories with price and count |
| **Ticket** | Individual ticket with unique code, state, and `order_id` (plain integer, no FK) |
| **CalendarEntryPost** | Junction linking entries to content via `content_type + content_id` |
`order_id` on CalendarEntry and Ticket is a plain integer column — no FK constraint to the orders table. The cart app writes these values via service calls, not directly.
## Cross-Domain Communication
- `services.market.*` — marketplace queries for page views
- `services.cart.*` — cart summary for context processor
- `services.federation.*` — AP publishing for new entries
- `shared.services.navigation` — site navigation tree
## Migrations
This app does **not** run Alembic migrations on startup. Migrations are managed in the `shared/` submodule and run from the blog app's entrypoint.
## Running ## Running
```bash ```bash
# Set required environment variables (see .env.example) export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
export APP_MODULE=app:app export REDIS_URL=redis://localhost:6379/0
hypercorn app:app --bind 0.0.0.0:8000 export SECRET_KEY=your-secret-key
hypercorn app:app --bind 0.0.0.0:8003
``` ```
## Docker
```bash
docker build -t events .
docker run -p 8000:8000 --env-file .env events
```
## Notes
- This app does **not** run Alembic migrations. Database schema is managed by the blog app.
- Internal API endpoints under `/internal/events/` are used by the cart app for cross-service communication.
- Depends on shared packages (`shared/`, `models/`, `config/`) from the main coop monorepo.

0
__init__.py Normal file
View File

95
app.py
View File

@@ -1,51 +1,58 @@
from __future__ import annotations from __future__ import annotations
import path_setup # noqa: F401 # adds shared_lib to sys.path import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path from pathlib import Path
from quart import g, abort from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.factory import create_base_app from shared.infrastructure.factory import create_base_app
from suma_browser.app.bp import register_calendars, register_markets, register_payments from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments
async def events_context() -> dict: async def events_context() -> dict:
""" """
Events app context processor. Events app context processor.
- menu_items: fetched from coop internal API - nav_tree_html: fetched from blog as fragment
- cart_count/cart_total: fetched from cart internal API - cart_count/cart_total: via cart service (shared DB)
""" """
from shared.context import base_context from shared.infrastructure.context import base_context
from shared.internal_api import get as api_get, dictobj from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragment
ctx = await base_context() ctx = await base_context()
# Menu items from coop API (wrapped for attribute access in templates) ctx["nav_tree_html"] = await fetch_fragment(
menu_data = await api_get("coop", "/internal/menu-items") "blog", "nav-tree",
ctx["menu_items"] = dictobj(menu_data) if menu_data else [] params={"app_name": "events", "path": request.path},
)
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data from cart API # Cart data via service (replaces cross-app HTTP API)
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True) ident = current_cart_identity()
if cart_data: summary = await services.cart.cart_summary(
ctx["cart_count"] = cart_data.get("count", 0) g.s, user_id=ident["user_id"], session_id=ident["session_id"],
ctx["cart_total"] = cart_data.get("total", 0) )
else: ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_count"] = 0 ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
ctx["cart_total"] = 0
return ctx return ctx
def create_app() -> "Quart": def create_app() -> "Quart":
from models.ghost_content import Post from shared.services.registry import services
from models.calendars import Calendar from services import register_domain_services
from models.market_place import MarketPlace
app = create_base_app("events", context_fn=events_context) app = create_base_app(
"events",
context_fn=events_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates # App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates") app_templates = str(Path(__file__).resolve().parent / "templates")
@@ -54,6 +61,18 @@ def create_app() -> "Quart":
app.jinja_loader, app.jinja_loader,
]) ])
# All events: / — global view across all pages
app.register_blueprint(
register_all_events(),
url_prefix="/",
)
# Page summary: /<slug>/ — upcoming events across all calendars
app.register_blueprint(
register_page(),
url_prefix="/<slug>",
)
# Calendars nested under post slug: /<slug>/calendars/... # Calendars nested under post slug: /<slug>/calendars/...
app.register_blueprint( app.register_blueprint(
register_calendars(), register_calendars(),
@@ -72,6 +91,8 @@ def create_app() -> "Quart":
url_prefix="/<slug>/payments", url_prefix="/<slug>/payments",
) )
app.register_blueprint(register_fragments())
# --- Auto-inject slug into url_for() calls --- # --- Auto-inject slug into url_for() calls ---
@app.url_value_preprocessor @app.url_value_preprocessor
def pull_slug(endpoint, values): def pull_slug(endpoint, values):
@@ -91,11 +112,7 @@ def create_app() -> "Quart":
slug = getattr(g, "post_slug", None) slug = getattr(g, "post_slug", None)
if not slug: if not slug:
return return
post = ( post = await services.blog.get_post_by_slug(g.s, slug)
await g.s.execute(
select(Post).where(Post.slug == slug)
)
).scalar_one_or_none()
if not post: if not post:
abort(404) abort(404)
g.post_data = { g.post_data = {
@@ -115,20 +132,8 @@ def create_app() -> "Quart":
if not post_data: if not post_data:
return {} return {}
post_id = post_data["post"]["id"] post_id = post_data["post"]["id"]
calendars = ( calendars = await services.calendar.calendars_for_container(g.s, "page", post_id)
await g.s.execute( markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc())
)
).scalars().all()
markets = (
await g.s.execute(
select(MarketPlace)
.where(MarketPlace.post_id == post_id, MarketPlace.deleted_at.is_(None))
.order_by(MarketPlace.name.asc())
)
).scalars().all()
return { return {
**post_data, **post_data,
"calendars": calendars, "calendars": calendars,
@@ -143,10 +148,6 @@ def create_app() -> "Quart":
from bp.ticket_admin.routes import register as register_ticket_admin from bp.ticket_admin.routes import register as register_ticket_admin
app.register_blueprint(register_ticket_admin()) app.register_blueprint(register_ticket_admin())
# Internal API (server-to-server, CSRF-exempt)
from events_api import register as register_events_api
app.register_blueprint(register_events_api())
return app return app

View File

@@ -1,3 +1,6 @@
from .all_events.routes import register as register_all_events
from .calendars.routes import register as register_calendars from .calendars.routes import register as register_calendars
from .markets.routes import register as register_markets from .markets.routes import register as register_markets
from .payments.routes import register as register_payments from .payments.routes import register as register_payments
from .page.routes import register as register_page
from .fragments import register_fragments

View File

143
bp/all_events/routes.py Normal file
View File

@@ -0,0 +1,143 @@
"""
All-events blueprint — shows upcoming events across ALL pages' calendars.
Mounted at / (root of events app). No slug context — works independently
of the post/slug machinery.
Routes:
GET / — full page with first page of entries
GET /all-entries — HTMX fragment for infinite scroll
POST /all-tickets/adjust — adjust ticket quantity inline
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, render_template_string, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services
def register() -> Blueprint:
bp = Blueprint("all_events", __name__)
async def _load_entries(page, per_page=20):
"""Load all upcoming entries + pending ticket counts + page info."""
entries, has_more = await services.calendar.upcoming_entries_for_container(
g.s, page=page, per_page=per_page,
)
# Pending ticket counts keyed by entry_id
ident = current_cart_identity()
pending_tickets = {}
if entries:
tickets = await services.calendar.pending_tickets(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
for t in tickets:
if t.entry_id is not None:
pending_tickets[t.entry_id] = pending_tickets.get(t.entry_id, 0) + 1
# Batch-load page info for container_ids
page_info = {} # {post_id: {title, slug}}
if entries:
post_ids = list({
e.calendar_container_id
for e in entries
if e.calendar_container_type == "page" and e.calendar_container_id
})
if post_ids:
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
for p in posts:
page_info[p.id] = {"title": p.title, "slug": p.slug}
return entries, has_more, pending_tickets, page_info
@bp.get("/")
async def index():
view = request.args.get("view", "list")
page = int(request.args.get("page", 1))
entries, has_more, pending_tickets, page_info = await _load_entries(page)
ctx = dict(
entries=entries,
has_more=has_more,
pending_tickets=pending_tickets,
page_info=page_info,
page=page,
view=view,
)
if is_htmx_request():
html = await render_template("_types/all_events/_main_panel.html", **ctx)
else:
html = await render_template("_types/all_events/index.html", **ctx)
return await make_response(html, 200)
@bp.get("/all-entries")
async def entries_fragment():
view = request.args.get("view", "list")
page = int(request.args.get("page", 1))
entries, has_more, pending_tickets, page_info = await _load_entries(page)
html = await render_template(
"_types/all_events/_cards.html",
entries=entries,
has_more=has_more,
pending_tickets=pending_tickets,
page_info=page_info,
page=page,
view=view,
)
return await make_response(html, 200)
@bp.post("/all-tickets/adjust")
async def adjust_ticket():
"""Adjust ticket quantity, return updated widget + OOB cart-mini."""
ident = current_cart_identity()
form = await request.form
entry_id = int(form.get("entry_id", 0))
count = max(int(form.get("count", 0)), 0)
tt_raw = (form.get("ticket_type_id") or "").strip()
ticket_type_id = int(tt_raw) if tt_raw else None
await services.calendar.adjust_ticket_quantity(
g.s, entry_id, count,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
# Get updated ticket count for this entry
tickets = await services.calendar.pending_tickets(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
qty = sum(1 for t in tickets if t.entry_id == entry_id)
# Load entry DTO for the widget template
entry = await services.calendar.entry_by_id(g.s, entry_id)
# Updated cart count for OOB mini-cart
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
cart_count = summary.count + summary.calendar_count + summary.ticket_count
# Render widget + OOB cart-mini
widget_html = await render_template(
"_types/page_summary/_ticket_widget.html",
entry=entry,
qty=qty,
ticket_url="/all-tickets/adjust",
)
mini_html = await render_template_string(
'{% from "_types/cart/_mini.html" import mini with context %}'
'{{ mini(oob="true") }}',
cart_count=cart_count,
)
return await make_response(widget_html + mini_html, 200)
return bp

View File

@@ -5,8 +5,8 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
@@ -17,7 +17,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(calendar_slug: str, **kwargs): async def admin(calendar_slug: str, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -11,7 +11,7 @@ from sqlalchemy import select
from models.calendars import Calendar from models.calendars import Calendar
from sqlalchemy.orm import selectinload, with_loader_criteria from sqlalchemy.orm import selectinload, with_loader_criteria
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from .admin.routes import register as register_admin from .admin.routes import register as register_admin
from .services import get_visible_entries_for_period from .services import get_visible_entries_for_period
@@ -23,17 +23,17 @@ from .services.calendar_view import (
get_calendar_by_slug, get_calendar_by_slug,
update_calendar_description, update_calendar_description,
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from ..slots.routes import register as register_slots from ..slots.routes import register as register_slots
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
from suma_browser.app.bp.calendars.services.calendars import soft_delete from bp.calendars.services.calendars import soft_delete
from suma_browser.app.bp.day.routes import register as register_day from bp.day.routes import register as register_day
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from sqlalchemy import select from sqlalchemy import select
@@ -213,7 +213,7 @@ def register():
@require_admin @require_admin
@clear_cache(tag="calendars", tag_scope="all") @clear_cache(tag="calendars", tag_scope="all")
async def delete(calendar_slug: str, **kwargs): async def delete(calendar_slug: str, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
cal = g.calendar cal = g.calendar
cal.deleted_at = datetime.now(timezone.utc) cal.deleted_at = datetime.now(timezone.utc)
@@ -230,7 +230,7 @@ def register():
cals = ( cals = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()

View File

@@ -1,5 +1,6 @@
from sqlalchemy import select, update from sqlalchemy import select, update
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
from sqlalchemy import func from sqlalchemy import func
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None: async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:

View File

@@ -11,7 +11,6 @@ from sqlalchemy.orm import selectinload, with_loader_criteria
from models.calendars import Calendar, CalendarSlot from models.calendars import Calendar, CalendarSlot
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]: def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
"""Parse an integer query parameter from the request.""" """Parse an integer query parameter from the request."""
val = request.args.get(name, "").strip() val = request.args.get(name, "").strip()
@@ -71,7 +70,8 @@ async def get_calendar_by_post_and_slug(
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)), with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
) )
.where( .where(
Calendar.post_id == post_id, Calendar.container_type == "page",
Calendar.container_id == post_id,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None), Calendar.deleted_at.is_(None),
) )

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
class SlotError(ValueError): class SlotError(ValueError):
pass pass

View File

@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
@dataclass @dataclass
class VisibleEntries: class VisibleEntries:
""" """

View File

@@ -3,26 +3,29 @@ from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g, redirect, url_for, jsonify request, render_template, render_template_string, make_response,
Blueprint, g, redirect, url_for, jsonify,
) )
from sqlalchemy import update from sqlalchemy import update, func as sa_func
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
from .services.entries import ( from .services.entries import (
add_entry as svc_add_entry, add_entry as svc_add_entry,
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from suma_browser.app.bp.calendar_entry.routes import register as register_calendar_entry from bp.calendar_entry.routes import register as register_calendar_entry
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
from sqlalchemy import select from sqlalchemy import select
@@ -203,8 +206,36 @@ def register():
entry.ticket_price = ticket_price entry.ticket_price = ticket_price
entry.ticket_count = ticket_count entry.ticket_count = ticket_count
# Count pending calendar entries from local session (sees the just-added entry)
user_id = getattr(g, "user", None) and g.user.id
cal_filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if user_id:
cal_filters.append(CalendarEntry.user_id == user_id)
cal_count = await g.s.scalar(
select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters)
) or 0
# Get product cart count via service (same DB, no HTTP needed)
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services
ident = current_cart_identity()
cart_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
product_count = cart_summary.count
total_count = product_count + cal_count
html = await render_template("_types/day/_main_panel.html") html = await render_template("_types/day/_main_panel.html")
return await make_response(html, 200) mini_html = await render_template_string(
'{% from "_types/cart/_mini.html" import mini with context %}'
'{{ mini(oob="true") }}',
cart_count=total_count,
)
return await make_response(html + mini_html, 200)
@bp.get("/add/") @bp.get("/add/")
async def add_form(day: int, month: int, year: int, **kwargs): async def add_form(day: int, month: int, year: int, **kwargs):

View File

@@ -7,9 +7,10 @@ from sqlalchemy import select, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar, CalendarEntry from models.calendars import Calendar, CalendarEntry
from datetime import datetime from datetime import datetime
from suma_browser.app.errors import AppError from shared.browser.app.errors import AppError
class CalendarError(AppError): class CalendarError(AppError):
"""Base error for calendar service operations.""" """Base error for calendar service operations."""
@@ -93,6 +94,24 @@ async def add_entry(
) )
sess.add(entry) sess.add(entry)
await sess.flush() await sess.flush()
# Publish to federation inline
if entry.user_id:
from shared.services.federation_publish import try_publish
await try_publish(
sess,
user_id=entry.user_id,
activity_type="Create",
object_type="Event",
object_data={
"name": entry.name or "",
"startTime": entry.start_at.isoformat() if entry.start_at else "",
"endTime": entry.end_at.isoformat() if entry.end_at else "",
},
source_type="CalendarEntry",
source_id=entry.id,
)
return entry return entry
@@ -120,7 +139,8 @@ async def list_entries(
await sess.execute( await sess.execute(
select(Calendar.id) select(Calendar.id)
.where( .where(
Calendar.post_id == post_id, Calendar.container_type == "page",
Calendar.container_id == post_id,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None), Calendar.deleted_at.is_(None),
) )

View File

@@ -5,7 +5,7 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
def register(): def register():
@@ -15,7 +15,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(entry_id: int, **kwargs): async def admin(entry_id: int, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -5,8 +5,8 @@ from sqlalchemy import select, update
from models.calendars import CalendarEntry, CalendarSlot from models.calendars import CalendarEntry, CalendarSlot
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from sqlalchemy import select from sqlalchemy import select
@@ -27,6 +27,8 @@ from datetime import datetime, timezone
import math import math
import logging import logging
from shared.infrastructure.fragments import fetch_fragment
from ..ticket_types.routes import register as register_ticket_types from ..ticket_types.routes import register as register_ticket_types
from .admin.routes import register as register_admin from .admin.routes import register as register_admin
@@ -142,7 +144,7 @@ def register():
calendars = ( calendars = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post.id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post.id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()
@@ -160,13 +162,22 @@ def register():
@bp.context_processor @bp.context_processor
async def inject_root(): async def inject_root():
from ..tickets.services.tickets import get_available_ticket_count from ..tickets.services.tickets import (
get_available_ticket_count,
get_sold_ticket_count,
get_user_reserved_count,
)
from shared.infrastructure.cart_identity import current_cart_identity
from sqlalchemy.orm import selectinload
view_args = getattr(request, "view_args", {}) or {} view_args = getattr(request, "view_args", {}) or {}
entry_id = view_args.get("entry_id") entry_id = view_args.get("entry_id")
calendar_entry = None calendar_entry = None
entry_posts = [] entry_posts = []
ticket_remaining = None ticket_remaining = None
ticket_sold_count = 0
user_ticket_count = 0
user_ticket_counts_by_type = {}
stmt = ( stmt = (
select(CalendarEntry) select(CalendarEntry)
@@ -174,6 +185,7 @@ def register():
CalendarEntry.id == entry_id, CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None), CalendarEntry.deleted_at.is_(None),
) )
.options(selectinload(CalendarEntry.ticket_types))
) )
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
calendar_entry = result.scalar_one_or_none() calendar_entry = result.scalar_one_or_none()
@@ -190,19 +202,53 @@ def register():
entry_posts = await get_entry_posts(g.s, calendar_entry.id) entry_posts = await get_entry_posts(g.s, calendar_entry.id)
# Get ticket availability # Get ticket availability
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id) ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
# Get sold count
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
# Get current user's reserved count
ident = current_cart_identity()
user_ticket_count = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
# Per-type counts for multi-type entries
if calendar_entry.ticket_types:
for tt in calendar_entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, calendar_entry.id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
# Fetch container nav from market (skip calendar — we're on a calendar page)
container_nav_html = ""
post_data = getattr(g, "post_data", None)
if post_data:
post_id = post_data["post"]["id"]
post_slug = post_data["post"]["slug"]
container_nav_html = await fetch_fragment("market", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": post_slug,
})
return { return {
"entry": calendar_entry, "entry": calendar_entry,
"entry_posts": entry_posts, "entry_posts": entry_posts,
"ticket_remaining": ticket_remaining, "ticket_remaining": ticket_remaining,
} "ticket_sold_count": ticket_sold_count,
"user_ticket_count": user_ticket_count,
"user_ticket_counts_by_type": user_ticket_counts_by_type,
"container_nav_html": container_nav_html,
}
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def get(entry_id: int, **rest): async def get(entry_id: int, **rest):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX # Full template for both HTMX and normal requests
# For now, render full template for both HTMX and normal requests
if not is_htmx_request(): if not is_htmx_request():
# Normal browser request: full page with layout # Normal browser request: full page with layout
html = await render_template( html = await render_template(
@@ -496,8 +542,8 @@ def register():
await g.s.flush() await g.s.flush()
# Return updated entry view # Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
html = await render_template("_types/entry/index.html") html = await render_template("_types/entry/_tickets.html")
return await make_response(html, 200) return await make_response(html, 200)
@bp.get("/posts/search/") @bp.get("/posts/search/")

View File

@@ -5,7 +5,7 @@ from sqlalchemy import select
from sqlalchemy.sql import func from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost from models.calendars import CalendarEntry, CalendarEntryPost
from models.ghost_content import Post from shared.services.registry import services
async def add_post_to_entry( async def add_post_to_entry(
@@ -28,9 +28,7 @@ async def add_post_to_entry(
return False, "Calendar entry not found" return False, "Calendar entry not found"
# Check if post exists # Check if post exists
post = await session.scalar( post = await services.blog.get_post_by_id(session, post_id)
select(Post).where(Post.id == post_id)
)
if not post: if not post:
return False, "Post not found" return False, "Post not found"
@@ -38,7 +36,8 @@ async def add_post_to_entry(
existing = await session.scalar( existing = await session.scalar(
select(CalendarEntryPost).where( select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id, CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None) CalendarEntryPost.deleted_at.is_(None)
) )
) )
@@ -49,7 +48,8 @@ async def add_post_to_entry(
# Create association # Create association
association = CalendarEntryPost( association = CalendarEntryPost(
entry_id=entry_id, entry_id=entry_id,
post_id=post_id content_type="post",
content_id=post_id
) )
session.add(association) session.add(association)
await session.flush() await session.flush()
@@ -70,7 +70,8 @@ async def remove_post_from_entry(
association = await session.scalar( association = await session.scalar(
select(CalendarEntryPost).where( select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.post_id == post_id, CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id == post_id,
CalendarEntryPost.deleted_at.is_(None) CalendarEntryPost.deleted_at.is_(None)
) )
) )
@@ -88,20 +89,22 @@ async def remove_post_from_entry(
async def get_entry_posts( async def get_entry_posts(
session: AsyncSession, session: AsyncSession,
entry_id: int entry_id: int
) -> list[Post]: ) -> list:
""" """
Get all posts associated with a calendar entry. Get all posts (as PostDTOs) associated with a calendar entry.
""" """
result = await session.execute( result = await session.execute(
select(Post) select(CalendarEntryPost.content_id).where(
.join(CalendarEntryPost)
.where(
CalendarEntryPost.entry_id == entry_id, CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.deleted_at.is_(None) CalendarEntryPost.content_type == "post",
CalendarEntryPost.deleted_at.is_(None),
) )
.order_by(Post.title)
) )
return list(result.scalars().all()) post_ids = list(result.scalars().all())
if not post_ids:
return []
posts = await services.blog.get_posts_by_ids(session, post_ids)
return sorted(posts, key=lambda p: (p.title or ""))
async def search_posts( async def search_posts(
@@ -109,29 +112,10 @@ async def search_posts(
query: str, query: str,
page: int = 1, page: int = 1,
per_page: int = 10 per_page: int = 10
) -> tuple[list[Post], int]: ) -> tuple[list, int]:
""" """
Search for posts by title with pagination. Search for posts by title with pagination.
If query is empty, returns all posts in published order. If query is empty, returns all posts in published order.
Returns (posts, total_count). Returns (post_dtos, total_count).
""" """
# Build base query return await services.blog.search_posts(session, query, page, per_page)
if query:
# Search by title
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
# All posts in published order (newest first)
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
# Count total
count_result = await session.execute(count_stmt)
total = count_result.scalar() or 0
# Get paginated results
offset = (page - 1) * per_page
result = await session.execute(
posts_stmt.limit(per_page).offset(offset)
)
return list(result.scalars().all()), total

View File

@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
async def update_ticket_config( async def update_ticket_config(
session: AsyncSession, session: AsyncSession,
entry_id: int, entry_id: int,
@@ -82,6 +83,5 @@ async def get_available_tickets(
if entry.ticket_count is None: if entry.ticket_count is None:
return None, None return None, None
# TODO: Subtract booked tickets when ticket booking is implemented # Returns total count (booked tickets not yet subtracted)
# For now, just return the total count
return entry.ticket_count, None return entry.ticket_count, None

View File

@@ -7,16 +7,17 @@ from sqlalchemy import select
from models.calendars import Calendar from models.calendars import Calendar
from .services.calendars import ( from .services.calendars import (
create_calendar as svc_create_calendar, create_calendar as svc_create_calendar,
) )
from ..calendar.routes import register as register_calendar from ..calendar.routes import register as register_calendar
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():
@@ -78,7 +79,7 @@ def register():
cals = ( cals = (
await g.s.execute( await g.s.execute(
select(Calendar) select(Calendar)
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None)) .where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
.order_by(Calendar.name.asc()) .order_by(Calendar.name.asc())
) )
).scalars().all() ).scalars().all()

View File

@@ -4,7 +4,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar from models.calendars import Calendar
from models.ghost_content import Post # for FK existence checks from shared.services.registry import services
from shared.services.relationships import attach_child, detach_child
import unicodedata import unicodedata
import re import re
@@ -12,7 +13,7 @@ import re
class CalendarError(ValueError): class CalendarError(ValueError):
"""Base error for calendar service operations.""" """Base error for calendar service operations."""
from suma_browser.app.utils import ( from shared.browser.app.utils import (
utcnow utcnow
) )
@@ -48,12 +49,15 @@ def slugify(value: str, max_len: int = 255) -> str:
async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool: async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
if not post:
return False
cal = ( cal = (
await sess.execute( await sess.execute(
select(Calendar) select(Calendar).where(
.join(Post, Calendar.post_id == Post.id) Calendar.container_type == "page",
.where( Calendar.container_id == post.id,
Post.slug == post_slug,
Calendar.slug == calendar_slug, Calendar.slug == calendar_slug,
Calendar.deleted_at.is_(None), Calendar.deleted_at.is_(None),
) )
@@ -65,6 +69,7 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
cal.deleted_at = utcnow() cal.deleted_at = utcnow()
await sess.flush() await sess.flush()
await detach_child(sess, "page", cal.container_id, "calendar", cal.id)
return True return True
async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar: async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar:
@@ -79,7 +84,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
slug=slugify(name) slug=slugify(name)
# Ensure post exists (avoid silent FK errors in some DBs) # Ensure post exists (avoid silent FK errors in some DBs)
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none() post = await services.blog.get_post_by_id(sess, post_id)
if not post: if not post:
raise CalendarError(f"Post {post_id} does not exist.") raise CalendarError(f"Post {post_id} does not exist.")
@@ -89,7 +94,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
# Look for existing (including soft-deleted) # Look for existing (including soft-deleted)
q = await sess.execute( q = await sess.execute(
select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name) select(Calendar).where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.name == name)
) )
existing = q.scalar_one_or_none() existing = q.scalar_one_or_none()
@@ -97,12 +102,14 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
if existing.deleted_at is not None: if existing.deleted_at is not None:
existing.deleted_at = None # revive existing.deleted_at = None # revive
await sess.flush() await sess.flush()
await attach_child(sess, "page", post_id, "calendar", existing.id)
return existing return existing
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.') raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
cal = Calendar(post_id=post_id, name=name, slug=slug) cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
sess.add(cal) sess.add(cal)
await sess.flush() await sess.flush()
await attach_child(sess, "page", post_id, "calendar", cal.id)
return cal return cal

View File

@@ -5,7 +5,7 @@ from quart import (
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
def register(): def register():
@@ -15,7 +15,7 @@ def register():
@bp.get("/") @bp.get("/")
@require_admin @require_admin
async def admin(year: int, month: int, day: int, **kwargs): async def admin(year: int, month: int, day: int, **kwargs):
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Determine which template to use based on request type # Determine which template to use based on request type
if not is_htmx_request(): if not is_htmx_request():

View File

@@ -5,17 +5,19 @@ from quart import (
request, render_template, make_response, Blueprint, g, abort, session as qsession request, render_template, make_response, Blueprint, g, abort, session as qsession
) )
from suma_browser.app.bp.calendar.services import get_visible_entries_for_period from bp.calendar.services import get_visible_entries_for_period
from suma_browser.app.bp.calendar_entries.routes import register as register_calendar_entries from bp.calendar_entries.routes import register as register_calendar_entries
from .admin.routes import register as register_admin from .admin.routes import register as register_admin
from suma_browser.app.redis_cacher import cache_page from shared.browser.app.redis_cacher import cache_page
from shared.infrastructure.fragments import fetch_fragment
from models.calendars import CalendarSlot # add this import from models.calendars import CalendarSlot # add this import
from sqlalchemy import select from sqlalchemy import select
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():
@@ -76,6 +78,18 @@ def register():
result = await g.s.execute(stmt) result = await g.s.execute(stmt)
day_slots = list(result.scalars()) day_slots = list(result.scalars())
# Fetch container nav from market (skip calendar — we're on a calendar page)
container_nav_html = ""
post_data = getattr(g, "post_data", None)
if post_data:
post_id = post_data["post"]["id"]
post_slug = post_data["post"]["slug"]
container_nav_html = await fetch_fragment("market", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": post_slug,
})
return { return {
"qsession": qsession, "qsession": qsession,
"day_date": day_date, "day_date": day_date,
@@ -85,7 +99,8 @@ def register():
"day_entries": visible.merged_entries, "day_entries": visible.merged_entries,
"user_entries": visible.user_entries, "user_entries": visible.user_entries,
"confirmed_entries": visible.confirmed_entries, "confirmed_entries": visible.confirmed_entries,
"day_slots": day_slots, # <-- NEW "day_slots": day_slots,
"container_nav_html": container_nav_html,
} }
@@ -117,5 +132,23 @@ def register():
) )
return await make_response(html) return await make_response(html)
@bp.get("/w/<widget_domain>/")
async def widget_paginate(widget_domain: str, **kwargs):
"""Proxies paginated widget requests to the appropriate fragment provider."""
page = int(request.args.get("page", 1))
post_data = getattr(g, "post_data", None)
if not post_data:
abort(404)
post_id = post_data["post"]["id"]
post_slug = post_data["post"]["slug"]
if widget_domain == "market":
html = await fetch_fragment("market", "container-nav", params={
"container_type": "page",
"container_id": str(post_id),
"post_slug": post_slug,
})
return await make_response(html or "")
abort(404)
return bp return bp

1
bp/fragments/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_fragments

130
bp/fragments/routes.py Normal file
View File

@@ -0,0 +1,130 @@
"""Events app fragment endpoints.
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
by other coop apps via the fragment client.
"""
from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.services.registry import services
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
_handlers: dict[str, object] = {}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
# --- container-nav fragment: calendar entries + calendar links -----------
async def _container_nav_handler():
container_type = request.args.get("container_type", "page")
container_id = int(request.args.get("container_id", 0))
post_slug = request.args.get("post_slug", "")
paginate_url_base = request.args.get("paginate_url", "")
page = int(request.args.get("page", 1))
exclude = request.args.get("exclude", "")
excludes = [e.strip() for e in exclude.split(",") if e.strip()]
html_parts = []
# Calendar entries nav
if not any(e.startswith("calendar") for e in excludes):
entries, has_more = await services.calendar.associated_entries(
g.s, container_type, container_id, page,
)
if entries:
html_parts.append(await render_template(
"fragments/container_nav_entries.html",
entries=entries, has_more=has_more,
page=page, post_slug=post_slug,
paginate_url_base=paginate_url_base,
))
# Calendar links nav
if not any(e.startswith("calendar") for e in excludes):
calendars = await services.calendar.calendars_for_container(
g.s, container_type, container_id,
)
if calendars:
html_parts.append(await render_template(
"fragments/container_nav_calendars.html",
calendars=calendars, post_slug=post_slug,
))
return "\n".join(html_parts)
_handlers["container-nav"] = _container_nav_handler
# --- container-cards fragment: entries for blog listing cards ------------
async def _container_cards_handler():
post_ids_raw = request.args.get("post_ids", "")
post_slugs_raw = request.args.get("post_slugs", "")
post_ids = [int(x) for x in post_ids_raw.split(",") if x.strip()]
post_slugs = [x.strip() for x in post_slugs_raw.split(",") if x.strip()]
if not post_ids:
return ""
# Build post_id -> slug mapping
slug_map = {}
for i, pid in enumerate(post_ids):
slug_map[pid] = post_slugs[i] if i < len(post_slugs) else ""
batch = await services.calendar.confirmed_entries_for_posts(g.s, post_ids)
return await render_template(
"fragments/container_cards_entries.html",
batch=batch, post_ids=post_ids, slug_map=slug_map,
)
_handlers["container-cards"] = _container_cards_handler
# --- account-nav-item fragment: tickets + bookings links for account nav -
async def _account_nav_item_handler():
return await render_template("fragments/account_nav_items.html")
_handlers["account-nav-item"] = _account_nav_item_handler
# --- account-page fragment: tickets or bookings panel --------------------
async def _account_page_handler():
slug = request.args.get("slug", "")
user_id = request.args.get("user_id", type=int)
if not user_id:
return ""
if slug == "tickets":
tickets = await services.calendar.user_tickets(g.s, user_id=user_id)
return await render_template(
"fragments/account_page_tickets.html",
tickets=tickets,
)
elif slug == "bookings":
bookings = await services.calendar.user_bookings(g.s, user_id=user_id)
return await render_template(
"fragments/account_page_bookings.html",
bookings=bookings,
)
return ""
_handlers["account-page"] = _account_page_handler
bp._fragment_handlers = _handlers
return bp

View File

@@ -3,18 +3,15 @@ from __future__ import annotations
from quart import ( from quart import (
request, render_template, make_response, Blueprint, g request, render_template, make_response, Blueprint, g
) )
from sqlalchemy import select
from models.market_place import MarketPlace
from .services.markets import ( from .services.markets import (
create_market as svc_create_market, create_market as svc_create_market,
soft_delete as svc_soft_delete, soft_delete as svc_soft_delete,
) )
from suma_browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -3,12 +3,10 @@ from __future__ import annotations
import re import re
import unicodedata import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.market_place import MarketPlace from shared.contracts.dtos import MarketPlaceDTO
from models.ghost_content import Post from shared.services.registry import services
from suma_browser.app.utils import utcnow
class MarketError(ValueError): class MarketError(ValueError):
@@ -28,7 +26,7 @@ def slugify(value: str, max_len: int = 255) -> str:
return value or "market" return value or "market"
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace: async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
""" """
Create a market for a page. Name must be unique per page. Create a market for a page. Name must be unique per page.
If a market with the same (post_id, slug) exists but is soft-deleted, If a market with the same (post_id, slug) exists but is soft-deleted,
@@ -39,47 +37,21 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Market name must not be empty.") raise MarketError("Market name must not be empty.")
slug = slugify(name) slug = slugify(name)
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none() post = await services.blog.get_post_by_id(sess, post_id)
if not post: if not post:
raise MarketError(f"Post {post_id} does not exist.") raise MarketError(f"Post {post_id} does not exist.")
if not post.is_page: if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.") raise MarketError("Markets can only be created on pages, not posts.")
# Look for existing (including soft-deleted) try:
existing = (await sess.execute( return await services.market.create_marketplace(sess, "page", post_id, name, slug)
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug) except ValueError as e:
)).scalar_one_or_none() raise MarketError(str(e)) from e
if existing:
if existing.deleted_at is not None:
existing.deleted_at = None
existing.name = name
await sess.flush()
return existing
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
market = MarketPlace(post_id=post_id, name=name, slug=slug)
sess.add(market)
await sess.flush()
return market
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool: async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
market = ( post = await services.blog.get_post_by_slug(sess, post_slug)
await sess.execute( if not post:
select(MarketPlace)
.join(Post, MarketPlace.post_id == Post.id)
.where(
Post.slug == post_slug,
MarketPlace.slug == market_slug,
MarketPlace.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if not market:
return False return False
market.deleted_at = utcnow() return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
await sess.flush()
return True

0
bp/page/__init__.py Normal file
View File

129
bp/page/routes.py Normal file
View File

@@ -0,0 +1,129 @@
"""
Page summary blueprint — shows upcoming events for a single page's calendars.
Routes:
GET /<slug>/ — full page scoped to this page
GET /<slug>/entries — HTMX fragment for infinite scroll
POST /<slug>/tickets/adjust — adjust ticket quantity inline
"""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, render_template_string, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services
def register() -> Blueprint:
bp = Blueprint("page_summary", __name__)
async def _load_entries(post_id, page, per_page=20):
"""Load upcoming entries for this page + pending ticket counts."""
entries, has_more = await services.calendar.upcoming_entries_for_container(
g.s, "page", post_id, page=page, per_page=per_page,
)
# Pending ticket counts keyed by entry_id
ident = current_cart_identity()
pending_tickets = {}
if entries:
tickets = await services.calendar.pending_tickets(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
for t in tickets:
if t.entry_id is not None:
pending_tickets[t.entry_id] = pending_tickets.get(t.entry_id, 0) + 1
return entries, has_more, pending_tickets
@bp.get("/")
async def index():
post = g.post_data["post"]
view = request.args.get("view", "list")
page = int(request.args.get("page", 1))
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
ctx = dict(
entries=entries,
has_more=has_more,
pending_tickets=pending_tickets,
page_info={},
page=page,
view=view,
)
if is_htmx_request():
html = await render_template("_types/page_summary/_main_panel.html", **ctx)
else:
html = await render_template("_types/page_summary/index.html", **ctx)
return await make_response(html, 200)
@bp.get("/entries")
async def entries_fragment():
post = g.post_data["post"]
view = request.args.get("view", "list")
page = int(request.args.get("page", 1))
entries, has_more, pending_tickets = await _load_entries(post["id"], page)
html = await render_template(
"_types/page_summary/_cards.html",
entries=entries,
has_more=has_more,
pending_tickets=pending_tickets,
page_info={},
page=page,
view=view,
)
return await make_response(html, 200)
@bp.post("/tickets/adjust")
async def adjust_ticket():
"""Adjust ticket quantity, return updated widget + OOB cart-mini."""
ident = current_cart_identity()
form = await request.form
entry_id = int(form.get("entry_id", 0))
count = max(int(form.get("count", 0)), 0)
tt_raw = (form.get("ticket_type_id") or "").strip()
ticket_type_id = int(tt_raw) if tt_raw else None
await services.calendar.adjust_ticket_quantity(
g.s, entry_id, count,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
# Get updated ticket count for this entry
tickets = await services.calendar.pending_tickets(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
qty = sum(1 for t in tickets if t.entry_id == entry_id)
# Load entry DTO for the widget template
entry = await services.calendar.entry_by_id(g.s, entry_id)
# Updated cart count for OOB mini-cart
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
cart_count = summary.count + summary.calendar_count + summary.ticket_count
# Render widget + OOB cart-mini
widget_html = await render_template(
"_types/page_summary/_ticket_widget.html",
entry=entry,
qty=qty,
ticket_url=f"/{g.post_slug}/tickets/adjust",
)
mini_html = await render_template_string(
'{% from "_types/cart/_mini.html" import mini with context %}'
'{{ mini(oob="true") }}',
cart_count=cart_count,
)
return await make_response(widget_html + mini_html, 200)
return bp

View File

@@ -5,10 +5,10 @@ from quart import (
) )
from sqlalchemy import select from sqlalchemy import select
from models.page_config import PageConfig from shared.models.page_config import PageConfig
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():
@@ -26,7 +26,7 @@ def register():
return {} return {}
pc = (await g.s.execute( pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id) select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none() )).scalar_one_or_none()
return { return {
@@ -55,10 +55,10 @@ def register():
return await make_response("Post not found", 404) return await make_response("Post not found", 404)
pc = (await g.s.execute( pc = (await g.s.execute(
select(PageConfig).where(PageConfig.post_id == post_id) select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
)).scalar_one_or_none() )).scalar_one_or_none()
if pc is None: if pc is None:
pc = PageConfig(post_id=post_id, features={}) pc = PageConfig(container_type="page", container_id=post_id, features={})
g.s.add(pc) g.s.add(pc)
await g.s.flush() await g.s.flush()

View File

@@ -6,8 +6,8 @@ from quart import (
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.slot import ( from .services.slot import (
update_slot as svc_update_slot, update_slot as svc_update_slot,
@@ -19,11 +19,11 @@ from ..slots.services.slots import (
list_slots as svc_list_slots, list_slots as svc_list_slots,
) )
from suma_browser.app.utils import ( from shared.browser.app.utils import (
parse_time, parse_time,
parse_cost parse_cost
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
class SlotError(ValueError): class SlotError(ValueError):
pass pass

View File

@@ -5,8 +5,8 @@ from quart import (
) )
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.slots import ( from .services.slots import (
list_slots as svc_list_slots, list_slots as svc_list_slots,
@@ -15,11 +15,11 @@ from .services.slots import (
from ..slot.routes import register as register_slot from ..slot.routes import register as register_slot
from suma_browser.app.utils import ( from shared.browser.app.utils import (
parse_time, parse_time,
parse_cost parse_cost
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import CalendarSlot from models.calendars import CalendarSlot
class SlotError(ValueError): class SlotError(ValueError):
pass pass

View File

@@ -18,8 +18,8 @@ from sqlalchemy import select, func
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Ticket, TicketType from models.calendars import CalendarEntry, Ticket, TicketType
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from ..tickets.services.tickets import ( from ..tickets.services.tickets import (
get_ticket_by_code, get_ticket_by_code,
@@ -37,7 +37,7 @@ def register() -> Blueprint:
@require_admin @require_admin
async def dashboard(): async def dashboard():
"""Ticket admin dashboard with QR scanner and recent tickets.""" """Ticket admin dashboard with QR scanner and recent tickets."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
# Get recent tickets # Get recent tickets
result = await g.s.execute( result = await g.s.execute(
@@ -89,7 +89,7 @@ def register() -> Blueprint:
@require_admin @require_admin
async def entry_tickets(entry_id: int): async def entry_tickets(entry_id: int):
"""List all tickets for a specific calendar entry.""" """List all tickets for a specific calendar entry."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
entry = await g.s.scalar( entry = await g.s.scalar(
select(CalendarEntry) select(CalendarEntry)

View File

@@ -4,8 +4,8 @@ from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, render_template, make_response, Blueprint, g, jsonify
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.ticket import ( from .services.ticket import (
get_ticket_type as svc_get_ticket_type, get_ticket_type as svc_get_ticket_type,
@@ -16,7 +16,7 @@ from .services.ticket import (
from ..ticket_types.services.tickets import ( from ..ticket_types.services.tickets import (
list_ticket_types as svc_list_ticket_types, list_ticket_types as svc_list_ticket_types,
) )
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -4,6 +4,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import TicketType from models.calendars import TicketType
from datetime import datetime, timezone from datetime import datetime, timezone

View File

@@ -4,8 +4,8 @@ from quart import (
request, render_template, make_response, Blueprint, g, jsonify request, render_template, make_response, Blueprint, g, jsonify
) )
from suma_browser.app.authz import require_admin from shared.browser.app.authz import require_admin
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.tickets import ( from .services.tickets import (
list_ticket_types as svc_list_ticket_types, list_ticket_types as svc_list_ticket_types,
@@ -14,7 +14,7 @@ from .services.tickets import (
from ..ticket_type.routes import register as register_ticket_type from ..ticket_type.routes import register as register_ticket_type
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
def register(): def register():

View File

@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import TicketType from models.calendars import TicketType
from datetime import datetime, timezone from datetime import datetime, timezone

View File

@@ -5,6 +5,7 @@ Routes:
GET /tickets/ — My tickets list GET /tickets/ — My tickets list
GET /tickets/<code>/ — Ticket detail with QR code GET /tickets/<code>/ — Ticket detail with QR code
POST /tickets/buy/ — Purchase tickets for an entry POST /tickets/buy/ — Purchase tickets for an entry
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
""" """
from __future__ import annotations from __future__ import annotations
@@ -17,8 +18,8 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry from models.calendars import CalendarEntry
from shared.cart_identity import current_cart_identity from shared.infrastructure.cart_identity import current_cart_identity
from suma_browser.app.redis_cacher import clear_cache from shared.browser.app.redis_cacher import clear_cache
from .services.tickets import ( from .services.tickets import (
create_ticket, create_ticket,
@@ -26,6 +27,9 @@ from .services.tickets import (
get_user_tickets, get_user_tickets,
get_available_ticket_count, get_available_ticket_count,
get_tickets_for_entry, get_tickets_for_entry,
get_sold_ticket_count,
get_user_reserved_count,
cancel_latest_reserved_ticket,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,7 +41,7 @@ def register() -> Blueprint:
@bp.get("/") @bp.get("/")
async def my_tickets(): async def my_tickets():
"""List all tickets for the current user/session.""" """List all tickets for the current user/session."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
ident = current_cart_identity() ident = current_cart_identity()
tickets = await get_user_tickets( tickets = await get_user_tickets(
@@ -62,7 +66,7 @@ def register() -> Blueprint:
@bp.get("/<code>/") @bp.get("/<code>/")
async def ticket_detail(code: str): async def ticket_detail(code: str):
"""View a single ticket with QR code.""" """View a single ticket with QR code."""
from suma_browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
ticket = await get_ticket_by_code(g.s, code) ticket = await get_ticket_by_code(g.s, code)
if not ticket: if not ticket:
@@ -178,4 +182,127 @@ def register() -> Blueprint:
) )
return await make_response(html, 200) return await make_response(html, 200)
@bp.post("/adjust/")
@clear_cache(tag="calendars", tag_scope="all")
async def adjust_quantity():
"""
Adjust ticket quantity for a calendar entry (+/- pattern).
Creates or cancels tickets to reach the target count.
Form fields:
entry_id — the calendar entry ID
ticket_type_id — (optional) specific ticket type
count — target quantity of reserved tickets
"""
form = await request.form
entry_id_raw = form.get("entry_id", "").strip()
if not entry_id_raw:
return await make_response("Entry ID required", 400)
try:
entry_id = int(entry_id_raw)
except ValueError:
return await make_response("Invalid entry ID", 400)
# Load entry
entry = await g.s.scalar(
select(CalendarEntry)
.where(
CalendarEntry.id == entry_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.ticket_types))
)
if not entry:
return await make_response("Entry not found", 404)
if entry.ticket_price is None:
return await make_response("Tickets not available for this entry", 400)
# Ticket type (optional)
ticket_type_id = None
tt_raw = form.get("ticket_type_id", "").strip()
if tt_raw:
try:
ticket_type_id = int(tt_raw)
except ValueError:
pass
target = max(int(form.get("count", 0)), 0)
ident = current_cart_identity()
current = await get_user_reserved_count(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
if target > current:
# Need to add tickets
to_add = target - current
available = await get_available_ticket_count(g.s, entry_id)
if available is not None and to_add > available:
return await make_response(
f"Only {available} ticket(s) remaining", 400
)
for _ in range(to_add):
await create_ticket(
g.s,
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
state="reserved",
)
elif target < current:
# Need to remove tickets
to_remove = current - target
for _ in range(to_remove):
await cancel_latest_reserved_ticket(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
# Build context for re-rendering the buy form
ticket_remaining = await get_available_ticket_count(g.s, entry_id)
ticket_sold_count = await get_sold_ticket_count(g.s, entry_id)
user_ticket_count = await get_user_reserved_count(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
# Per-type counts for multi-type entries
user_ticket_counts_by_type = {}
if entry.ticket_types:
for tt in entry.ticket_types:
if tt.deleted_at is None:
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
g.s, entry_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=tt.id,
)
# Compute cart count for OOB mini-cart update
from shared.services.registry import services
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
cart_count = summary.count + summary.calendar_count + summary.ticket_count
html = await render_template(
"_types/tickets/_adjust_response.html",
entry=entry,
ticket_remaining=ticket_remaining,
ticket_sold_count=ticket_sold_count,
user_ticket_count=user_ticket_count,
user_ticket_counts_by_type=user_ticket_counts_by_type,
cart_count=cart_count,
)
return await make_response(html, 200)
return bp return bp

View File

@@ -14,6 +14,7 @@ from sqlalchemy.orm import selectinload
from models.calendars import Ticket, TicketType, CalendarEntry from models.calendars import Ticket, TicketType, CalendarEntry
async def create_ticket( async def create_ticket(
session: AsyncSession, session: AsyncSession,
*, *,
@@ -181,6 +182,80 @@ async def get_tickets_for_entry(
return result.scalars().all() return result.scalars().all()
async def get_sold_ticket_count(
session: AsyncSession,
entry_id: int,
) -> int:
"""Count all non-cancelled tickets for an entry (total sold/reserved)."""
result = await session.scalar(
select(func.count(Ticket.id)).where(
Ticket.entry_id == entry_id,
Ticket.state != "cancelled",
)
)
return result or 0
async def get_user_reserved_count(
session: AsyncSession,
entry_id: int,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
ticket_type_id: Optional[int] = None,
) -> int:
"""Count reserved tickets for a specific user/session + entry + optional type."""
filters = [
Ticket.entry_id == entry_id,
Ticket.state == "reserved",
]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return 0
if ticket_type_id is not None:
filters.append(Ticket.ticket_type_id == ticket_type_id)
result = await session.scalar(
select(func.count(Ticket.id)).where(*filters)
)
return result or 0
async def cancel_latest_reserved_ticket(
session: AsyncSession,
entry_id: int,
user_id: Optional[int] = None,
session_id: Optional[str] = None,
ticket_type_id: Optional[int] = None,
) -> bool:
"""Cancel the most recently created reserved ticket. Returns True if one was cancelled."""
filters = [
Ticket.entry_id == entry_id,
Ticket.state == "reserved",
]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return False
if ticket_type_id is not None:
filters.append(Ticket.ticket_type_id == ticket_type_id)
ticket = await session.scalar(
select(Ticket)
.where(*filters)
.order_by(Ticket.created_at.desc())
.limit(1)
)
if ticket:
ticket.state = "cancelled"
await session.flush()
return True
return False
async def get_available_ticket_count( async def get_available_ticket_count(
session: AsyncSession, session: AsyncSession,
entry_id: int, entry_id: int,

84
config/app-config.yaml Normal file
View File

@@ -0,0 +1,84 @@
# App-wide settings
base_host: "wholesale.suma.coop"
base_login: https://wholesale.suma.coop/customer/account/login/
base_url: https://wholesale.suma.coop/
title: Rose Ash
market_root: /market
market_title: Market
blog_root: /
blog_title: all the news
cart_root: /cart
app_urls:
blog: "http://localhost:8000"
market: "http://localhost:8001"
cart: "http://localhost:8002"
events: "http://localhost:8003"
federation: "http://localhost:8004"
cache:
fs_root: _snapshot # <- absolute path to your snapshot dir
categories:
allow:
Basics: basics
Branded Goods: branded-goods
Chilled: chilled
Frozen: frozen
Non-foods: non-foods
Supplements: supplements
Christmas: christmas
slugs:
skip:
- ""
- customer
- account
- checkout
- wishlist
- sales
- contact
- privacy-policy
- terms-and-conditions
- delivery
- catalogsearch
- quickorder
- apply
- search
- static
- media
section-titles:
- ingredients
- allergy information
- allergens
- nutritional information
- nutrition
- storage
- directions
- preparation
- serving suggestions
- origin
- country of origin
- recycling
- general information
- additional information
- a note about prices
blacklist:
category:
- branded-goods/alcoholic-drinks
- branded-goods/beers
- branded-goods/wines
- branded-goods/ciders
product:
- list-price-suma-current-suma-price-list-each-bk012-2-html
- ---just-lem-just-wholefoods-jelly-crystals-lemon-12-x-85g-vf067-2-html
product-details:
- General Information
- A Note About Prices
# SumUp payment settings (fill these in for live usage)
sumup:
merchant_code: "ME4J6100"
currency: "GBP"
# Name of the environment variable that holds your SumUp API key
api_key_env: "SUMUP_API_KEY"
webhook_secret: "CHANGE_ME_TO_A_LONG_RANDOM_STRING"
checkout_reference_prefix: 'dev-'

View File

@@ -1,198 +0,0 @@
"""
Internal JSON API for the events app.
These endpoints are called by other apps (cart) over HTTP.
They are CSRF-exempt because they are server-to-server calls.
"""
from __future__ import annotations
from quart import Blueprint, g, request, jsonify
from sqlalchemy import select, update, func
from sqlalchemy.orm import selectinload
from models.calendars import CalendarEntry, Calendar, Ticket
from suma_browser.app.csrf import csrf_exempt
def register() -> Blueprint:
bp = Blueprint("events_api", __name__, url_prefix="/internal/events")
@bp.get("/calendar-entries")
@csrf_exempt
async def calendar_entries():
"""
Return pending calendar entries for a user/session.
Used by the cart app to display calendar items in the cart.
Query params: user_id, session_id, state (default: pending)
"""
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
state = request.args.get("state", "pending")
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == state,
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id:
filters.append(CalendarEntry.session_id == session_id)
else:
return jsonify([])
result = await g.s.execute(
select(CalendarEntry)
.where(*filters)
.options(selectinload(CalendarEntry.calendar))
.order_by(CalendarEntry.start_at.asc())
)
entries = result.scalars().all()
return jsonify([
{
"id": e.id,
"name": e.name,
"cost": float(e.cost) if e.cost else 0,
"state": e.state,
"start_at": e.start_at.isoformat() if e.start_at else None,
"end_at": e.end_at.isoformat() if e.end_at else None,
"calendar_name": e.calendar.name if e.calendar else None,
"calendar_slug": e.calendar.slug if e.calendar else None,
}
for e in entries
])
@bp.post("/adopt")
@csrf_exempt
async def adopt():
"""
Adopt anonymous calendar entries for a user.
Called by the cart app after login.
Body: {"user_id": int, "session_id": str}
"""
data = await request.get_json() or {}
user_id = data.get("user_id")
session_id = data.get("session_id")
if not user_id or not session_id:
return jsonify({"ok": False, "error": "user_id and session_id required"}), 400
# Soft-delete existing user entries
await g.s.execute(
update(CalendarEntry)
.where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.user_id == user_id,
)
.values(deleted_at=func.now())
)
# Adopt anonymous entries
cal_result = await g.s.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
return jsonify({"ok": True})
@bp.get("/entry/<int:entry_id>")
@csrf_exempt
async def entry_detail(entry_id: int):
"""
Return entry details for order display.
Called by the cart app when showing order items.
"""
result = await g.s.execute(
select(CalendarEntry)
.where(CalendarEntry.id == entry_id)
.options(selectinload(CalendarEntry.calendar))
)
entry = result.scalar_one_or_none()
if not entry:
return jsonify(None), 404
return jsonify({
"id": entry.id,
"name": entry.name,
"cost": float(entry.cost) if entry.cost else 0,
"state": entry.state,
"start_at": entry.start_at.isoformat() if entry.start_at else None,
"end_at": entry.end_at.isoformat() if entry.end_at else None,
"calendar_name": entry.calendar.name if entry.calendar else None,
"calendar_slug": entry.calendar.slug if entry.calendar else None,
})
@bp.get("/tickets")
@csrf_exempt
async def tickets():
"""
Return tickets for a user/session.
Query params: user_id, session_id, order_id, state
"""
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
order_id = request.args.get("order_id", type=int)
state = request.args.get("state")
filters = []
if order_id is not None:
filters.append(Ticket.order_id == order_id)
elif user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id:
filters.append(Ticket.session_id == session_id)
else:
return jsonify([])
if state:
filters.append(Ticket.state == state)
result = await g.s.execute(
select(Ticket)
.where(*filters)
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
.order_by(Ticket.created_at.desc())
)
tix = result.scalars().all()
return jsonify([
{
"id": t.id,
"code": t.code,
"state": t.state,
"entry_name": t.entry.name if t.entry else None,
"entry_start_at": t.entry.start_at.isoformat() if t.entry and t.entry.start_at else None,
"calendar_name": t.entry.calendar.name if t.entry and t.entry.calendar else None,
"ticket_type_name": t.ticket_type.name if t.ticket_type else None,
"ticket_type_cost": float(t.ticket_type.cost) if t.ticket_type and t.ticket_type.cost else None,
"checked_in_at": t.checked_in_at.isoformat() if t.checked_in_at else None,
}
for t in tix
])
@bp.post("/tickets/<code>/checkin")
@csrf_exempt
async def checkin(code: str):
"""
Check in a ticket by code.
Used by admin check-in interface.
"""
from .bp.tickets.services.tickets import checkin_ticket
success, error = await checkin_ticket(g.s, code)
if not success:
return jsonify({"ok": False, "error": error}), 400
return jsonify({"ok": True})
return bp

4
models/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .calendars import (
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

4
models/calendars.py Normal file
View File

@@ -0,0 +1,4 @@
from shared.models.calendars import ( # noqa: F401
Calendar, CalendarEntry, CalendarSlot,
TicketType, Ticket, CalendarEntryPost,
)

View File

@@ -1,7 +1,9 @@
import sys import sys
import os import os
# Add the shared library submodule to the Python path _app_dir = os.path.dirname(os.path.abspath(__file__))
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib") _project_root = os.path.dirname(_app_dir)
if _shared not in sys.path:
sys.path.insert(0, _shared) for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

29
services/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
"""Events app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the events app.
Events owns: Calendar, CalendarEntry, CalendarSlot, TicketType,
Ticket, CalendarEntryPost.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.calendar = SqlCalendarService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

1
shared Submodule

Submodule shared added at 9ab4b7b3fe

Submodule shared_lib deleted from 0c9b8d6aa2

View File

@@ -0,0 +1,62 @@
{# List card for all events — one entry #}
{% set pi = page_info.get(entry.calendar_container_id, {}) %}
{% set page_slug = pi.get('slug', '') %}
{% set page_title = pi.get('title') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-4">
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
{# Left: event info #}
<div class="flex-1 min-w-0">
{% if page_slug %}
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% else %}
{% set day_href = '' %}
{% endif %}
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' if day_href else '' %}
{% if entry_href %}
<a href="{{ entry_href }}" class="hover:text-emerald-700">
<h2 class="text-lg font-semibold text-stone-900">{{ entry.name }}</h2>
</a>
{% else %}
<h2 class="text-lg font-semibold text-stone-900">{{ entry.name }}</h2>
{% endif %}
<div class="flex flex-wrap items-center gap-1.5 mt-1">
{% if page_title %}
<a href="{{ events_url('/' ~ page_slug ~ '/') }}"
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
{{ page_title }}
</a>
{% endif %}
{% if entry.calendar_name %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">
{{ entry.calendar_name }}
</span>
{% endif %}
</div>
<div class="mt-1 text-sm text-stone-500">
{% if day_href %}
<a href="{{ day_href }}" class="hover:text-stone-700">{{ entry.start_at.strftime('%a %-d %b') }}</a> &middot;
{% else %}
{{ entry.start_at.strftime('%a %-d %b') }} &middot;
{% endif %}
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} &ndash; {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
{% if entry.cost %}
<div class="mt-1 text-sm font-medium text-green-600">
&pound;{{ '%.2f'|format(entry.cost) }}
</div>
{% endif %}
</div>
{# Right: ticket widget #}
{% if entry.ticket_price is not none %}
<div class="shrink-0">
{% set qty = pending_tickets.get(entry.id, 0) %}
{% set ticket_url = url_for('all_events.adjust_ticket') %}
{% include '_types/page_summary/_ticket_widget.html' %}
</div>
{% endif %}
</div>
</article>

View File

@@ -0,0 +1,60 @@
{# Tile card for all events — compact event tile #}
{% set pi = page_info.get(entry.calendar_container_id, {}) %}
{% set page_slug = pi.get('slug', '') %}
{% set page_title = pi.get('title') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden">
{% if page_slug %}
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% else %}
{% set day_href = '' %}
{% endif %}
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' if day_href else '' %}
<div class="p-3">
{% if entry_href %}
<a href="{{ entry_href }}" class="hover:text-emerald-700">
<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{{ entry.name }}</h2>
</a>
{% else %}
<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{{ entry.name }}</h2>
{% endif %}
<div class="flex flex-wrap items-center gap-1 mt-1">
{% if page_title %}
<a href="{{ events_url('/' ~ page_slug ~ '/') }}"
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
{{ page_title }}
</a>
{% endif %}
{% if entry.calendar_name %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">
{{ entry.calendar_name }}
</span>
{% endif %}
</div>
<div class="mt-1 text-xs text-stone-500">
{% if day_href %}
<a href="{{ day_href }}" class="hover:text-stone-700">{{ entry.start_at.strftime('%a %-d %b') }}</a>
{% else %}
{{ entry.start_at.strftime('%a %-d %b') }}
{% endif %}
&middot;
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} &ndash; {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
{% if entry.cost %}
<div class="mt-1 text-sm font-medium text-green-600">
&pound;{{ '%.2f'|format(entry.cost) }}
</div>
{% endif %}
</div>
{# Ticket widget below card #}
{% if entry.ticket_price is not none %}
<div class="border-t border-stone-100 px-3 py-2">
{% set qty = pending_tickets.get(entry.id, 0) %}
{% set ticket_url = url_for('all_events.adjust_ticket') %}
{% include '_types/page_summary/_ticket_widget.html' %}
</div>
{% endif %}
</article>

View File

@@ -0,0 +1,31 @@
{% for entry in entries %}
{% if view == 'tile' %}
{% include "_types/all_events/_card_tile.html" %}
{% else %}
{# Date header when date changes (list view only) #}
{% set entry_date = entry.start_at.strftime('%A %-d %B %Y') %}
{% if loop.first or entry_date != entries[loop.index0 - 1].start_at.strftime('%A %-d %B %Y') %}
<div class="pt-2 pb-1">
<h3 class="text-sm font-semibold text-stone-500 uppercase tracking-wide">
{{ entry_date }}
</h3>
</div>
{% endif %}
{% include "_types/all_events/_card.html" %}
{% endif %}
{% endfor %}
{% if has_more %}
{# Infinite scroll sentinel #}
{% set entries_url = url_for('all_events.entries_fragment', page=page + 1, view=view if view != 'list' else '')|host %}
<div
id="sentinel-{{ page }}"
class="h-4 opacity-0 pointer-events-none"
hx-get="{{ entries_url }}"
hx-trigger="intersect once delay:250ms"
hx-swap="outerHTML"
role="status"
aria-hidden="true"
>
<div class="text-center text-xs text-stone-400">loading...</div>
</div>
{% endif %}

View File

@@ -0,0 +1,54 @@
{# View toggle bar - desktop only #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('events_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('events_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if entries %}
{% if view == 'tile' %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/all_events/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/all_events/_cards.html" %}
</div>
{% endif %}
{% else %}
<div class="px-3 py-12 text-center text-stone-400">
<i class="fa fa-calendar-xmark text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg">No upcoming events</p>
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -0,0 +1,7 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block content %}
{% include '_types/all_events/_main_panel.html' %}
{% endblock %}

View File

@@ -6,7 +6,7 @@
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %} {% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a <a
href="{{ coop_url('/' + entry_post.slug + '/') }}" href="{{ blog_url('/' + entry_post.slug + '/') }}"
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0"> class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
{% if entry_post.feature_image %} {% if entry_post.feature_image %}
<img src="{{ entry_post.feature_image }}" <img src="{{ entry_post.feature_image }}"

View File

@@ -9,7 +9,7 @@
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %} {% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %} {% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
<a <a
href="{{ coop_url('/' + entry_post.slug + '/') }}" href="{{ blog_url('/' + entry_post.slug + '/') }}"
class="{{styles.nav_button}}" class="{{styles.nav_button}}"
> >
{% if entry_post.feature_image %} {% if entry_post.feature_image %}

View File

@@ -0,0 +1,49 @@
{# List card for page summary — one entry #}
{% set pi = page_info.get(entry.calendar_container_id, {}) %}
{% set page_slug = pi.get('slug', post.slug) %}
{% set page_title = pi.get('title') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 p-4">
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
{# Left: event info #}
<div class="flex-1 min-w-0">
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' %}
<a href="{{ entry_href }}" class="hover:text-emerald-700">
<h2 class="text-lg font-semibold text-stone-900">{{ entry.name }}</h2>
</a>
<div class="flex flex-wrap items-center gap-1.5 mt-1">
{% if page_title and page_title != post.title %}
<a href="{{ events_url('/' ~ page_slug ~ '/') }}"
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
{{ page_title }}
</a>
{% endif %}
{% if entry.calendar_name %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">
{{ entry.calendar_name }}
</span>
{% endif %}
</div>
<div class="mt-1 text-sm text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} &ndash; {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
{% if entry.cost %}
<div class="mt-1 text-sm font-medium text-green-600">
&pound;{{ '%.2f'|format(entry.cost) }}
</div>
{% endif %}
</div>
{# Right: ticket widget #}
{% if entry.ticket_price is not none %}
<div class="shrink-0">
{% set qty = pending_tickets.get(entry.id, 0) %}
{% set ticket_url = url_for('page_summary.adjust_ticket') %}
{% include '_types/page_summary/_ticket_widget.html' %}
</div>
{% endif %}
</div>
</article>

View File

@@ -0,0 +1,48 @@
{# Tile card for page summary — compact event tile #}
{% set pi = page_info.get(entry.calendar_container_id, {}) %}
{% set page_slug = pi.get('slug', post.slug) %}
{% set page_title = pi.get('title') %}
<article class="rounded-xl bg-white shadow-sm border border-stone-200 overflow-hidden">
{% set day_href = events_url('/' ~ page_slug ~ '/calendars/' ~ entry.calendar_slug ~ '/day/' ~ entry.start_at.strftime('%Y/%-m/%-d') ~ '/') %}
{% set entry_href = day_href ~ 'entries/' ~ entry.id ~ '/' %}
<div class="p-3">
<a href="{{ entry_href }}" class="hover:text-emerald-700">
<h2 class="text-base font-semibold text-stone-900 line-clamp-2">{{ entry.name }}</h2>
</a>
<div class="flex flex-wrap items-center gap-1 mt-1">
{% if page_title and page_title != post.title %}
<a href="{{ events_url('/' ~ page_slug ~ '/') }}"
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 hover:bg-amber-200">
{{ page_title }}
</a>
{% endif %}
{% if entry.calendar_name %}
<span class="inline-block px-2 py-0.5 rounded-full text-xs font-medium bg-sky-100 text-sky-700">
{{ entry.calendar_name }}
</span>
{% endif %}
</div>
<div class="mt-1 text-xs text-stone-500">
<a href="{{ day_href }}" class="hover:text-stone-700">{{ entry.start_at.strftime('%a %-d %b') }}</a>
&middot;
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} &ndash; {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
{% if entry.cost %}
<div class="mt-1 text-sm font-medium text-green-600">
&pound;{{ '%.2f'|format(entry.cost) }}
</div>
{% endif %}
</div>
{# Ticket widget below card #}
{% if entry.ticket_price is not none %}
<div class="border-t border-stone-100 px-3 py-2">
{% set qty = pending_tickets.get(entry.id, 0) %}
{% set ticket_url = url_for('page_summary.adjust_ticket') %}
{% include '_types/page_summary/_ticket_widget.html' %}
</div>
{% endif %}
</article>

View File

@@ -0,0 +1,31 @@
{% for entry in entries %}
{% if view == 'tile' %}
{% include "_types/page_summary/_card_tile.html" %}
{% else %}
{# Date header when date changes (list view only) #}
{% set entry_date = entry.start_at.strftime('%A %-d %B %Y') %}
{% if loop.first or entry_date != entries[loop.index0 - 1].start_at.strftime('%A %-d %B %Y') %}
<div class="pt-2 pb-1">
<h3 class="text-sm font-semibold text-stone-500 uppercase tracking-wide">
{{ entry_date }}
</h3>
</div>
{% endif %}
{% include "_types/page_summary/_card.html" %}
{% endif %}
{% endfor %}
{% if has_more %}
{# Infinite scroll sentinel #}
{% set entries_url = url_for('page_summary.entries_fragment', page=page + 1, view=view if view != 'list' else '')|host %}
<div
id="sentinel-{{ page }}"
class="h-4 opacity-0 pointer-events-none"
hx-get="{{ entries_url }}"
hx-trigger="intersect once delay:250ms"
hx-swap="outerHTML"
role="status"
aria-hidden="true"
>
<div class="text-center text-xs text-stone-400">loading...</div>
</div>
{% endif %}

View File

@@ -0,0 +1,54 @@
{# View toggle bar - desktop only #}
<div class="hidden md:flex justify-end px-3 pt-3 gap-1">
{% set list_href = (current_local_href ~ {'view': None}|qs)|host %}
{% set tile_href = (current_local_href ~ {'view': 'tile'}|qs)|host %}
<a
href="{{ list_href }}"
hx-get="{{ list_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view != 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="List view"
_="on click js localStorage.removeItem('events_view') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</a>
<a
href="{{ tile_href }}"
hx-get="{{ tile_href }}"
hx-target="#main-panel"
hx-select="{{hx_select_search}}"
hx-swap="outerHTML"
hx-push-url="true"
class="p-1.5 rounded {{ 'bg-stone-200 text-stone-800' if view == 'tile' else 'text-stone-400 hover:text-stone-600' }}"
title="Tile view"
_="on click js localStorage.setItem('events_view','tile') end"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zM14 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
</svg>
</a>
</div>
{# Cards container - list or grid based on view #}
{% if entries %}
{% if view == 'tile' %}
<div class="max-w-full px-3 py-3 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{% include "_types/page_summary/_cards.html" %}
</div>
{% else %}
<div class="max-w-full px-3 py-3 space-y-3">
{% include "_types/page_summary/_cards.html" %}
</div>
{% endif %}
{% else %}
<div class="px-3 py-12 text-center text-stone-400">
<i class="fa fa-calendar-xmark text-4xl mb-3" aria-hidden="true"></i>
<p class="text-lg">No upcoming events</p>
</div>
{% endif %}
<div class="pb-8"></div>

View File

@@ -0,0 +1,63 @@
{# Inline ticket +/- widget for page summary cards.
Variables: entry, qty, ticket_url
Wrapped in a div with stable ID for HTMX targeting. #}
<div id="page-ticket-{{ entry.id }}" class="flex items-center gap-2">
<span class="text-green-600 font-medium text-sm">&pound;{{ '%.2f'|format(entry.ticket_price) }}</span>
{% if qty == 0 %}
<form
action="{{ ticket_url }}"
method="post"
hx-post="{{ ticket_url }}"
hx-target="#page-ticket-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ entry.id }}">
<input type="hidden" name="count" value="1">
<button
type="submit"
class="relative inline-flex items-center justify-center text-stone-500 hover:bg-emerald-50 rounded p-1"
>
<i class="fa fa-cart-plus text-2xl" aria-hidden="true"></i>
</button>
</form>
{% else %}
<form
action="{{ ticket_url }}"
method="post"
hx-post="{{ ticket_url }}"
hx-target="#page-ticket-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ entry.id }}">
<input type="hidden" name="count" value="{{ qty - 1 }}">
<button type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">-</button>
</form>
<a class="relative inline-flex items-center justify-center text-emerald-700" href="{{ cart_url('/') }}">
<span class="relative inline-flex items-center justify-center">
<i class="fa-solid fa-shopping-cart text-xl" aria-hidden="true"></i>
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">{{ qty }}</span>
</span>
</span>
</a>
<form
action="{{ ticket_url }}"
method="post"
hx-post="{{ ticket_url }}"
hx-target="#page-ticket-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="entry_id" value="{{ entry.id }}">
<input type="hidden" name="count" value="{{ qty + 1 }}">
<button type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl">+</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,15 @@
{% extends '_types/root/_index.html' %}
{% block meta %}{% endblock %}
{% block root_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-header-child', '_types/post/header/_header.html') %}
{% block post_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block content %}
{% include '_types/page_summary/_main_panel.html' %}
{% endblock %}

View File

@@ -8,7 +8,7 @@
{% endfor %} {% endfor %}
{% endif %} {% endif %}
{% if g.rights.admin %} {% if g.rights.admin %}
<a href="{{ coop_url('/' + post.slug + '/admin/') }}" class="{{styles.nav_button}}"> <a href="{{ blog_url('/' + post.slug + '/admin/') }}" class="{{styles.nav_button}}">
<i class="fa fa-cog" aria-hidden="true"></i> <i class="fa fa-cog" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@@ -0,0 +1,50 @@
<div id="associated-entries-list" class="border rounded-lg p-4 bg-white">
<h3 class="text-lg font-semibold mb-4">Associated Entries</h3>
{% if associated_entry_ids %}
<div class="space-y-1">
{% for calendar in all_calendars %}
{% for entry in calendar.entries %}
{% if entry.id in associated_entry_ids and entry.deleted_at is none %}
<button
type="button"
class="w-full text-left p-3 rounded border bg-green-50 border-green-300 transition hover:bg-green-100"
data-confirm
data-confirm-title="Remove entry?"
data-confirm-text="This will remove {{ entry.name }} from this post"
data-confirm-icon="warning"
data-confirm-confirm-text="Yes, remove it"
data-confirm-cancel-text="Cancel"
data-confirm-event="confirmed"
hx-post="{{ url_for('blog.post.admin.toggle_entry', slug=post.slug, entry_id=entry.id) }}"
hx-trigger="confirmed"
hx-target="#associated-entries-list"
hx-swap="outerHTML"
hx-headers='{"X-CSRFToken": "{{ csrf_token() }}"}'
_="on htmx:afterRequest trigger entryToggled on body"
>
<div class="flex items-center justify-between gap-3">
{% if calendar.post.feature_image %}
<img src="{{ calendar.post.feature_image }}"
alt="{{ calendar.post.title }}"
class="w-8 h-8 rounded-full object-cover flex-shrink-0" />
{% else %}
<div class="w-8 h-8 rounded-full bg-stone-200 flex-shrink-0"></div>
{% endif %}
<div class="flex-1">
<div class="font-medium text-sm">{{ entry.name }}</div>
<div class="text-xs text-stone-600 mt-1">
{{ calendar.name }} • {{ entry.start_at.strftime('%A, %B %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
<i class="fa fa-times-circle text-green-600 text-lg flex-shrink-0"></i>
</div>
</button>
{% endif %}
{% endfor %}
{% endfor %}
</div>
{% else %}
<div class="text-sm text-stone-400">No entries associated yet. Browse calendars below to add entries.</div>
{% endif %}
</div>

View File

@@ -15,22 +15,22 @@
</a> </a>
</div> </div>
<div class="relative nav-group"> <div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/entries/') }}" class="{{styles.nav_button}}"> <a href="{{ blog_url('/' + post.slug + '/admin/entries/') }}" class="{{styles.nav_button}}">
entries entries
</a> </a>
</div> </div>
<div class="relative nav-group"> <div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/data/') }}" class="{{styles.nav_button}}"> <a href="{{ blog_url('/' + post.slug + '/admin/data/') }}" class="{{styles.nav_button}}">
data data
</a> </a>
</div> </div>
<div class="relative nav-group"> <div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/edit/') }}" class="{{styles.nav_button}}"> <a href="{{ blog_url('/' + post.slug + '/admin/edit/') }}" class="{{styles.nav_button}}">
edit edit
</a> </a>
</div> </div>
<div class="relative nav-group"> <div class="relative nav-group">
<a href="{{ coop_url('/' + post.slug + '/admin/settings/') }}" class="{{styles.nav_button}}"> <a href="{{ blog_url('/' + post.slug + '/admin/settings/') }}" class="{{styles.nav_button}}">
settings settings
</a> </a>
</div> </div>

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='post-admin-row', oob=oob) %} {% call links.menu_row(id='post-admin-row', oob=oob) %}
<a href="{{ coop_url('/' + post.slug + '/admin/') }}" <a href="{{ blog_url('/' + post.slug + '/admin/') }}"
class="flex items-center gap-2 px-3 py-2 rounded"> class="flex items-center gap-2 px-3 py-2 rounded">
{{ links.admin() }} {{ links.admin() }}
</a> </a>

View File

@@ -0,0 +1,28 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='post-row', oob=oob) %}
{% call links.link(blog_url('/' + post.slug + '/'), hx_select_search ) %}
{% if post.feature_image %}
<img
src="{{ post.feature_image }}"
class="h-8 w-8 rounded-full object-cover border border-stone-300 flex-shrink-0"
>
{% endif %}
<span>
{{ post.title | truncate(160, True, '…') }}
</span>
{% endcall %}
{% call links.desktop_nav() %}
{% if page_cart_count is defined and page_cart_count > 0 %}
<a
href="{{ cart_url('/' + post.slug + '/') }}"
class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-full border border-emerald-300 bg-emerald-50 text-emerald-800 hover:bg-emerald-100 transition"
>
<i class="fa fa-shopping-cart" aria-hidden="true"></i>
<span>{{ page_cart_count }}</span>
</a>
{% endif %}
{% include '_types/post/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -1,28 +0,0 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('post-admin-header-child', 'post_entries-header-child', '_types/post_entries/header/_header.html')}}
{% from '_types/post/admin/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/post_entries/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/post_entries/_main_panel.html" %}
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% import 'macros/links.html' as links %} {% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %} {% macro header_row(oob=False) %}
{% call links.menu_row(id='post_entries-row', oob=oob) %} {% call links.menu_row(id='post_entries-row', oob=oob) %}
{% call links.link(coop_url('/' + post.slug + '/admin/entries/'), hx_select_search) %} {% call links.link(blog_url('/' + post.slug + '/admin/entries/'), hx_select_search) %}
<i class="fa fa-clock" aria-hidden="true"></i> <i class="fa fa-clock" aria-hidden="true"></i>
<div> <div>
entries entries

View File

@@ -1,19 +0,0 @@
{% extends '_types/post/admin/index.html' %}
{% block post_admin_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('post-admin-header-child', '_types/post_entries/header/_header.html') %}
{% block post_entries_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/post_entries/_nav.html' %}
{% endblock %}
{% block content %}
{% include '_types/post_entries/_main_panel.html' %}
{% endblock %}

View File

@@ -0,0 +1,4 @@
{# Response for ticket adjust — buy form + OOB cart-mini update #}
{% from 'macros/cart_icon.html' import cart_icon %}
{{ cart_icon(count=cart_count, oob='true') }}
{% include '_types/tickets/_buy_form.html' %}

View File

@@ -3,14 +3,31 @@
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-stone-200 bg-white p-4"> <div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-stone-200 bg-white p-4">
<h3 class="text-sm font-semibold text-stone-700 mb-3"> <h3 class="text-sm font-semibold text-stone-700 mb-3">
<i class="fa fa-ticket mr-1" aria-hidden="true"></i> <i class="fa fa-ticket mr-1" aria-hidden="true"></i>
Buy Tickets Tickets
</h3> </h3>
{# Sold / remaining info #}
<div class="flex items-center gap-3 mb-3 text-xs text-stone-500">
{% if ticket_sold_count is defined and ticket_sold_count %}
<span>{{ ticket_sold_count }} sold</span>
{% endif %}
{% if ticket_remaining is not none %}
<span>{{ ticket_remaining }} remaining</span>
{% endif %}
{% if user_ticket_count is defined and user_ticket_count %}
<span class="text-emerald-600 font-medium">
<i class="fa fa-shopping-cart text-[0.6rem]" aria-hidden="true"></i>
{{ user_ticket_count }} in basket
</span>
{% endif %}
</div>
{% if entry.ticket_types %} {% if entry.ticket_types %}
{# Multiple ticket types #} {# Multiple ticket types #}
<div class="space-y-2 mb-4"> <div class="space-y-2">
{% for tt in entry.ticket_types %} {% for tt in entry.ticket_types %}
{% if tt.deleted_at is none %} {% if tt.deleted_at is none %}
{% set type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type is defined else 0 %}
<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100"> <div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">
<div> <div>
<div class="font-medium text-sm">{{ tt.name }}</div> <div class="font-medium text-sm">{{ tt.name }}</div>
@@ -18,34 +35,83 @@
£{{ '%.2f'|format(tt.cost) }} £{{ '%.2f'|format(tt.cost) }}
</div> </div>
</div> </div>
<form
hx-post="{{ url_for('tickets.buy_tickets') }}" {% if type_count == 0 %}
hx-target="#ticket-buy-{{ entry.id }}" {# Add to basket button #}
hx-swap="outerHTML" <form
class="flex items-center gap-2" hx-post="{{ url_for('tickets.adjust_quantity') }}"
> hx-target="#ticket-buy-{{ entry.id }}"
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> hx-swap="outerHTML"
<input type="hidden" name="entry_id" value="{{ entry.id }}" /> class="flex items-center"
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
<input
type="number"
name="quantity"
value="1"
min="1"
max="10"
class="w-16 px-2 py-1 text-sm border rounded text-center"
/>
<button
type="submit"
class="px-3 py-1 bg-emerald-600 text-white text-sm rounded hover:bg-emerald-700 transition"
> >
Buy <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
</button> <input type="hidden" name="entry_id" value="{{ entry.id }}" />
</form> <input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
<input type="hidden" name="count" value="1" />
<button
type="submit"
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
>
<i class="fa fa-cart-plus text-2xl" aria-hidden="true"></i>
</button>
</form>
{% else %}
{# +/- controls #}
<div class="flex items-center gap-2">
<form
hx-post="{{ url_for('tickets.adjust_quantity') }}"
hx-target="#ticket-buy-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
<input type="hidden" name="count" value="{{ type_count - 1 }}" />
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
-
</button>
</form>
<a
class="relative inline-flex items-center justify-center text-emerald-700"
href="{{ url_for('tickets.my_tickets') }}"
>
<span class="relative inline-flex items-center justify-center">
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">
{{ type_count }}
</span>
</span>
</span>
</a>
<form
hx-post="{{ url_for('tickets.adjust_quantity') }}"
hx-target="#ticket-buy-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
<input type="hidden" name="count" value="{{ type_count + 1 }}" />
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
+
</button>
</form>
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
{# Simple ticket (single price) #} {# Simple ticket (single price) #}
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
@@ -55,38 +121,80 @@
</span> </span>
<span class="text-sm text-stone-500 ml-2">per ticket</span> <span class="text-sm text-stone-500 ml-2">per ticket</span>
</div> </div>
{% if ticket_remaining is not none %}
<span class="text-xs text-stone-500">
{{ ticket_remaining }} remaining
</span>
{% endif %}
</div> </div>
<form {% set qty = user_ticket_count if user_ticket_count is defined else 0 %}
hx-post="{{ url_for('tickets.buy_tickets') }}"
hx-target="#ticket-buy-{{ entry.id }}" {% if qty == 0 %}
hx-swap="outerHTML" {# Add to basket button #}
class="flex items-center gap-3" <form
> hx-post="{{ url_for('tickets.adjust_quantity') }}"
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" /> hx-target="#ticket-buy-{{ entry.id }}"
<input type="hidden" name="entry_id" value="{{ entry.id }}" /> hx-swap="outerHTML"
<label class="text-sm text-stone-600">Qty:</label> class="flex items-center"
<input
type="number"
name="quantity"
value="1"
min="1"
max="10"
class="w-16 px-2 py-1 text-sm border rounded text-center"
/>
<button
type="submit"
class="px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition font-medium"
> >
<i class="fa fa-ticket mr-1" aria-hidden="true"></i> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
Buy Tickets <input type="hidden" name="entry_id" value="{{ entry.id }}" />
</button> <input type="hidden" name="count" value="1" />
</form> <button
type="submit"
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
>
<span class="relative inline-flex items-center justify-center">
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
</span>
</button>
</form>
{% else %}
{# +/- controls #}
<div class="flex items-center gap-2">
<form
hx-post="{{ url_for('tickets.adjust_quantity') }}"
hx-target="#ticket-buy-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
<input type="hidden" name="count" value="{{ qty - 1 }}" />
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
-
</button>
</form>
<a
class="relative inline-flex items-center justify-center text-emerald-700"
href="{{ url_for('tickets.my_tickets') }}"
>
<span class="relative inline-flex items-center justify-center">
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">
{{ qty }}
</span>
</span>
</span>
</a>
<form
hx-post="{{ url_for('tickets.adjust_quantity') }}"
hx-target="#ticket-buy-{{ entry.id }}"
hx-swap="outerHTML"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
<input type="hidden" name="count" value="{{ qty + 1 }}" />
<button
type="submit"
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
>
+
</button>
</form>
</div>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% elif entry.ticket_price is not none %} {% elif entry.ticket_price is not none %}

View File

@@ -1,4 +1,8 @@
{# Shown after ticket purchase — replaces the buy form #} {# Shown after ticket purchase — replaces the buy form #}
{# OOB: refresh cart badge to reflect new ticket count #}
{% from 'macros/cart_icon.html' import cart_icon %}
{{ cart_icon(count=cart_count|default(0), oob='true') }}
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4"> <div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
<div class="flex items-center gap-2 mb-3"> <div class="flex items-center gap-2 mb-3">
<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i> <i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>

View File

@@ -0,0 +1,23 @@
{# Account nav items: tickets + bookings links for the account dashboard #}
<div class="relative nav-group">
<a href="{{ account_url('/tickets/') }}"
hx-get="{{ account_url('/tickets/') }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="{{styles.nav_button}}">
tickets
</a>
</div>
<div class="relative nav-group">
<a href="{{ account_url('/bookings/') }}"
hx-get="{{ account_url('/bookings/') }}"
hx-target="#main-panel"
hx-select="{{ hx_select_search }}"
hx-swap="outerHTML"
hx-push-url="true"
class="{{styles.nav_button}}">
bookings
</a>
</div>

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Bookings</h1>
{% if bookings %}
<div class="divide-y divide-stone-100">
{% for booking in bookings %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-stone-800">{{ booking.name }}</p>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ booking.start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if booking.end_at %}
<span>&ndash; {{ booking.end_at.strftime('%H:%M') }}</span>
{% endif %}
{% if booking.calendar_name %}
<span>&middot; {{ booking.calendar_name }}</span>
{% endif %}
{% if booking.cost %}
<span>&middot; &pound;{{ booking.cost }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if booking.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% elif booking.state == 'provisional' %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">provisional</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-stone-50 border border-stone-200 px-2.5 py-0.5 text-xs font-medium text-stone-600">{{ booking.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No bookings yet.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,44 @@
<div class="w-full max-w-3xl mx-auto px-4 py-6">
<div class="bg-white/70 backdrop-blur rounded-2xl shadow border border-stone-200 p-6 sm:p-8 space-y-6">
<h1 class="text-xl font-semibold tracking-tight">Tickets</h1>
{% if tickets %}
<div class="divide-y divide-stone-100">
{% for ticket in tickets %}
<div class="py-4 first:pt-0 last:pb-0">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<a href="{{ events_url('/tickets/' ~ ticket.code ~ '/') }}"
class="text-sm font-medium text-stone-800 hover:text-emerald-700 transition">
{{ ticket.entry_name }}
</a>
<div class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-stone-500">
<span>{{ ticket.entry_start_at.strftime('%d %b %Y, %H:%M') }}</span>
{% if ticket.calendar_name %}
<span>&middot; {{ ticket.calendar_name }}</span>
{% endif %}
{% if ticket.ticket_type_name %}
<span>&middot; {{ ticket.ticket_type_name }}</span>
{% endif %}
</div>
</div>
<div class="flex-shrink-0">
{% if ticket.state == 'checked_in' %}
<span class="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2.5 py-0.5 text-xs font-medium text-blue-700">checked in</span>
{% elif ticket.state == 'confirmed' %}
<span class="inline-flex items-center rounded-full bg-emerald-50 border border-emerald-200 px-2.5 py-0.5 text-xs font-medium text-emerald-700">confirmed</span>
{% else %}
<span class="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-2.5 py-0.5 text-xs font-medium text-amber-700">{{ ticket.state }}</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-stone-500">No tickets yet.</p>
{% endif %}
</div>
</div>

View File

@@ -0,0 +1,33 @@
{# Calendar entries for blog listing cards — served as fragment from events app.
Each post's entries are delimited by comment markers so the consumer can
extract per-post HTML via simple string splitting. #}
{% for post_id in post_ids %}
<!-- card-widget:{{ post_id }} -->
{% set widget_entries = batch.get(post_id, []) %}
{% if widget_entries %}
<div class="mt-4 mb-2">
<h3 class="text-sm font-semibold text-stone-700 mb-2 px-2">Events:</h3>
<div class="overflow-x-auto scrollbar-hide" style="scroll-behavior: smooth;">
<div class="flex gap-2 px-2">
{% for entry in widget_entries %}
{% set _post_slug = slug_map.get(post_id, '') %}
{% set _entry_path = '/' + _post_slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="flex flex-col gap-1 px-3 py-2 bg-stone-50 hover:bg-stone-100 rounded border border-stone-200 transition text-sm whitespace-nowrap flex-shrink-0 min-w-[180px]">
<div class="font-medium text-stone-900 truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600">
{{ entry.start_at.strftime('%a, %b %d') }}
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</a>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- /card-widget:{{ post_id }} -->
{% endfor %}

View File

@@ -0,0 +1,10 @@
{# Calendar links nav — served as fragment from events app #}
{% for calendar in calendars %}
{% set local_href=events_url('/' + post_slug + '/calendars/' + calendar.slug + '/') %}
<a
href="{{ local_href }}"
class="{{styles.nav_button_less_pad}}">
<i class="fa fa-calendar" aria-hidden="true"></i>
<div>{{calendar.name}}</div>
</a>
{% endfor %}

View File

@@ -0,0 +1,28 @@
{# Calendar entries nav — served as fragment from events app #}
{% for entry in entries %}
{% set _entry_path = '/' + post_slug + '/calendars/' + entry.calendar_slug + '/' + entry.start_at.year|string + '/' + entry.start_at.month|string + '/' + entry.start_at.day|string + '/entries/' + entry.id|string + '/' %}
<a
href="{{ events_url(_entry_path) }}"
class="{{styles.nav_button_less_pad}}"
>
<div class="w-8 h-8 rounded bg-stone-200 flex-shrink-0"></div>
<div class="flex-1 min-w-0">
<div class="font-medium truncate">{{ entry.name }}</div>
<div class="text-xs text-stone-600 truncate">
{{ entry.start_at.strftime('%b %d, %Y at %H:%M') }}
{% if entry.end_at %} {{ entry.end_at.strftime('%H:%M') }}{% endif %}
</div>
</div>
</a>
{% endfor %}
{# Infinite scroll sentinel — URL points back to the consumer app #}
{% if has_more and paginate_url_base %}
<div id="entries-load-sentinel-{{ page }}"
hx-get="{{ paginate_url_base }}?page={{ page + 1 }}"
hx-trigger="intersect once"
hx-swap="beforebegin"
_="on htmx:afterRequest trigger scroll on #associated-entries-container"
class="flex-shrink-0 w-1">
</div>
{% endif %}

View File

@@ -0,0 +1,7 @@
{% macro dt(d) -%}
{{ d.astimezone().strftime('%-d %b %Y, %H:%M') if d.tzinfo else d.strftime('%-d %b %Y, %H:%M') }}
{%- endmacro %}
{% macro t(d) -%}
{{ d.astimezone().strftime('%H:%M') if d.tzinfo else d.strftime('%H:%M') }}
{%- endmacro %}