Compare commits
142 Commits
main
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec65b9fea8 | ||
|
|
7169378e41 | ||
|
|
1b7cc5849b | ||
|
|
0f8dd636af | ||
|
|
bc4d332157 | ||
|
|
77c5fc716a | ||
|
|
2a723af201 | ||
|
|
503f7ca7d8 | ||
|
|
de80c393e4 | ||
|
|
1602b14c3c | ||
|
|
95af55da39 | ||
|
|
226d50a980 | ||
|
|
4d5dd7b86e | ||
|
|
ec2a91a401 | ||
|
|
db78bd395e | ||
|
|
83d3898aa4 | ||
|
|
e6fb4e8cd4 | ||
|
|
d634724a44 | ||
|
|
46ce430831 | ||
|
|
d57917b9c6 | ||
|
|
6eedc5b9b2 | ||
|
|
94ab2e0545 | ||
|
|
b2d692ae9c | ||
|
|
cf79734aff | ||
|
|
a425439df1 | ||
|
|
e55bbcc091 | ||
|
|
bbdf7b7d08 | ||
|
|
aa2f0e2733 | ||
|
|
af8340d6d0 | ||
|
|
40d83b2e90 | ||
|
|
b7c3fa2ec7 | ||
|
|
fa2ec6ef2f | ||
|
|
15057a1a22 | ||
|
|
17296c4114 | ||
|
|
d02d45c468 | ||
|
|
34ed7b6705 | ||
|
|
6876a83f3b | ||
|
|
fb2c1e63c7 | ||
|
|
81144ddbfc | ||
|
|
63ce51bf31 | ||
|
|
32a6296093 | ||
|
|
837dee33e0 | ||
|
|
8de467245a | ||
|
|
dae207927b | ||
|
|
edbc11d956 | ||
|
|
cfefa1bc91 | ||
|
|
8bb09e21e4 | ||
|
|
4c131cd293 | ||
|
|
690924a1f9 | ||
|
|
67c065fdc8 | ||
|
|
a70e0b81f0 | ||
|
|
af47498cc0 | ||
|
|
53e79f0d44 | ||
|
|
751e959dba | ||
|
|
424c267110 | ||
|
|
dad53fd1b5 | ||
|
|
39f500c41c | ||
|
|
b8724eaf66 | ||
|
|
f1b3093d94 | ||
|
|
1dbf8f479e | ||
|
|
e1f96f02b1 | ||
|
|
9f46520b45 | ||
|
|
e5ab555359 | ||
|
|
995503480b | ||
|
|
f8c99d3044 | ||
|
|
748a13a369 | ||
|
|
fb82fca10e | ||
|
|
fef13b9f94 | ||
|
|
60d7cf03c2 | ||
|
|
bd25a09e0d | ||
|
|
09de64be99 | ||
|
|
4dd51feb92 | ||
|
|
8f4d733d12 | ||
|
|
e7e8d69b7a | ||
|
|
8da241f60b | ||
|
|
ddd599a2f4 | ||
|
|
7b642b3430 | ||
|
|
012bb868e6 | ||
|
|
c1314f7f7d | ||
|
|
446bbf74b4 | ||
|
|
4c8d038156 | ||
|
|
6e2bbe73be | ||
|
|
f4be5b47e6 | ||
|
|
5b8e0df990 | ||
|
|
6731790c9d | ||
|
|
dc357daae8 | ||
|
|
ef273a7311 | ||
|
|
a7b70569c9 | ||
|
|
90c918595c | ||
|
|
f445d39d22 | ||
|
|
4aaaf2c7f1 | ||
|
|
256eb390b0 | ||
|
|
13064c3772 | ||
|
|
44d089ced4 | ||
|
|
e098422fff | ||
|
|
df77f3d2a5 | ||
|
|
3c8f231e19 | ||
|
|
9932209b7d | ||
|
|
c16092e984 | ||
|
|
0e0359c84d | ||
|
|
5f63c9b1f3 | ||
|
|
3873f22bf2 | ||
|
|
2f8a62e4a1 | ||
|
|
2527c854cb | ||
|
|
a03fa90463 | ||
|
|
f2ce575699 | ||
|
|
f289ee0dcd | ||
|
|
cec9a3296f | ||
|
|
14836e0ea3 | ||
|
|
f1b5aeac53 | ||
|
|
7b15f37686 | ||
|
|
d2195c0969 | ||
|
|
b153203fc0 | ||
|
|
ba456dca4c | ||
|
|
6e9c973572 | ||
|
|
7c84933bc8 | ||
|
|
a9d42cdd39 | ||
|
|
f9b9f2d10f | ||
|
|
9fc97b8f6d | ||
|
|
19a6b7318d | ||
|
|
6b022a444f | ||
|
|
f7c5c7ea88 | ||
|
|
d2521e81aa | ||
|
|
ca8288e28d | ||
|
|
ad3c8a637e | ||
|
|
ddbd04b67f | ||
|
|
88347222d8 | ||
|
|
bbde3c1f3f | ||
|
|
fca0950cd1 | ||
|
|
ee93832db0 | ||
|
|
0a8d1391f6 | ||
|
|
4a0041efd5 | ||
|
|
b2aa657d70 | ||
|
|
dd827541ee | ||
|
|
b26b47169a | ||
|
|
05867ff7f5 | ||
|
|
b188bb8f20 | ||
|
|
387af7faa7 | ||
|
|
541dd2ccd7 | ||
|
|
cac3c12241 | ||
|
|
b35abdeda8 | ||
|
|
154f968296 |
@@ -2,13 +2,13 @@ name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, decoupling]
|
||||
|
||||
env:
|
||||
REGISTRY: registry.rose-ash.com:5000
|
||||
IMAGE: events
|
||||
REPO_DIR: /root/rose-ash/events
|
||||
COOP_DIR: /root/coop
|
||||
COOP_DIR: /root/rose-ash
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -36,9 +36,23 @@ jobs:
|
||||
run: |
|
||||
ssh "root@$DEPLOY_HOST" "
|
||||
cd ${{ env.REPO_DIR }}
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
git fetch origin ${{ github.ref_name }}
|
||||
git reset --hard origin/${{ github.ref_name }}
|
||||
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
|
||||
|
||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
||||
[submodule "shared_lib"]
|
||||
path = shared_lib
|
||||
[submodule "shared"]
|
||||
path = shared
|
||||
url = https://git.rose-ash.com/coop/shared.git
|
||||
branch = decoupling
|
||||
|
||||
@@ -4,6 +4,7 @@ FROM python:3.11-slim AS base
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONPATH=/app \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
APP_PORT=8000 \
|
||||
APP_MODULE=app:app
|
||||
@@ -16,14 +17,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
postgresql-client \
|
||||
&& 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
|
||||
|
||||
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 ----------
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
|
||||
98
README.md
98
README.md
@@ -1,48 +1,78 @@
|
||||
# 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.
|
||||
It runs as a standalone Quart microservice, part of the multi-app coop architecture.
|
||||
One of five Quart microservices sharing a single PostgreSQL database:
|
||||
|
||||
| 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
|
||||
|
||||
```
|
||||
app.py # Application factory and entry point
|
||||
events_api.py # Internal JSON API (server-to-server, CSRF-exempt)
|
||||
bp/ # Blueprints
|
||||
calendars/ # Calendar listing
|
||||
calendar/ # Single calendar view and admin
|
||||
calendar_entries/ # Calendar entries listing
|
||||
calendar_entry/ # Single entry view and admin
|
||||
day/ # Day view and admin
|
||||
slots/ # Slot listing
|
||||
slot/ # Single slot management
|
||||
ticket_types/ # Ticket type listing
|
||||
ticket_type/ # Single ticket type management
|
||||
templates/ # Jinja2 templates
|
||||
_types/ # Feature-specific templates
|
||||
app.py # Application factory (create_base_app + blueprints)
|
||||
path_setup.py # Adds project root + app dir to sys.path
|
||||
config/app-config.yaml # App URLs, feature flags
|
||||
models/ # Events-domain models
|
||||
calendars.py # Calendar, CalendarEntry, CalendarSlot,
|
||||
# TicketType, Ticket, CalendarEntryPost
|
||||
bp/ # Blueprints
|
||||
calendars/ # Calendar listing
|
||||
calendar/ # Single calendar view and admin
|
||||
calendar_entries/ # Calendar entries listing
|
||||
calendar_entry/ # Single entry view and admin
|
||||
day/ # Day view and admin
|
||||
slots/ # Slot listing
|
||||
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
|
||||
|
||||
```bash
|
||||
# Set required environment variables (see .env.example)
|
||||
export APP_MODULE=app:app
|
||||
hypercorn app:app --bind 0.0.0.0:8000
|
||||
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||
export REDIS_URL=redis://localhost:6379/0
|
||||
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
0
__init__.py
Normal file
95
app.py
95
app.py
@@ -1,51 +1,58 @@
|
||||
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 quart import g, abort
|
||||
from quart import g, abort, request
|
||||
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:
|
||||
"""
|
||||
Events app context processor.
|
||||
|
||||
- menu_items: fetched from coop internal API
|
||||
- cart_count/cart_total: fetched from cart internal API
|
||||
- nav_tree_html: fetched from blog as fragment
|
||||
- cart_count/cart_total: via cart service (shared DB)
|
||||
"""
|
||||
from shared.context import base_context
|
||||
from shared.internal_api import get as api_get, dictobj
|
||||
from shared.infrastructure.context import base_context
|
||||
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()
|
||||
|
||||
# Menu items from coop API (wrapped for attribute access in templates)
|
||||
menu_data = await api_get("coop", "/internal/menu-items")
|
||||
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
|
||||
ctx["nav_tree_html"] = await fetch_fragment(
|
||||
"blog", "nav-tree",
|
||||
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 = await api_get("cart", "/internal/cart/summary", forward_session=True)
|
||||
if cart_data:
|
||||
ctx["cart_count"] = cart_data.get("count", 0)
|
||||
ctx["cart_total"] = cart_data.get("total", 0)
|
||||
else:
|
||||
ctx["cart_count"] = 0
|
||||
ctx["cart_total"] = 0
|
||||
# Cart data via service (replaces cross-app HTTP API)
|
||||
ident = current_cart_identity()
|
||||
summary = await services.cart.cart_summary(
|
||||
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||
)
|
||||
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
|
||||
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
from models.ghost_content import Post
|
||||
from models.calendars import Calendar
|
||||
from models.market_place import MarketPlace
|
||||
from shared.services.registry import services
|
||||
from services import register_domain_services
|
||||
|
||||
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_templates = str(Path(__file__).resolve().parent / "templates")
|
||||
@@ -54,6 +61,18 @@ def create_app() -> "Quart":
|
||||
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/...
|
||||
app.register_blueprint(
|
||||
register_calendars(),
|
||||
@@ -72,6 +91,8 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<slug>/payments",
|
||||
)
|
||||
|
||||
app.register_blueprint(register_fragments())
|
||||
|
||||
# --- Auto-inject slug into url_for() calls ---
|
||||
@app.url_value_preprocessor
|
||||
def pull_slug(endpoint, values):
|
||||
@@ -91,11 +112,7 @@ def create_app() -> "Quart":
|
||||
slug = getattr(g, "post_slug", None)
|
||||
if not slug:
|
||||
return
|
||||
post = (
|
||||
await g.s.execute(
|
||||
select(Post).where(Post.slug == slug)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||
if not post:
|
||||
abort(404)
|
||||
g.post_data = {
|
||||
@@ -115,20 +132,8 @@ def create_app() -> "Quart":
|
||||
if not post_data:
|
||||
return {}
|
||||
post_id = post_data["post"]["id"]
|
||||
calendars = (
|
||||
await g.s.execute(
|
||||
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()
|
||||
calendars = await services.calendar.calendars_for_container(g.s, "page", post_id)
|
||||
markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
|
||||
return {
|
||||
**post_data,
|
||||
"calendars": calendars,
|
||||
@@ -143,10 +148,6 @@ def create_app() -> "Quart":
|
||||
from bp.ticket_admin.routes import register as 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
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
from .all_events.routes import register as register_all_events
|
||||
from .calendars.routes import register as register_calendars
|
||||
from .markets.routes import register as register_markets
|
||||
from .payments.routes import register as register_payments
|
||||
from .page.routes import register as register_page
|
||||
from .fragments import register_fragments
|
||||
|
||||
0
bp/all_events/__init__.py
Normal file
0
bp/all_events/__init__.py
Normal file
143
bp/all_events/routes.py
Normal file
143
bp/all_events/routes.py
Normal 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
|
||||
@@ -5,8 +5,8 @@ from quart import (
|
||||
)
|
||||
|
||||
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ def register():
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
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
|
||||
if not is_htmx_request():
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy import select
|
||||
from models.calendars import Calendar
|
||||
|
||||
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 .services import get_visible_entries_for_period
|
||||
@@ -23,17 +23,17 @@ from .services.calendar_view import (
|
||||
get_calendar_by_slug,
|
||||
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 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
|
||||
|
||||
@@ -213,7 +213,7 @@ def register():
|
||||
@require_admin
|
||||
@clear_cache(tag="calendars", tag_scope="all")
|
||||
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.deleted_at = datetime.now(timezone.utc)
|
||||
@@ -230,7 +230,7 @@ def register():
|
||||
cals = (
|
||||
await g.s.execute(
|
||||
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())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from sqlalchemy import select, update
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:
|
||||
|
||||
@@ -11,7 +11,6 @@ from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||
|
||||
from models.calendars import Calendar, CalendarSlot
|
||||
|
||||
|
||||
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
|
||||
"""Parse an integer query parameter from the request."""
|
||||
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)),
|
||||
)
|
||||
.where(
|
||||
Calendar.post_id == post_id,
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post_id,
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class VisibleEntries:
|
||||
"""
|
||||
|
||||
@@ -3,26 +3,29 @@ from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
|
||||
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 .services.entries import (
|
||||
|
||||
add_entry as svc_add_entry,
|
||||
)
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
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 sqlalchemy import select
|
||||
|
||||
|
||||
@@ -203,8 +206,36 @@ def register():
|
||||
entry.ticket_price = ticket_price
|
||||
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")
|
||||
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/")
|
||||
async def add_form(day: int, month: int, year: int, **kwargs):
|
||||
|
||||
@@ -7,9 +7,10 @@ from sqlalchemy import select, and_, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import Calendar, CalendarEntry
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from suma_browser.app.errors import AppError
|
||||
from shared.browser.app.errors import AppError
|
||||
|
||||
class CalendarError(AppError):
|
||||
"""Base error for calendar service operations."""
|
||||
@@ -93,6 +94,24 @@ async def add_entry(
|
||||
)
|
||||
sess.add(entry)
|
||||
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
|
||||
|
||||
|
||||
@@ -120,7 +139,8 @@ async def list_entries(
|
||||
await sess.execute(
|
||||
select(Calendar.id)
|
||||
.where(
|
||||
Calendar.post_id == post_id,
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post_id,
|
||||
Calendar.slug == calendar_slug,
|
||||
Calendar.deleted_at.is_(None),
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
@@ -15,7 +15,7 @@ def register():
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
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
|
||||
if not is_htmx_request():
|
||||
|
||||
@@ -5,8 +5,8 @@ from sqlalchemy import select, update
|
||||
|
||||
from models.calendars import CalendarEntry, CalendarSlot
|
||||
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -27,6 +27,8 @@ from datetime import datetime, timezone
|
||||
import math
|
||||
import logging
|
||||
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
|
||||
from ..ticket_types.routes import register as register_ticket_types
|
||||
|
||||
from .admin.routes import register as register_admin
|
||||
@@ -142,7 +144,7 @@ def register():
|
||||
calendars = (
|
||||
await g.s.execute(
|
||||
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())
|
||||
)
|
||||
).scalars().all()
|
||||
@@ -160,13 +162,22 @@ def register():
|
||||
|
||||
@bp.context_processor
|
||||
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 {}
|
||||
entry_id = view_args.get("entry_id")
|
||||
calendar_entry = None
|
||||
entry_posts = []
|
||||
ticket_remaining = None
|
||||
ticket_sold_count = 0
|
||||
user_ticket_count = 0
|
||||
user_ticket_counts_by_type = {}
|
||||
|
||||
stmt = (
|
||||
select(CalendarEntry)
|
||||
@@ -174,6 +185,7 @@ def register():
|
||||
CalendarEntry.id == entry_id,
|
||||
CalendarEntry.deleted_at.is_(None),
|
||||
)
|
||||
.options(selectinload(CalendarEntry.ticket_types))
|
||||
)
|
||||
result = await g.s.execute(stmt)
|
||||
calendar_entry = result.scalar_one_or_none()
|
||||
@@ -190,19 +202,53 @@ def register():
|
||||
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
|
||||
# Get ticket availability
|
||||
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 {
|
||||
"entry": calendar_entry,
|
||||
"entry_posts": entry_posts,
|
||||
"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("/")
|
||||
@require_admin
|
||||
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
|
||||
# For now, render full template for both HTMX and normal requests
|
||||
# Full template for both HTMX and normal requests
|
||||
if not is_htmx_request():
|
||||
# Normal browser request: full page with layout
|
||||
html = await render_template(
|
||||
@@ -496,8 +542,8 @@ def register():
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
# Return updated entry view
|
||||
html = await render_template("_types/entry/index.html")
|
||||
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
|
||||
html = await render_template("_types/entry/_tickets.html")
|
||||
return await make_response(html, 200)
|
||||
|
||||
@bp.get("/posts/search/")
|
||||
|
||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from models.calendars import CalendarEntry, CalendarEntryPost
|
||||
from models.ghost_content import Post
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
async def add_post_to_entry(
|
||||
@@ -28,9 +28,7 @@ async def add_post_to_entry(
|
||||
return False, "Calendar entry not found"
|
||||
|
||||
# Check if post exists
|
||||
post = await session.scalar(
|
||||
select(Post).where(Post.id == post_id)
|
||||
)
|
||||
post = await services.blog.get_post_by_id(session, post_id)
|
||||
if not post:
|
||||
return False, "Post not found"
|
||||
|
||||
@@ -38,7 +36,8 @@ async def add_post_to_entry(
|
||||
existing = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.post_id == post_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
@@ -49,7 +48,8 @@ async def add_post_to_entry(
|
||||
# Create association
|
||||
association = CalendarEntryPost(
|
||||
entry_id=entry_id,
|
||||
post_id=post_id
|
||||
content_type="post",
|
||||
content_id=post_id
|
||||
)
|
||||
session.add(association)
|
||||
await session.flush()
|
||||
@@ -70,7 +70,8 @@ async def remove_post_from_entry(
|
||||
association = await session.scalar(
|
||||
select(CalendarEntryPost).where(
|
||||
CalendarEntryPost.entry_id == entry_id,
|
||||
CalendarEntryPost.post_id == post_id,
|
||||
CalendarEntryPost.content_type == "post",
|
||||
CalendarEntryPost.content_id == post_id,
|
||||
CalendarEntryPost.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
@@ -88,20 +89,22 @@ async def remove_post_from_entry(
|
||||
async def get_entry_posts(
|
||||
session: AsyncSession,
|
||||
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(
|
||||
select(Post)
|
||||
.join(CalendarEntryPost)
|
||||
.where(
|
||||
select(CalendarEntryPost.content_id).where(
|
||||
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(
|
||||
@@ -109,29 +112,10 @@ async def search_posts(
|
||||
query: str,
|
||||
page: int = 1,
|
||||
per_page: int = 10
|
||||
) -> tuple[list[Post], int]:
|
||||
) -> tuple[list, int]:
|
||||
"""
|
||||
Search for posts by title with pagination.
|
||||
If query is empty, returns all posts in published order.
|
||||
Returns (posts, total_count).
|
||||
Returns (post_dtos, total_count).
|
||||
"""
|
||||
# Build base query
|
||||
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
|
||||
return await services.blog.search_posts(session, query, page, per_page)
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from models.calendars import CalendarEntry
|
||||
|
||||
|
||||
|
||||
async def update_ticket_config(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
@@ -82,6 +83,5 @@ async def get_available_tickets(
|
||||
if entry.ticket_count is None:
|
||||
return None, None
|
||||
|
||||
# TODO: Subtract booked tickets when ticket booking is implemented
|
||||
# For now, just return the total count
|
||||
# Returns total count (booked tickets not yet subtracted)
|
||||
return entry.ticket_count, None
|
||||
|
||||
@@ -7,16 +7,17 @@ from sqlalchemy import select
|
||||
|
||||
from models.calendars import Calendar
|
||||
|
||||
|
||||
from .services.calendars import (
|
||||
create_calendar as svc_create_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 suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
@@ -78,7 +79,7 @@ def register():
|
||||
cals = (
|
||||
await g.s.execute(
|
||||
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())
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
@@ -4,7 +4,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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 re
|
||||
|
||||
@@ -12,7 +13,7 @@ import re
|
||||
class CalendarError(ValueError):
|
||||
"""Base error for calendar service operations."""
|
||||
|
||||
from suma_browser.app.utils import (
|
||||
from shared.browser.app.utils import (
|
||||
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:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
cal = (
|
||||
await sess.execute(
|
||||
select(Calendar)
|
||||
.join(Post, Calendar.post_id == Post.id)
|
||||
.where(
|
||||
Post.slug == post_slug,
|
||||
select(Calendar).where(
|
||||
Calendar.container_type == "page",
|
||||
Calendar.container_id == post.id,
|
||||
Calendar.slug == calendar_slug,
|
||||
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()
|
||||
await sess.flush()
|
||||
await detach_child(sess, "page", cal.container_id, "calendar", cal.id)
|
||||
return True
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -97,12 +102,14 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
||||
if existing.deleted_at is not None:
|
||||
existing.deleted_at = None # revive
|
||||
await sess.flush()
|
||||
await attach_child(sess, "page", post_id, "calendar", existing.id)
|
||||
return existing
|
||||
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)
|
||||
await sess.flush()
|
||||
await attach_child(sess, "page", post_id, "calendar", cal.id)
|
||||
return cal
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
@@ -15,7 +15,7 @@ def register():
|
||||
@bp.get("/")
|
||||
@require_admin
|
||||
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
|
||||
if not is_htmx_request():
|
||||
|
||||
@@ -5,17 +5,19 @@ from quart import (
|
||||
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 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 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():
|
||||
@@ -76,6 +78,18 @@ def register():
|
||||
result = await g.s.execute(stmt)
|
||||
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 {
|
||||
"qsession": qsession,
|
||||
"day_date": day_date,
|
||||
@@ -85,7 +99,8 @@ def register():
|
||||
"day_entries": visible.merged_entries,
|
||||
"user_entries": visible.user_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)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .routes import register as register_fragments
|
||||
130
bp/fragments/routes.py
Normal file
130
bp/fragments/routes.py
Normal 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
|
||||
@@ -3,18 +3,15 @@ from __future__ import annotations
|
||||
from quart import (
|
||||
request, render_template, make_response, Blueprint, g
|
||||
)
|
||||
from sqlalchemy import select
|
||||
|
||||
from models.market_place import MarketPlace
|
||||
|
||||
from .services.markets import (
|
||||
create_market as svc_create_market,
|
||||
soft_delete as svc_soft_delete,
|
||||
)
|
||||
|
||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
|
||||
@@ -3,12 +3,10 @@ from __future__ import annotations
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.market_place import MarketPlace
|
||||
from models.ghost_content import Post
|
||||
from suma_browser.app.utils import utcnow
|
||||
from shared.contracts.dtos import MarketPlaceDTO
|
||||
from shared.services.registry import services
|
||||
|
||||
|
||||
class MarketError(ValueError):
|
||||
@@ -28,7 +26,7 @@ def slugify(value: str, max_len: int = 255) -> str:
|
||||
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.
|
||||
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.")
|
||||
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:
|
||||
raise MarketError(f"Post {post_id} does not exist.")
|
||||
if not post.is_page:
|
||||
raise MarketError("Markets can only be created on pages, not posts.")
|
||||
|
||||
# Look for existing (including soft-deleted)
|
||||
existing = (await sess.execute(
|
||||
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
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
|
||||
try:
|
||||
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
|
||||
except ValueError as e:
|
||||
raise MarketError(str(e)) from e
|
||||
|
||||
|
||||
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||
market = (
|
||||
await sess.execute(
|
||||
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:
|
||||
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||
if not post:
|
||||
return False
|
||||
|
||||
market.deleted_at = utcnow()
|
||||
await sess.flush()
|
||||
return True
|
||||
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
|
||||
|
||||
0
bp/page/__init__.py
Normal file
0
bp/page/__init__.py
Normal file
129
bp/page/routes.py
Normal file
129
bp/page/routes.py
Normal 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
|
||||
@@ -5,10 +5,10 @@ from quart import (
|
||||
)
|
||||
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 suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
@@ -26,7 +26,7 @@ def register():
|
||||
return {}
|
||||
|
||||
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()
|
||||
|
||||
return {
|
||||
@@ -55,10 +55,10 @@ def register():
|
||||
return await make_response("Post not found", 404)
|
||||
|
||||
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()
|
||||
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)
|
||||
await g.s.flush()
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ from quart import (
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.slot import (
|
||||
update_slot as svc_update_slot,
|
||||
@@ -19,11 +19,11 @@ from ..slots.services.slots import (
|
||||
list_slots as svc_list_slots,
|
||||
)
|
||||
|
||||
from suma_browser.app.utils import (
|
||||
from shared.browser.app.utils import (
|
||||
parse_time,
|
||||
parse_cost
|
||||
)
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ from quart import (
|
||||
)
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.slots import (
|
||||
list_slots as svc_list_slots,
|
||||
@@ -15,11 +15,11 @@ from .services.slots import (
|
||||
|
||||
from ..slot.routes import register as register_slot
|
||||
|
||||
from suma_browser.app.utils import (
|
||||
from shared.browser.app.utils import (
|
||||
parse_time,
|
||||
parse_cost
|
||||
)
|
||||
from suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import CalendarSlot
|
||||
|
||||
|
||||
class SlotError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -18,8 +18,8 @@ from sqlalchemy import select, func
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import CalendarEntry, Ticket, TicketType
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from ..tickets.services.tickets import (
|
||||
get_ticket_by_code,
|
||||
@@ -37,7 +37,7 @@ def register() -> Blueprint:
|
||||
@require_admin
|
||||
async def dashboard():
|
||||
"""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
|
||||
result = await g.s.execute(
|
||||
@@ -89,7 +89,7 @@ def register() -> Blueprint:
|
||||
@require_admin
|
||||
async def entry_tickets(entry_id: int):
|
||||
"""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(
|
||||
select(CalendarEntry)
|
||||
|
||||
@@ -4,8 +4,8 @@ from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.ticket import (
|
||||
get_ticket_type as svc_get_ticket_type,
|
||||
@@ -16,7 +16,7 @@ from .services.ticket import (
|
||||
from ..ticket_types.services.tickets import (
|
||||
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():
|
||||
|
||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.calendars import TicketType
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ from quart import (
|
||||
request, render_template, make_response, Blueprint, g, jsonify
|
||||
)
|
||||
|
||||
from suma_browser.app.authz import require_admin
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.browser.app.authz import require_admin
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.tickets import (
|
||||
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 suma_browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
|
||||
|
||||
def register():
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import TicketType
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ Routes:
|
||||
GET /tickets/ — My tickets list
|
||||
GET /tickets/<code>/ — Ticket detail with QR code
|
||||
POST /tickets/buy/ — Purchase tickets for an entry
|
||||
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -17,8 +18,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from models.calendars import CalendarEntry
|
||||
from shared.cart_identity import current_cart_identity
|
||||
from suma_browser.app.redis_cacher import clear_cache
|
||||
from shared.infrastructure.cart_identity import current_cart_identity
|
||||
from shared.browser.app.redis_cacher import clear_cache
|
||||
|
||||
from .services.tickets import (
|
||||
create_ticket,
|
||||
@@ -26,6 +27,9 @@ from .services.tickets import (
|
||||
get_user_tickets,
|
||||
get_available_ticket_count,
|
||||
get_tickets_for_entry,
|
||||
get_sold_ticket_count,
|
||||
get_user_reserved_count,
|
||||
cancel_latest_reserved_ticket,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -37,7 +41,7 @@ def register() -> Blueprint:
|
||||
@bp.get("/")
|
||||
async def my_tickets():
|
||||
"""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()
|
||||
tickets = await get_user_tickets(
|
||||
@@ -62,7 +66,7 @@ def register() -> Blueprint:
|
||||
@bp.get("/<code>/")
|
||||
async def ticket_detail(code: str):
|
||||
"""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)
|
||||
if not ticket:
|
||||
@@ -178,4 +182,127 @@ def register() -> Blueprint:
|
||||
)
|
||||
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
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import selectinload
|
||||
from models.calendars import Ticket, TicketType, CalendarEntry
|
||||
|
||||
|
||||
|
||||
async def create_ticket(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
@@ -181,6 +182,80 @@ async def get_tickets_for_entry(
|
||||
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(
|
||||
session: AsyncSession,
|
||||
entry_id: int,
|
||||
|
||||
84
config/app-config.yaml
Normal file
84
config/app-config.yaml
Normal 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-'
|
||||
|
||||
198
events_api.py
198
events_api.py
@@ -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
4
models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .calendars import (
|
||||
Calendar, CalendarEntry, CalendarSlot,
|
||||
TicketType, Ticket, CalendarEntryPost,
|
||||
)
|
||||
4
models/calendars.py
Normal file
4
models/calendars.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from shared.models.calendars import ( # noqa: F401
|
||||
Calendar, CalendarEntry, CalendarSlot,
|
||||
TicketType, Ticket, CalendarEntryPost,
|
||||
)
|
||||
@@ -1,7 +1,9 @@
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add the shared library submodule to the Python path
|
||||
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
|
||||
if _shared not in sys.path:
|
||||
sys.path.insert(0, _shared)
|
||||
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_project_root = os.path.dirname(_app_dir)
|
||||
|
||||
for _p in (_project_root, _app_dir):
|
||||
if _p not in sys.path:
|
||||
sys.path.insert(0, _p)
|
||||
|
||||
29
services/__init__.py
Normal file
29
services/__init__.py
Normal 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
1
shared
Submodule
Submodule shared added at 9ab4b7b3fe
Submodule shared_lib deleted from 0c9b8d6aa2
62
templates/_types/all_events/_card.html
Normal file
62
templates/_types/all_events/_card.html
Normal 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> ·
|
||||
{% else %}
|
||||
{{ entry.start_at.strftime('%a %-d %b') }} ·
|
||||
{% endif %}
|
||||
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entry.cost %}
|
||||
<div class="mt-1 text-sm font-medium text-green-600">
|
||||
£{{ '%.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>
|
||||
60
templates/_types/all_events/_card_tile.html
Normal file
60
templates/_types/all_events/_card_tile.html
Normal 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 %}
|
||||
·
|
||||
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entry.cost %}
|
||||
<div class="mt-1 text-sm font-medium text-green-600">
|
||||
£{{ '%.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>
|
||||
31
templates/_types/all_events/_cards.html
Normal file
31
templates/_types/all_events/_cards.html
Normal 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 %}
|
||||
54
templates/_types/all_events/_main_panel.html
Normal file
54
templates/_types/all_events/_main_panel.html
Normal 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>
|
||||
7
templates/_types/all_events/index.html
Normal file
7
templates/_types/all_events/index.html
Normal file
@@ -0,0 +1,7 @@
|
||||
{% extends '_types/root/_index.html' %}
|
||||
|
||||
{% block meta %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include '_types/all_events/_main_panel.html' %}
|
||||
{% endblock %}
|
||||
@@ -6,7 +6,7 @@
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||
<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">
|
||||
{% if entry_post.feature_image %}
|
||||
<img src="{{ entry_post.feature_image }}"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||
<a
|
||||
href="{{ coop_url('/' + entry_post.slug + '/') }}"
|
||||
href="{{ blog_url('/' + entry_post.slug + '/') }}"
|
||||
class="{{styles.nav_button}}"
|
||||
>
|
||||
{% if entry_post.feature_image %}
|
||||
|
||||
49
templates/_types/page_summary/_card.html
Normal file
49
templates/_types/page_summary/_card.html
Normal 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 %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entry.cost %}
|
||||
<div class="mt-1 text-sm font-medium text-green-600">
|
||||
£{{ '%.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>
|
||||
48
templates/_types/page_summary/_card_tile.html
Normal file
48
templates/_types/page_summary/_card_tile.html
Normal 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>
|
||||
·
|
||||
{{ entry.start_at.strftime('%H:%M') }}{% if entry.end_at %} – {{ entry.end_at.strftime('%H:%M') }}{% endif %}
|
||||
</div>
|
||||
|
||||
{% if entry.cost %}
|
||||
<div class="mt-1 text-sm font-medium text-green-600">
|
||||
£{{ '%.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>
|
||||
31
templates/_types/page_summary/_cards.html
Normal file
31
templates/_types/page_summary/_cards.html
Normal 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 %}
|
||||
54
templates/_types/page_summary/_main_panel.html
Normal file
54
templates/_types/page_summary/_main_panel.html
Normal 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>
|
||||
63
templates/_types/page_summary/_ticket_widget.html
Normal file
63
templates/_types/page_summary/_ticket_widget.html
Normal 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">£{{ '%.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>
|
||||
15
templates/_types/page_summary/index.html
Normal file
15
templates/_types/page_summary/index.html
Normal 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 %}
|
||||
@@ -8,7 +8,7 @@
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% 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>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
50
templates/_types/post/admin/_associated_entries.html
Normal file
50
templates/_types/post/admin/_associated_entries.html
Normal 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>
|
||||
@@ -15,22 +15,22 @@
|
||||
</a>
|
||||
</div>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% 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">
|
||||
{{ links.admin() }}
|
||||
</a>
|
||||
|
||||
28
templates/_types/post/header/_header.html
Normal file
28
templates/_types/post/header/_header.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% import 'macros/links.html' as links %}
|
||||
{% macro header_row(oob=False) %}
|
||||
{% 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>
|
||||
<div>
|
||||
entries
|
||||
|
||||
@@ -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 %}
|
||||
4
templates/_types/tickets/_adjust_response.html
Normal file
4
templates/_types/tickets/_adjust_response.html
Normal 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' %}
|
||||
@@ -3,14 +3,31 @@
|
||||
<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">
|
||||
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
||||
Buy Tickets
|
||||
Tickets
|
||||
</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 %}
|
||||
{# Multiple ticket types #}
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="space-y-2">
|
||||
{% for tt in entry.ticket_types %}
|
||||
{% 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>
|
||||
<div class="font-medium text-sm">{{ tt.name }}</div>
|
||||
@@ -18,34 +35,83 @@
|
||||
£{{ '%.2f'|format(tt.cost) }}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
hx-post="{{ url_for('tickets.buy_tickets') }}"
|
||||
hx-target="#ticket-buy-{{ entry.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<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="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"
|
||||
|
||||
{% if type_count == 0 %}
|
||||
{# Add to basket button #}
|
||||
<form
|
||||
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||
hx-target="#ticket-buy-{{ entry.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center"
|
||||
>
|
||||
Buy
|
||||
</button>
|
||||
</form>
|
||||
<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="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>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
{# Simple ticket (single price) #}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
@@ -55,38 +121,80 @@
|
||||
</span>
|
||||
<span class="text-sm text-stone-500 ml-2">per ticket</span>
|
||||
</div>
|
||||
{% if ticket_remaining is not none %}
|
||||
<span class="text-xs text-stone-500">
|
||||
{{ ticket_remaining }} remaining
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<form
|
||||
hx-post="{{ url_for('tickets.buy_tickets') }}"
|
||||
hx-target="#ticket-buy-{{ entry.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||
<label class="text-sm text-stone-600">Qty:</label>
|
||||
<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"
|
||||
{% set qty = user_ticket_count if user_ticket_count is defined else 0 %}
|
||||
|
||||
{% if qty == 0 %}
|
||||
{# Add to basket button #}
|
||||
<form
|
||||
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||
hx-target="#ticket-buy-{{ entry.id }}"
|
||||
hx-swap="outerHTML"
|
||||
class="flex items-center"
|
||||
>
|
||||
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
||||
Buy Tickets
|
||||
</button>
|
||||
</form>
|
||||
<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-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 %}
|
||||
</div>
|
||||
{% elif entry.ticket_price is not none %}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{# 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 class="flex items-center gap-2 mb-3">
|
||||
<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>
|
||||
|
||||
23
templates/fragments/account_nav_items.html
Normal file
23
templates/fragments/account_nav_items.html
Normal 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>
|
||||
44
templates/fragments/account_page_bookings.html
Normal file
44
templates/fragments/account_page_bookings.html
Normal 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>– {{ booking.end_at.strftime('%H:%M') }}</span>
|
||||
{% endif %}
|
||||
{% if booking.calendar_name %}
|
||||
<span>· {{ booking.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if booking.cost %}
|
||||
<span>· £{{ 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>
|
||||
44
templates/fragments/account_page_tickets.html
Normal file
44
templates/fragments/account_page_tickets.html
Normal 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>· {{ ticket.calendar_name }}</span>
|
||||
{% endif %}
|
||||
{% if ticket.ticket_type_name %}
|
||||
<span>· {{ 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>
|
||||
33
templates/fragments/container_cards_entries.html
Normal file
33
templates/fragments/container_cards_entries.html
Normal 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 %}
|
||||
10
templates/fragments/container_nav_calendars.html
Normal file
10
templates/fragments/container_nav_calendars.html
Normal 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 %}
|
||||
28
templates/fragments/container_nav_entries.html
Normal file
28
templates/fragments/container_nav_entries.html
Normal 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 %}
|
||||
7
templates/macros/date.html
Normal file
7
templates/macros/date.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user