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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main, decoupling]
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: registry.rose-ash.com:5000
|
REGISTRY: registry.rose-ash.com:5000
|
||||||
IMAGE: events
|
IMAGE: events
|
||||||
REPO_DIR: /root/rose-ash/events
|
REPO_DIR: /root/rose-ash/events
|
||||||
COOP_DIR: /root/coop
|
COOP_DIR: /root/rose-ash
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -36,9 +36,23 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
ssh "root@$DEPLOY_HOST" "
|
ssh "root@$DEPLOY_HOST" "
|
||||||
cd ${{ env.REPO_DIR }}
|
cd ${{ env.REPO_DIR }}
|
||||||
git fetch origin main
|
git fetch origin ${{ github.ref_name }}
|
||||||
git reset --hard origin/main
|
git reset --hard origin/${{ github.ref_name }}
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
|
# Clean ALL sibling dirs (including stale self-copies from previous runs)
|
||||||
|
for sibling in blog market cart events federation; do
|
||||||
|
rm -rf \$sibling
|
||||||
|
done
|
||||||
|
# Copy non-self sibling models for cross-domain imports
|
||||||
|
for sibling in blog market cart events federation; do
|
||||||
|
[ \"\$sibling\" = \"${{ env.IMAGE }}\" ] && continue
|
||||||
|
repo=/root/rose-ash/\$sibling
|
||||||
|
if [ -d \$repo/.git ]; then
|
||||||
|
git -C \$repo fetch origin ${{ github.ref_name }} 2>/dev/null || true
|
||||||
|
mkdir -p \$sibling
|
||||||
|
git -C \$repo archive origin/${{ github.ref_name }} -- __init__.py models/ 2>/dev/null | tar -x -C \$sibling/ || true
|
||||||
|
fi
|
||||||
|
done
|
||||||
"
|
"
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build and push image
|
||||||
|
|||||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -1,3 +1,4 @@
|
|||||||
[submodule "shared_lib"]
|
[submodule "shared"]
|
||||||
path = shared_lib
|
path = shared
|
||||||
url = https://git.rose-ash.com/coop/shared.git
|
url = https://git.rose-ash.com/coop/shared.git
|
||||||
|
branch = decoupling
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ FROM python:3.11-slim AS base
|
|||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
PYTHONUNBUFFERED=1 \
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONPATH=/app \
|
||||||
PIP_NO_CACHE_DIR=1 \
|
PIP_NO_CACHE_DIR=1 \
|
||||||
APP_PORT=8000 \
|
APP_PORT=8000 \
|
||||||
APP_MODULE=app:app
|
APP_MODULE=app:app
|
||||||
@@ -16,14 +17,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
postgresql-client \
|
postgresql-client \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY shared_lib/requirements.txt ./requirements.txt
|
COPY shared/requirements.txt ./requirements.txt
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Link app blueprints into the shared library's namespace
|
|
||||||
RUN rm -rf /app/shared_lib/suma_browser/app/bp && ln -s /app/bp /app/shared_lib/suma_browser/app/bp
|
|
||||||
|
|
||||||
# ---------- Runtime setup ----------
|
# ---------- Runtime setup ----------
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|||||||
78
README.md
78
README.md
@@ -1,17 +1,28 @@
|
|||||||
# Events App
|
# Events App
|
||||||
|
|
||||||
Calendar and event booking service for the Rose Ash cooperative platform.
|
Calendar and event booking service for the Rose Ash cooperative platform. Manages calendars, time slots, calendar entries (bookings), tickets, and ticket types.
|
||||||
|
|
||||||
## Overview
|
## Architecture
|
||||||
|
|
||||||
The events app provides calendar-based event booking with flexible slot management.
|
One of five Quart microservices sharing a single PostgreSQL database:
|
||||||
It runs as a standalone Quart microservice, part of the multi-app coop architecture.
|
|
||||||
|
| App | Port | Domain |
|
||||||
|
|-----|------|--------|
|
||||||
|
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
|
||||||
|
| market | 8001 | Product browsing, Suma scraping |
|
||||||
|
| cart | 8002 | Shopping cart, checkout, orders |
|
||||||
|
| **events** | 8003 | Calendars, bookings, tickets |
|
||||||
|
| federation | 8004 | ActivityPub, fediverse social |
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app.py # Application factory and entry point
|
app.py # Application factory (create_base_app + blueprints)
|
||||||
events_api.py # Internal JSON API (server-to-server, CSRF-exempt)
|
path_setup.py # Adds project root + app dir to sys.path
|
||||||
|
config/app-config.yaml # App URLs, feature flags
|
||||||
|
models/ # Events-domain models
|
||||||
|
calendars.py # Calendar, CalendarEntry, CalendarSlot,
|
||||||
|
# TicketType, Ticket, CalendarEntryPost
|
||||||
bp/ # Blueprints
|
bp/ # Blueprints
|
||||||
calendars/ # Calendar listing
|
calendars/ # Calendar listing
|
||||||
calendar/ # Single calendar view and admin
|
calendar/ # Single calendar view and admin
|
||||||
@@ -22,27 +33,46 @@ bp/ # Blueprints
|
|||||||
slot/ # Single slot management
|
slot/ # Single slot management
|
||||||
ticket_types/ # Ticket type listing
|
ticket_types/ # Ticket type listing
|
||||||
ticket_type/ # Single ticket type management
|
ticket_type/ # Single ticket type management
|
||||||
templates/ # Jinja2 templates
|
tickets/ # Ticket listing
|
||||||
_types/ # Feature-specific templates
|
ticket_admin/ # Ticket administration
|
||||||
|
markets/ # Page-scoped marketplace views
|
||||||
|
payments/ # Payment-related views
|
||||||
|
services/ # register_domain_services() — wires calendar + market + cart
|
||||||
|
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
All events-domain models live in `models/calendars.py`:
|
||||||
|
|
||||||
|
| Model | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| **Calendar** | Container for entries, scoped to a page via `container_type + container_id` |
|
||||||
|
| **CalendarEntry** | A bookable event/time slot. Has `state` (pending/ordered/provisional), `cost`, ownership (`user_id`/`session_id`), and `order_id` (plain integer, no FK) |
|
||||||
|
| **CalendarSlot** | Recurring time bands (day-of-week + time range) within a calendar |
|
||||||
|
| **TicketType** | Named ticket categories with price and count |
|
||||||
|
| **Ticket** | Individual ticket with unique code, state, and `order_id` (plain integer, no FK) |
|
||||||
|
| **CalendarEntryPost** | Junction linking entries to content via `content_type + content_id` |
|
||||||
|
|
||||||
|
`order_id` on CalendarEntry and Ticket is a plain integer column — no FK constraint to the orders table. The cart app writes these values via service calls, not directly.
|
||||||
|
|
||||||
|
## Cross-Domain Communication
|
||||||
|
|
||||||
|
- `services.market.*` — marketplace queries for page views
|
||||||
|
- `services.cart.*` — cart summary for context processor
|
||||||
|
- `services.federation.*` — AP publishing for new entries
|
||||||
|
- `shared.services.navigation` — site navigation tree
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
This app does **not** run Alembic migrations on startup. Migrations are managed in the `shared/` submodule and run from the blog app's entrypoint.
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set required environment variables (see .env.example)
|
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||||
export APP_MODULE=app:app
|
export REDIS_URL=redis://localhost:6379/0
|
||||||
hypercorn app:app --bind 0.0.0.0:8000
|
export SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
|
hypercorn app:app --bind 0.0.0.0:8003
|
||||||
```
|
```
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t events .
|
|
||||||
docker run -p 8000:8000 --env-file .env events
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- This app does **not** run Alembic migrations. Database schema is managed by the blog app.
|
|
||||||
- Internal API endpoints under `/internal/events/` are used by the cart app for cross-service communication.
|
|
||||||
- Depends on shared packages (`shared/`, `models/`, `config/`) from the main coop monorepo.
|
|
||||||
|
|||||||
0
__init__.py
Normal file
0
__init__.py
Normal file
95
app.py
95
app.py
@@ -1,51 +1,58 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import path_setup # noqa: F401 # adds shared_lib to sys.path
|
import path_setup # noqa: F401 # adds shared/ to sys.path
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import g, abort
|
from quart import g, abort, request
|
||||||
from jinja2 import FileSystemLoader, ChoiceLoader
|
from jinja2 import FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from shared.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
|
|
||||||
from suma_browser.app.bp import register_calendars, register_markets, register_payments
|
from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments
|
||||||
|
|
||||||
|
|
||||||
async def events_context() -> dict:
|
async def events_context() -> dict:
|
||||||
"""
|
"""
|
||||||
Events app context processor.
|
Events app context processor.
|
||||||
|
|
||||||
- menu_items: fetched from coop internal API
|
- nav_tree_html: fetched from blog as fragment
|
||||||
- cart_count/cart_total: fetched from cart internal API
|
- cart_count/cart_total: via cart service (shared DB)
|
||||||
"""
|
"""
|
||||||
from shared.context import base_context
|
from shared.infrastructure.context import base_context
|
||||||
from shared.internal_api import get as api_get, dictobj
|
from shared.services.navigation import get_navigation_tree
|
||||||
|
from shared.services.registry import services
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
# Menu items from coop API (wrapped for attribute access in templates)
|
ctx["nav_tree_html"] = await fetch_fragment(
|
||||||
menu_data = await api_get("coop", "/internal/menu-items")
|
"blog", "nav-tree",
|
||||||
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
|
params={"app_name": "events", "path": request.path},
|
||||||
|
)
|
||||||
|
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||||
|
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||||
|
|
||||||
# Cart data from cart API
|
# Cart data via service (replaces cross-app HTTP API)
|
||||||
cart_data = await api_get("cart", "/internal/cart/summary", forward_session=True)
|
ident = current_cart_identity()
|
||||||
if cart_data:
|
summary = await services.cart.cart_summary(
|
||||||
ctx["cart_count"] = cart_data.get("count", 0)
|
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||||
ctx["cart_total"] = cart_data.get("total", 0)
|
)
|
||||||
else:
|
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
|
||||||
ctx["cart_count"] = 0
|
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
|
||||||
ctx["cart_total"] = 0
|
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> "Quart":
|
def create_app() -> "Quart":
|
||||||
from models.ghost_content import Post
|
from shared.services.registry import services
|
||||||
from models.calendars import Calendar
|
from services import register_domain_services
|
||||||
from models.market_place import MarketPlace
|
|
||||||
|
|
||||||
app = create_base_app("events", context_fn=events_context)
|
app = create_base_app(
|
||||||
|
"events",
|
||||||
|
context_fn=events_context,
|
||||||
|
domain_services_fn=register_domain_services,
|
||||||
|
)
|
||||||
|
|
||||||
# App-specific templates override shared templates
|
# App-specific templates override shared templates
|
||||||
app_templates = str(Path(__file__).resolve().parent / "templates")
|
app_templates = str(Path(__file__).resolve().parent / "templates")
|
||||||
@@ -54,6 +61,18 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
# All events: / — global view across all pages
|
||||||
|
app.register_blueprint(
|
||||||
|
register_all_events(),
|
||||||
|
url_prefix="/",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Page summary: /<slug>/ — upcoming events across all calendars
|
||||||
|
app.register_blueprint(
|
||||||
|
register_page(),
|
||||||
|
url_prefix="/<slug>",
|
||||||
|
)
|
||||||
|
|
||||||
# Calendars nested under post slug: /<slug>/calendars/...
|
# Calendars nested under post slug: /<slug>/calendars/...
|
||||||
app.register_blueprint(
|
app.register_blueprint(
|
||||||
register_calendars(),
|
register_calendars(),
|
||||||
@@ -72,6 +91,8 @@ def create_app() -> "Quart":
|
|||||||
url_prefix="/<slug>/payments",
|
url_prefix="/<slug>/payments",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
app.register_blueprint(register_fragments())
|
||||||
|
|
||||||
# --- Auto-inject slug into url_for() calls ---
|
# --- Auto-inject slug into url_for() calls ---
|
||||||
@app.url_value_preprocessor
|
@app.url_value_preprocessor
|
||||||
def pull_slug(endpoint, values):
|
def pull_slug(endpoint, values):
|
||||||
@@ -91,11 +112,7 @@ def create_app() -> "Quart":
|
|||||||
slug = getattr(g, "post_slug", None)
|
slug = getattr(g, "post_slug", None)
|
||||||
if not slug:
|
if not slug:
|
||||||
return
|
return
|
||||||
post = (
|
post = await services.blog.get_post_by_slug(g.s, slug)
|
||||||
await g.s.execute(
|
|
||||||
select(Post).where(Post.slug == slug)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if not post:
|
if not post:
|
||||||
abort(404)
|
abort(404)
|
||||||
g.post_data = {
|
g.post_data = {
|
||||||
@@ -115,20 +132,8 @@ def create_app() -> "Quart":
|
|||||||
if not post_data:
|
if not post_data:
|
||||||
return {}
|
return {}
|
||||||
post_id = post_data["post"]["id"]
|
post_id = post_data["post"]["id"]
|
||||||
calendars = (
|
calendars = await services.calendar.calendars_for_container(g.s, "page", post_id)
|
||||||
await g.s.execute(
|
markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
|
||||||
select(Calendar)
|
|
||||||
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
|
|
||||||
.order_by(Calendar.name.asc())
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
markets = (
|
|
||||||
await g.s.execute(
|
|
||||||
select(MarketPlace)
|
|
||||||
.where(MarketPlace.post_id == post_id, MarketPlace.deleted_at.is_(None))
|
|
||||||
.order_by(MarketPlace.name.asc())
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
return {
|
return {
|
||||||
**post_data,
|
**post_data,
|
||||||
"calendars": calendars,
|
"calendars": calendars,
|
||||||
@@ -143,10 +148,6 @@ def create_app() -> "Quart":
|
|||||||
from bp.ticket_admin.routes import register as register_ticket_admin
|
from bp.ticket_admin.routes import register as register_ticket_admin
|
||||||
app.register_blueprint(register_ticket_admin())
|
app.register_blueprint(register_ticket_admin())
|
||||||
|
|
||||||
# Internal API (server-to-server, CSRF-exempt)
|
|
||||||
from events_api import register as register_events_api
|
|
||||||
app.register_blueprint(register_events_api())
|
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
from .all_events.routes import register as register_all_events
|
||||||
from .calendars.routes import register as register_calendars
|
from .calendars.routes import register as register_calendars
|
||||||
from .markets.routes import register as register_markets
|
from .markets.routes import register as register_markets
|
||||||
from .payments.routes import register as register_payments
|
from .payments.routes import register as register_payments
|
||||||
|
from .page.routes import register as register_page
|
||||||
|
from .fragments import register_fragments
|
||||||
|
|||||||
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 shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def admin(calendar_slug: str, **kwargs):
|
async def admin(calendar_slug: str, **kwargs):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
# Determine which template to use based on request type
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from sqlalchemy import select
|
|||||||
from models.calendars import Calendar
|
from models.calendars import Calendar
|
||||||
|
|
||||||
from sqlalchemy.orm import selectinload, with_loader_criteria
|
from sqlalchemy.orm import selectinload, with_loader_criteria
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
|
|
||||||
from .admin.routes import register as register_admin
|
from .admin.routes import register as register_admin
|
||||||
from .services import get_visible_entries_for_period
|
from .services import get_visible_entries_for_period
|
||||||
@@ -23,17 +23,17 @@ from .services.calendar_view import (
|
|||||||
get_calendar_by_slug,
|
get_calendar_by_slug,
|
||||||
update_calendar_description,
|
update_calendar_description,
|
||||||
)
|
)
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
from ..slots.routes import register as register_slots
|
from ..slots.routes import register as register_slots
|
||||||
|
|
||||||
from models.calendars import CalendarSlot
|
from models.calendars import CalendarSlot
|
||||||
|
|
||||||
from suma_browser.app.bp.calendars.services.calendars import soft_delete
|
from bp.calendars.services.calendars import soft_delete
|
||||||
|
|
||||||
from suma_browser.app.bp.day.routes import register as register_day
|
from bp.day.routes import register as register_day
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ def register():
|
|||||||
@require_admin
|
@require_admin
|
||||||
@clear_cache(tag="calendars", tag_scope="all")
|
@clear_cache(tag="calendars", tag_scope="all")
|
||||||
async def delete(calendar_slug: str, **kwargs):
|
async def delete(calendar_slug: str, **kwargs):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
cal = g.calendar
|
cal = g.calendar
|
||||||
cal.deleted_at = datetime.now(timezone.utc)
|
cal.deleted_at = datetime.now(timezone.utc)
|
||||||
@@ -230,7 +230,7 @@ def register():
|
|||||||
cals = (
|
cals = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(Calendar)
|
select(Calendar)
|
||||||
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
|
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
|
||||||
.order_by(Calendar.name.asc())
|
.order_by(Calendar.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from sqlalchemy import select, update
|
from sqlalchemy import select, update
|
||||||
from models.calendars import CalendarEntry
|
from models.calendars import CalendarEntry
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
|
||||||
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:
|
async def adopt_session_entries_for_user(session, user_id: int, session_id: str | None) -> None:
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from sqlalchemy.orm import selectinload, with_loader_criteria
|
|||||||
|
|
||||||
from models.calendars import Calendar, CalendarSlot
|
from models.calendars import Calendar, CalendarSlot
|
||||||
|
|
||||||
|
|
||||||
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
|
def parse_int_arg(name: str, default: Optional[int] = None) -> Optional[int]:
|
||||||
"""Parse an integer query parameter from the request."""
|
"""Parse an integer query parameter from the request."""
|
||||||
val = request.args.get(name, "").strip()
|
val = request.args.get(name, "").strip()
|
||||||
@@ -71,7 +70,8 @@ async def get_calendar_by_post_and_slug(
|
|||||||
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
with_loader_criteria(CalendarSlot, CalendarSlot.deleted_at.is_(None)),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
Calendar.post_id == post_id,
|
Calendar.container_type == "page",
|
||||||
|
Calendar.container_id == post_id,
|
||||||
Calendar.slug == calendar_slug,
|
Calendar.slug == calendar_slug,
|
||||||
Calendar.deleted_at.is_(None),
|
Calendar.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from models.calendars import CalendarSlot
|
from models.calendars import CalendarSlot
|
||||||
|
|
||||||
|
|
||||||
class SlotError(ValueError):
|
class SlotError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from models.calendars import CalendarEntry
|
from models.calendars import CalendarEntry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VisibleEntries:
|
class VisibleEntries:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -3,26 +3,29 @@ from datetime import datetime, timezone
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from quart import (
|
from quart import (
|
||||||
request, render_template, make_response, Blueprint, g, redirect, url_for, jsonify
|
request, render_template, render_template_string, make_response,
|
||||||
|
Blueprint, g, redirect, url_for, jsonify,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy import update
|
from sqlalchemy import update, func as sa_func
|
||||||
|
|
||||||
from models.calendars import CalendarEntry
|
from models.calendars import CalendarEntry
|
||||||
|
|
||||||
|
|
||||||
from .services.entries import (
|
from .services.entries import (
|
||||||
|
|
||||||
add_entry as svc_add_entry,
|
add_entry as svc_add_entry,
|
||||||
)
|
)
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
|
|
||||||
from suma_browser.app.bp.calendar_entry.routes import register as register_calendar_entry
|
from bp.calendar_entry.routes import register as register_calendar_entry
|
||||||
|
|
||||||
|
|
||||||
from models.calendars import CalendarSlot
|
from models.calendars import CalendarSlot
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
|
||||||
@@ -203,8 +206,36 @@ def register():
|
|||||||
entry.ticket_price = ticket_price
|
entry.ticket_price = ticket_price
|
||||||
entry.ticket_count = ticket_count
|
entry.ticket_count = ticket_count
|
||||||
|
|
||||||
|
# Count pending calendar entries from local session (sees the just-added entry)
|
||||||
|
user_id = getattr(g, "user", None) and g.user.id
|
||||||
|
cal_filters = [
|
||||||
|
CalendarEntry.deleted_at.is_(None),
|
||||||
|
CalendarEntry.state == "pending",
|
||||||
|
]
|
||||||
|
if user_id:
|
||||||
|
cal_filters.append(CalendarEntry.user_id == user_id)
|
||||||
|
|
||||||
|
cal_count = await g.s.scalar(
|
||||||
|
select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
# Get product cart count via service (same DB, no HTTP needed)
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from shared.services.registry import services
|
||||||
|
ident = current_cart_identity()
|
||||||
|
cart_summary = await services.cart.cart_summary(
|
||||||
|
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
product_count = cart_summary.count
|
||||||
|
total_count = product_count + cal_count
|
||||||
|
|
||||||
html = await render_template("_types/day/_main_panel.html")
|
html = await render_template("_types/day/_main_panel.html")
|
||||||
return await make_response(html, 200)
|
mini_html = await render_template_string(
|
||||||
|
'{% from "_types/cart/_mini.html" import mini with context %}'
|
||||||
|
'{{ mini(oob="true") }}',
|
||||||
|
cart_count=total_count,
|
||||||
|
)
|
||||||
|
return await make_response(html + mini_html, 200)
|
||||||
|
|
||||||
@bp.get("/add/")
|
@bp.get("/add/")
|
||||||
async def add_form(day: int, month: int, year: int, **kwargs):
|
async def add_form(day: int, month: int, year: int, **kwargs):
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ from sqlalchemy import select, and_, or_
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.calendars import Calendar, CalendarEntry
|
from models.calendars import Calendar, CalendarEntry
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from suma_browser.app.errors import AppError
|
from shared.browser.app.errors import AppError
|
||||||
|
|
||||||
class CalendarError(AppError):
|
class CalendarError(AppError):
|
||||||
"""Base error for calendar service operations."""
|
"""Base error for calendar service operations."""
|
||||||
@@ -93,6 +94,24 @@ async def add_entry(
|
|||||||
)
|
)
|
||||||
sess.add(entry)
|
sess.add(entry)
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
|
|
||||||
|
# Publish to federation inline
|
||||||
|
if entry.user_id:
|
||||||
|
from shared.services.federation_publish import try_publish
|
||||||
|
await try_publish(
|
||||||
|
sess,
|
||||||
|
user_id=entry.user_id,
|
||||||
|
activity_type="Create",
|
||||||
|
object_type="Event",
|
||||||
|
object_data={
|
||||||
|
"name": entry.name or "",
|
||||||
|
"startTime": entry.start_at.isoformat() if entry.start_at else "",
|
||||||
|
"endTime": entry.end_at.isoformat() if entry.end_at else "",
|
||||||
|
},
|
||||||
|
source_type="CalendarEntry",
|
||||||
|
source_id=entry.id,
|
||||||
|
)
|
||||||
|
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +139,8 @@ async def list_entries(
|
|||||||
await sess.execute(
|
await sess.execute(
|
||||||
select(Calendar.id)
|
select(Calendar.id)
|
||||||
.where(
|
.where(
|
||||||
Calendar.post_id == post_id,
|
Calendar.container_type == "page",
|
||||||
|
Calendar.container_id == post_id,
|
||||||
Calendar.slug == calendar_slug,
|
Calendar.slug == calendar_slug,
|
||||||
Calendar.deleted_at.is_(None),
|
Calendar.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from quart import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -15,7 +15,7 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def admin(entry_id: int, **kwargs):
|
async def admin(entry_id: int, **kwargs):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
# Determine which template to use based on request type
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from sqlalchemy import select, update
|
|||||||
|
|
||||||
from models.calendars import CalendarEntry, CalendarSlot
|
from models.calendars import CalendarEntry, CalendarSlot
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -27,6 +27,8 @@ from datetime import datetime, timezone
|
|||||||
import math
|
import math
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
|
|
||||||
from ..ticket_types.routes import register as register_ticket_types
|
from ..ticket_types.routes import register as register_ticket_types
|
||||||
|
|
||||||
from .admin.routes import register as register_admin
|
from .admin.routes import register as register_admin
|
||||||
@@ -142,7 +144,7 @@ def register():
|
|||||||
calendars = (
|
calendars = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(Calendar)
|
select(Calendar)
|
||||||
.where(Calendar.post_id == post.id, Calendar.deleted_at.is_(None))
|
.where(Calendar.container_type == "page", Calendar.container_id == post.id, Calendar.deleted_at.is_(None))
|
||||||
.order_by(Calendar.name.asc())
|
.order_by(Calendar.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
@@ -160,13 +162,22 @@ def register():
|
|||||||
|
|
||||||
@bp.context_processor
|
@bp.context_processor
|
||||||
async def inject_root():
|
async def inject_root():
|
||||||
from ..tickets.services.tickets import get_available_ticket_count
|
from ..tickets.services.tickets import (
|
||||||
|
get_available_ticket_count,
|
||||||
|
get_sold_ticket_count,
|
||||||
|
get_user_reserved_count,
|
||||||
|
)
|
||||||
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
view_args = getattr(request, "view_args", {}) or {}
|
view_args = getattr(request, "view_args", {}) or {}
|
||||||
entry_id = view_args.get("entry_id")
|
entry_id = view_args.get("entry_id")
|
||||||
calendar_entry = None
|
calendar_entry = None
|
||||||
entry_posts = []
|
entry_posts = []
|
||||||
ticket_remaining = None
|
ticket_remaining = None
|
||||||
|
ticket_sold_count = 0
|
||||||
|
user_ticket_count = 0
|
||||||
|
user_ticket_counts_by_type = {}
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
select(CalendarEntry)
|
select(CalendarEntry)
|
||||||
@@ -174,6 +185,7 @@ def register():
|
|||||||
CalendarEntry.id == entry_id,
|
CalendarEntry.id == entry_id,
|
||||||
CalendarEntry.deleted_at.is_(None),
|
CalendarEntry.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
|
.options(selectinload(CalendarEntry.ticket_types))
|
||||||
)
|
)
|
||||||
result = await g.s.execute(stmt)
|
result = await g.s.execute(stmt)
|
||||||
calendar_entry = result.scalar_one_or_none()
|
calendar_entry = result.scalar_one_or_none()
|
||||||
@@ -190,19 +202,53 @@ def register():
|
|||||||
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
|
entry_posts = await get_entry_posts(g.s, calendar_entry.id)
|
||||||
# Get ticket availability
|
# Get ticket availability
|
||||||
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
|
ticket_remaining = await get_available_ticket_count(g.s, calendar_entry.id)
|
||||||
|
# Get sold count
|
||||||
|
ticket_sold_count = await get_sold_ticket_count(g.s, calendar_entry.id)
|
||||||
|
# Get current user's reserved count
|
||||||
|
ident = current_cart_identity()
|
||||||
|
user_ticket_count = await get_user_reserved_count(
|
||||||
|
g.s, calendar_entry.id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
# Per-type counts for multi-type entries
|
||||||
|
if calendar_entry.ticket_types:
|
||||||
|
for tt in calendar_entry.ticket_types:
|
||||||
|
if tt.deleted_at is None:
|
||||||
|
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
||||||
|
g.s, calendar_entry.id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch container nav from market (skip calendar — we're on a calendar page)
|
||||||
|
container_nav_html = ""
|
||||||
|
post_data = getattr(g, "post_data", None)
|
||||||
|
if post_data:
|
||||||
|
post_id = post_data["post"]["id"]
|
||||||
|
post_slug = post_data["post"]["slug"]
|
||||||
|
container_nav_html = await fetch_fragment("market", "container-nav", params={
|
||||||
|
"container_type": "page",
|
||||||
|
"container_id": str(post_id),
|
||||||
|
"post_slug": post_slug,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"entry": calendar_entry,
|
"entry": calendar_entry,
|
||||||
"entry_posts": entry_posts,
|
"entry_posts": entry_posts,
|
||||||
"ticket_remaining": ticket_remaining,
|
"ticket_remaining": ticket_remaining,
|
||||||
|
"ticket_sold_count": ticket_sold_count,
|
||||||
|
"user_ticket_count": user_ticket_count,
|
||||||
|
"user_ticket_counts_by_type": user_ticket_counts_by_type,
|
||||||
|
"container_nav_html": container_nav_html,
|
||||||
}
|
}
|
||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def get(entry_id: int, **rest):
|
async def get(entry_id: int, **rest):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# TODO: Create _main_panel.html and _oob_elements.html for optimized HTMX
|
# Full template for both HTMX and normal requests
|
||||||
# For now, render full template for both HTMX and normal requests
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
# Normal browser request: full page with layout
|
# Normal browser request: full page with layout
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
@@ -496,8 +542,8 @@ def register():
|
|||||||
|
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
# Return updated entry view
|
# Return just the tickets fragment (targeted by hx-target="#entry-tickets-...")
|
||||||
html = await render_template("_types/entry/index.html")
|
html = await render_template("_types/entry/_tickets.html")
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
@bp.get("/posts/search/")
|
@bp.get("/posts/search/")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
from models.calendars import CalendarEntry, CalendarEntryPost
|
from models.calendars import CalendarEntry, CalendarEntryPost
|
||||||
from models.ghost_content import Post
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
async def add_post_to_entry(
|
async def add_post_to_entry(
|
||||||
@@ -28,9 +28,7 @@ async def add_post_to_entry(
|
|||||||
return False, "Calendar entry not found"
|
return False, "Calendar entry not found"
|
||||||
|
|
||||||
# Check if post exists
|
# Check if post exists
|
||||||
post = await session.scalar(
|
post = await services.blog.get_post_by_id(session, post_id)
|
||||||
select(Post).where(Post.id == post_id)
|
|
||||||
)
|
|
||||||
if not post:
|
if not post:
|
||||||
return False, "Post not found"
|
return False, "Post not found"
|
||||||
|
|
||||||
@@ -38,7 +36,8 @@ async def add_post_to_entry(
|
|||||||
existing = await session.scalar(
|
existing = await session.scalar(
|
||||||
select(CalendarEntryPost).where(
|
select(CalendarEntryPost).where(
|
||||||
CalendarEntryPost.entry_id == entry_id,
|
CalendarEntryPost.entry_id == entry_id,
|
||||||
CalendarEntryPost.post_id == post_id,
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.content_id == post_id,
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
CalendarEntryPost.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -49,7 +48,8 @@ async def add_post_to_entry(
|
|||||||
# Create association
|
# Create association
|
||||||
association = CalendarEntryPost(
|
association = CalendarEntryPost(
|
||||||
entry_id=entry_id,
|
entry_id=entry_id,
|
||||||
post_id=post_id
|
content_type="post",
|
||||||
|
content_id=post_id
|
||||||
)
|
)
|
||||||
session.add(association)
|
session.add(association)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@@ -70,7 +70,8 @@ async def remove_post_from_entry(
|
|||||||
association = await session.scalar(
|
association = await session.scalar(
|
||||||
select(CalendarEntryPost).where(
|
select(CalendarEntryPost).where(
|
||||||
CalendarEntryPost.entry_id == entry_id,
|
CalendarEntryPost.entry_id == entry_id,
|
||||||
CalendarEntryPost.post_id == post_id,
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.content_id == post_id,
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
CalendarEntryPost.deleted_at.is_(None)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -88,20 +89,22 @@ async def remove_post_from_entry(
|
|||||||
async def get_entry_posts(
|
async def get_entry_posts(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
entry_id: int
|
entry_id: int
|
||||||
) -> list[Post]:
|
) -> list:
|
||||||
"""
|
"""
|
||||||
Get all posts associated with a calendar entry.
|
Get all posts (as PostDTOs) associated with a calendar entry.
|
||||||
"""
|
"""
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Post)
|
select(CalendarEntryPost.content_id).where(
|
||||||
.join(CalendarEntryPost)
|
|
||||||
.where(
|
|
||||||
CalendarEntryPost.entry_id == entry_id,
|
CalendarEntryPost.entry_id == entry_id,
|
||||||
CalendarEntryPost.deleted_at.is_(None)
|
CalendarEntryPost.content_type == "post",
|
||||||
|
CalendarEntryPost.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(Post.title)
|
|
||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
post_ids = list(result.scalars().all())
|
||||||
|
if not post_ids:
|
||||||
|
return []
|
||||||
|
posts = await services.blog.get_posts_by_ids(session, post_ids)
|
||||||
|
return sorted(posts, key=lambda p: (p.title or ""))
|
||||||
|
|
||||||
|
|
||||||
async def search_posts(
|
async def search_posts(
|
||||||
@@ -109,29 +112,10 @@ async def search_posts(
|
|||||||
query: str,
|
query: str,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
per_page: int = 10
|
per_page: int = 10
|
||||||
) -> tuple[list[Post], int]:
|
) -> tuple[list, int]:
|
||||||
"""
|
"""
|
||||||
Search for posts by title with pagination.
|
Search for posts by title with pagination.
|
||||||
If query is empty, returns all posts in published order.
|
If query is empty, returns all posts in published order.
|
||||||
Returns (posts, total_count).
|
Returns (post_dtos, total_count).
|
||||||
"""
|
"""
|
||||||
# Build base query
|
return await services.blog.search_posts(session, query, page, per_page)
|
||||||
if query:
|
|
||||||
# Search by title
|
|
||||||
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
|
|
||||||
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
|
|
||||||
else:
|
|
||||||
# All posts in published order (newest first)
|
|
||||||
count_stmt = select(func.count(Post.id))
|
|
||||||
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
|
|
||||||
|
|
||||||
# Count total
|
|
||||||
count_result = await session.execute(count_stmt)
|
|
||||||
total = count_result.scalar() or 0
|
|
||||||
|
|
||||||
# Get paginated results
|
|
||||||
offset = (page - 1) * per_page
|
|
||||||
result = await session.execute(
|
|
||||||
posts_stmt.limit(per_page).offset(offset)
|
|
||||||
)
|
|
||||||
return list(result.scalars().all()), total
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from models.calendars import CalendarEntry
|
from models.calendars import CalendarEntry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def update_ticket_config(
|
async def update_ticket_config(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
@@ -82,6 +83,5 @@ async def get_available_tickets(
|
|||||||
if entry.ticket_count is None:
|
if entry.ticket_count is None:
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
# TODO: Subtract booked tickets when ticket booking is implemented
|
# Returns total count (booked tickets not yet subtracted)
|
||||||
# For now, just return the total count
|
|
||||||
return entry.ticket_count, None
|
return entry.ticket_count, None
|
||||||
|
|||||||
@@ -7,16 +7,17 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from models.calendars import Calendar
|
from models.calendars import Calendar
|
||||||
|
|
||||||
|
|
||||||
from .services.calendars import (
|
from .services.calendars import (
|
||||||
create_calendar as svc_create_calendar,
|
create_calendar as svc_create_calendar,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..calendar.routes import register as register_calendar
|
from ..calendar.routes import register as register_calendar
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -78,7 +79,7 @@ def register():
|
|||||||
cals = (
|
cals = (
|
||||||
await g.s.execute(
|
await g.s.execute(
|
||||||
select(Calendar)
|
select(Calendar)
|
||||||
.where(Calendar.post_id == post_id, Calendar.deleted_at.is_(None))
|
.where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.deleted_at.is_(None))
|
||||||
.order_by(Calendar.name.asc())
|
.order_by(Calendar.name.asc())
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.calendars import Calendar
|
from models.calendars import Calendar
|
||||||
from models.ghost_content import Post # for FK existence checks
|
from shared.services.registry import services
|
||||||
|
from shared.services.relationships import attach_child, detach_child
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import re
|
import re
|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ import re
|
|||||||
class CalendarError(ValueError):
|
class CalendarError(ValueError):
|
||||||
"""Base error for calendar service operations."""
|
"""Base error for calendar service operations."""
|
||||||
|
|
||||||
from suma_browser.app.utils import (
|
from shared.browser.app.utils import (
|
||||||
utcnow
|
utcnow
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,12 +49,15 @@ def slugify(value: str, max_len: int = 255) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool:
|
async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool:
|
||||||
|
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||||
|
if not post:
|
||||||
|
return False
|
||||||
|
|
||||||
cal = (
|
cal = (
|
||||||
await sess.execute(
|
await sess.execute(
|
||||||
select(Calendar)
|
select(Calendar).where(
|
||||||
.join(Post, Calendar.post_id == Post.id)
|
Calendar.container_type == "page",
|
||||||
.where(
|
Calendar.container_id == post.id,
|
||||||
Post.slug == post_slug,
|
|
||||||
Calendar.slug == calendar_slug,
|
Calendar.slug == calendar_slug,
|
||||||
Calendar.deleted_at.is_(None),
|
Calendar.deleted_at.is_(None),
|
||||||
)
|
)
|
||||||
@@ -65,6 +69,7 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
|
|||||||
|
|
||||||
cal.deleted_at = utcnow()
|
cal.deleted_at = utcnow()
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
|
await detach_child(sess, "page", cal.container_id, "calendar", cal.id)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar:
|
async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar:
|
||||||
@@ -79,7 +84,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
|||||||
slug=slugify(name)
|
slug=slugify(name)
|
||||||
|
|
||||||
# Ensure post exists (avoid silent FK errors in some DBs)
|
# Ensure post exists (avoid silent FK errors in some DBs)
|
||||||
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
|
post = await services.blog.get_post_by_id(sess, post_id)
|
||||||
if not post:
|
if not post:
|
||||||
raise CalendarError(f"Post {post_id} does not exist.")
|
raise CalendarError(f"Post {post_id} does not exist.")
|
||||||
|
|
||||||
@@ -89,7 +94,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
|||||||
|
|
||||||
# Look for existing (including soft-deleted)
|
# Look for existing (including soft-deleted)
|
||||||
q = await sess.execute(
|
q = await sess.execute(
|
||||||
select(Calendar).where(Calendar.post_id == post_id, Calendar.name == name)
|
select(Calendar).where(Calendar.container_type == "page", Calendar.container_id == post_id, Calendar.name == name)
|
||||||
)
|
)
|
||||||
existing = q.scalar_one_or_none()
|
existing = q.scalar_one_or_none()
|
||||||
|
|
||||||
@@ -97,12 +102,14 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
|||||||
if existing.deleted_at is not None:
|
if existing.deleted_at is not None:
|
||||||
existing.deleted_at = None # revive
|
existing.deleted_at = None # revive
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
|
await attach_child(sess, "page", post_id, "calendar", existing.id)
|
||||||
return existing
|
return existing
|
||||||
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
|
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
|
||||||
|
|
||||||
cal = Calendar(post_id=post_id, name=name, slug=slug)
|
cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
|
||||||
sess.add(cal)
|
sess.add(cal)
|
||||||
await sess.flush()
|
await sess.flush()
|
||||||
|
await attach_child(sess, "page", post_id, "calendar", cal.id)
|
||||||
return cal
|
return cal
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from quart import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -15,7 +15,7 @@ def register():
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
@require_admin
|
@require_admin
|
||||||
async def admin(year: int, month: int, day: int, **kwargs):
|
async def admin(year: int, month: int, day: int, **kwargs):
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Determine which template to use based on request type
|
# Determine which template to use based on request type
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ from quart import (
|
|||||||
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
request, render_template, make_response, Blueprint, g, abort, session as qsession
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.bp.calendar.services import get_visible_entries_for_period
|
from bp.calendar.services import get_visible_entries_for_period
|
||||||
|
|
||||||
from suma_browser.app.bp.calendar_entries.routes import register as register_calendar_entries
|
from bp.calendar_entries.routes import register as register_calendar_entries
|
||||||
from .admin.routes import register as register_admin
|
from .admin.routes import register as register_admin
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page
|
from shared.browser.app.redis_cacher import cache_page
|
||||||
|
from shared.infrastructure.fragments import fetch_fragment
|
||||||
|
|
||||||
from models.calendars import CalendarSlot # add this import
|
from models.calendars import CalendarSlot # add this import
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -76,6 +78,18 @@ def register():
|
|||||||
result = await g.s.execute(stmt)
|
result = await g.s.execute(stmt)
|
||||||
day_slots = list(result.scalars())
|
day_slots = list(result.scalars())
|
||||||
|
|
||||||
|
# Fetch container nav from market (skip calendar — we're on a calendar page)
|
||||||
|
container_nav_html = ""
|
||||||
|
post_data = getattr(g, "post_data", None)
|
||||||
|
if post_data:
|
||||||
|
post_id = post_data["post"]["id"]
|
||||||
|
post_slug = post_data["post"]["slug"]
|
||||||
|
container_nav_html = await fetch_fragment("market", "container-nav", params={
|
||||||
|
"container_type": "page",
|
||||||
|
"container_id": str(post_id),
|
||||||
|
"post_slug": post_slug,
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"qsession": qsession,
|
"qsession": qsession,
|
||||||
"day_date": day_date,
|
"day_date": day_date,
|
||||||
@@ -85,7 +99,8 @@ def register():
|
|||||||
"day_entries": visible.merged_entries,
|
"day_entries": visible.merged_entries,
|
||||||
"user_entries": visible.user_entries,
|
"user_entries": visible.user_entries,
|
||||||
"confirmed_entries": visible.confirmed_entries,
|
"confirmed_entries": visible.confirmed_entries,
|
||||||
"day_slots": day_slots, # <-- NEW
|
"day_slots": day_slots,
|
||||||
|
"container_nav_html": container_nav_html,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -117,5 +132,23 @@ def register():
|
|||||||
)
|
)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
@bp.get("/w/<widget_domain>/")
|
||||||
|
async def widget_paginate(widget_domain: str, **kwargs):
|
||||||
|
"""Proxies paginated widget requests to the appropriate fragment provider."""
|
||||||
|
page = int(request.args.get("page", 1))
|
||||||
|
post_data = getattr(g, "post_data", None)
|
||||||
|
if not post_data:
|
||||||
|
abort(404)
|
||||||
|
post_id = post_data["post"]["id"]
|
||||||
|
post_slug = post_data["post"]["slug"]
|
||||||
|
|
||||||
|
if widget_domain == "market":
|
||||||
|
html = await fetch_fragment("market", "container-nav", params={
|
||||||
|
"container_type": "page",
|
||||||
|
"container_id": str(post_id),
|
||||||
|
"post_slug": post_slug,
|
||||||
|
})
|
||||||
|
return await make_response(html or "")
|
||||||
|
abort(404)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
1
bp/fragments/__init__.py
Normal file
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 (
|
from quart import (
|
||||||
request, render_template, make_response, Blueprint, g
|
request, render_template, make_response, Blueprint, g
|
||||||
)
|
)
|
||||||
from sqlalchemy import select
|
|
||||||
|
|
||||||
from models.market_place import MarketPlace
|
|
||||||
|
|
||||||
from .services.markets import (
|
from .services.markets import (
|
||||||
create_market as svc_create_market,
|
create_market as svc_create_market,
|
||||||
soft_delete as svc_soft_delete,
|
soft_delete as svc_soft_delete,
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.redis_cacher import cache_page, clear_cache
|
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.market_place import MarketPlace
|
from shared.contracts.dtos import MarketPlaceDTO
|
||||||
from models.ghost_content import Post
|
from shared.services.registry import services
|
||||||
from suma_browser.app.utils import utcnow
|
|
||||||
|
|
||||||
|
|
||||||
class MarketError(ValueError):
|
class MarketError(ValueError):
|
||||||
@@ -28,7 +26,7 @@ def slugify(value: str, max_len: int = 255) -> str:
|
|||||||
return value or "market"
|
return value or "market"
|
||||||
|
|
||||||
|
|
||||||
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlace:
|
async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPlaceDTO:
|
||||||
"""
|
"""
|
||||||
Create a market for a page. Name must be unique per page.
|
Create a market for a page. Name must be unique per page.
|
||||||
If a market with the same (post_id, slug) exists but is soft-deleted,
|
If a market with the same (post_id, slug) exists but is soft-deleted,
|
||||||
@@ -39,47 +37,21 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
|||||||
raise MarketError("Market name must not be empty.")
|
raise MarketError("Market name must not be empty.")
|
||||||
slug = slugify(name)
|
slug = slugify(name)
|
||||||
|
|
||||||
post = (await sess.execute(select(Post).where(Post.id == post_id))).scalar_one_or_none()
|
post = await services.blog.get_post_by_id(sess, post_id)
|
||||||
if not post:
|
if not post:
|
||||||
raise MarketError(f"Post {post_id} does not exist.")
|
raise MarketError(f"Post {post_id} does not exist.")
|
||||||
if not post.is_page:
|
if not post.is_page:
|
||||||
raise MarketError("Markets can only be created on pages, not posts.")
|
raise MarketError("Markets can only be created on pages, not posts.")
|
||||||
|
|
||||||
# Look for existing (including soft-deleted)
|
try:
|
||||||
existing = (await sess.execute(
|
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
|
||||||
select(MarketPlace).where(MarketPlace.post_id == post_id, MarketPlace.slug == slug)
|
except ValueError as e:
|
||||||
)).scalar_one_or_none()
|
raise MarketError(str(e)) from e
|
||||||
|
|
||||||
if existing:
|
|
||||||
if existing.deleted_at is not None:
|
|
||||||
existing.deleted_at = None
|
|
||||||
existing.name = name
|
|
||||||
await sess.flush()
|
|
||||||
return existing
|
|
||||||
raise MarketError(f'Market with slug "{slug}" already exists for this page.')
|
|
||||||
|
|
||||||
market = MarketPlace(post_id=post_id, name=name, slug=slug)
|
|
||||||
sess.add(market)
|
|
||||||
await sess.flush()
|
|
||||||
return market
|
|
||||||
|
|
||||||
|
|
||||||
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
|
||||||
market = (
|
post = await services.blog.get_post_by_slug(sess, post_slug)
|
||||||
await sess.execute(
|
if not post:
|
||||||
select(MarketPlace)
|
|
||||||
.join(Post, MarketPlace.post_id == Post.id)
|
|
||||||
.where(
|
|
||||||
Post.slug == post_slug,
|
|
||||||
MarketPlace.slug == market_slug,
|
|
||||||
MarketPlace.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
|
|
||||||
if not market:
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
market.deleted_at = utcnow()
|
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
|
||||||
await sess.flush()
|
|
||||||
return True
|
|
||||||
|
|||||||
0
bp/page/__init__.py
Normal file
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 sqlalchemy import select
|
||||||
|
|
||||||
from models.page_config import PageConfig
|
from shared.models.page_config import PageConfig
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@@ -26,7 +26,7 @@ def register():
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
pc = (await g.s.execute(
|
pc = (await g.s.execute(
|
||||||
select(PageConfig).where(PageConfig.post_id == post_id)
|
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -55,10 +55,10 @@ def register():
|
|||||||
return await make_response("Post not found", 404)
|
return await make_response("Post not found", 404)
|
||||||
|
|
||||||
pc = (await g.s.execute(
|
pc = (await g.s.execute(
|
||||||
select(PageConfig).where(PageConfig.post_id == post_id)
|
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id)
|
||||||
)).scalar_one_or_none()
|
)).scalar_one_or_none()
|
||||||
if pc is None:
|
if pc is None:
|
||||||
pc = PageConfig(post_id=post_id, features={})
|
pc = PageConfig(container_type="page", container_id=post_id, features={})
|
||||||
g.s.add(pc)
|
g.s.add(pc)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ from quart import (
|
|||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
from .services.slot import (
|
from .services.slot import (
|
||||||
update_slot as svc_update_slot,
|
update_slot as svc_update_slot,
|
||||||
@@ -19,11 +19,11 @@ from ..slots.services.slots import (
|
|||||||
list_slots as svc_list_slots,
|
list_slots as svc_list_slots,
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.utils import (
|
from shared.browser.app.utils import (
|
||||||
parse_time,
|
parse_time,
|
||||||
parse_cost
|
parse_cost
|
||||||
)
|
)
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from models.calendars import CalendarSlot
|
from models.calendars import CalendarSlot
|
||||||
|
|
||||||
|
|
||||||
class SlotError(ValueError):
|
class SlotError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ from quart import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
from .services.slots import (
|
from .services.slots import (
|
||||||
list_slots as svc_list_slots,
|
list_slots as svc_list_slots,
|
||||||
@@ -15,11 +15,11 @@ from .services.slots import (
|
|||||||
|
|
||||||
from ..slot.routes import register as register_slot
|
from ..slot.routes import register as register_slot
|
||||||
|
|
||||||
from suma_browser.app.utils import (
|
from shared.browser.app.utils import (
|
||||||
parse_time,
|
parse_time,
|
||||||
parse_cost
|
parse_cost
|
||||||
)
|
)
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from models.calendars import CalendarSlot
|
from models.calendars import CalendarSlot
|
||||||
|
|
||||||
|
|
||||||
class SlotError(ValueError):
|
class SlotError(ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ from sqlalchemy import select, func
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.calendars import CalendarEntry, Ticket, TicketType
|
from models.calendars import CalendarEntry, Ticket, TicketType
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
from ..tickets.services.tickets import (
|
from ..tickets.services.tickets import (
|
||||||
get_ticket_by_code,
|
get_ticket_by_code,
|
||||||
@@ -37,7 +37,7 @@ def register() -> Blueprint:
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def dashboard():
|
async def dashboard():
|
||||||
"""Ticket admin dashboard with QR scanner and recent tickets."""
|
"""Ticket admin dashboard with QR scanner and recent tickets."""
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
# Get recent tickets
|
# Get recent tickets
|
||||||
result = await g.s.execute(
|
result = await g.s.execute(
|
||||||
@@ -89,7 +89,7 @@ def register() -> Blueprint:
|
|||||||
@require_admin
|
@require_admin
|
||||||
async def entry_tickets(entry_id: int):
|
async def entry_tickets(entry_id: int):
|
||||||
"""List all tickets for a specific calendar entry."""
|
"""List all tickets for a specific calendar entry."""
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
entry = await g.s.scalar(
|
entry = await g.s.scalar(
|
||||||
select(CalendarEntry)
|
select(CalendarEntry)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from quart import (
|
|||||||
request, render_template, make_response, Blueprint, g, jsonify
|
request, render_template, make_response, Blueprint, g, jsonify
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
from .services.ticket import (
|
from .services.ticket import (
|
||||||
get_ticket_type as svc_get_ticket_type,
|
get_ticket_type as svc_get_ticket_type,
|
||||||
@@ -16,7 +16,7 @@ from .services.ticket import (
|
|||||||
from ..ticket_types.services.tickets import (
|
from ..ticket_types.services.tickets import (
|
||||||
list_ticket_types as svc_list_ticket_types,
|
list_ticket_types as svc_list_ticket_types,
|
||||||
)
|
)
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from models.calendars import TicketType
|
from models.calendars import TicketType
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ from quart import (
|
|||||||
request, render_template, make_response, Blueprint, g, jsonify
|
request, render_template, make_response, Blueprint, g, jsonify
|
||||||
)
|
)
|
||||||
|
|
||||||
from suma_browser.app.authz import require_admin
|
from shared.browser.app.authz import require_admin
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
from .services.tickets import (
|
from .services.tickets import (
|
||||||
list_ticket_types as svc_list_ticket_types,
|
list_ticket_types as svc_list_ticket_types,
|
||||||
@@ -14,7 +14,7 @@ from .services.tickets import (
|
|||||||
|
|
||||||
from ..ticket_type.routes import register as register_ticket_type
|
from ..ticket_type.routes import register as register_ticket_type
|
||||||
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.calendars import TicketType
|
from models.calendars import TicketType
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Routes:
|
|||||||
GET /tickets/ — My tickets list
|
GET /tickets/ — My tickets list
|
||||||
GET /tickets/<code>/ — Ticket detail with QR code
|
GET /tickets/<code>/ — Ticket detail with QR code
|
||||||
POST /tickets/buy/ — Purchase tickets for an entry
|
POST /tickets/buy/ — Purchase tickets for an entry
|
||||||
|
POST /tickets/adjust/ — Adjust ticket quantity (+/-)
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,8 +18,8 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.calendars import CalendarEntry
|
from models.calendars import CalendarEntry
|
||||||
from shared.cart_identity import current_cart_identity
|
from shared.infrastructure.cart_identity import current_cart_identity
|
||||||
from suma_browser.app.redis_cacher import clear_cache
|
from shared.browser.app.redis_cacher import clear_cache
|
||||||
|
|
||||||
from .services.tickets import (
|
from .services.tickets import (
|
||||||
create_ticket,
|
create_ticket,
|
||||||
@@ -26,6 +27,9 @@ from .services.tickets import (
|
|||||||
get_user_tickets,
|
get_user_tickets,
|
||||||
get_available_ticket_count,
|
get_available_ticket_count,
|
||||||
get_tickets_for_entry,
|
get_tickets_for_entry,
|
||||||
|
get_sold_ticket_count,
|
||||||
|
get_user_reserved_count,
|
||||||
|
cancel_latest_reserved_ticket,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -37,7 +41,7 @@ def register() -> Blueprint:
|
|||||||
@bp.get("/")
|
@bp.get("/")
|
||||||
async def my_tickets():
|
async def my_tickets():
|
||||||
"""List all tickets for the current user/session."""
|
"""List all tickets for the current user/session."""
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
ident = current_cart_identity()
|
ident = current_cart_identity()
|
||||||
tickets = await get_user_tickets(
|
tickets = await get_user_tickets(
|
||||||
@@ -62,7 +66,7 @@ def register() -> Blueprint:
|
|||||||
@bp.get("/<code>/")
|
@bp.get("/<code>/")
|
||||||
async def ticket_detail(code: str):
|
async def ticket_detail(code: str):
|
||||||
"""View a single ticket with QR code."""
|
"""View a single ticket with QR code."""
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
ticket = await get_ticket_by_code(g.s, code)
|
ticket = await get_ticket_by_code(g.s, code)
|
||||||
if not ticket:
|
if not ticket:
|
||||||
@@ -178,4 +182,127 @@ def register() -> Blueprint:
|
|||||||
)
|
)
|
||||||
return await make_response(html, 200)
|
return await make_response(html, 200)
|
||||||
|
|
||||||
|
@bp.post("/adjust/")
|
||||||
|
@clear_cache(tag="calendars", tag_scope="all")
|
||||||
|
async def adjust_quantity():
|
||||||
|
"""
|
||||||
|
Adjust ticket quantity for a calendar entry (+/- pattern).
|
||||||
|
Creates or cancels tickets to reach the target count.
|
||||||
|
|
||||||
|
Form fields:
|
||||||
|
entry_id — the calendar entry ID
|
||||||
|
ticket_type_id — (optional) specific ticket type
|
||||||
|
count — target quantity of reserved tickets
|
||||||
|
"""
|
||||||
|
form = await request.form
|
||||||
|
|
||||||
|
entry_id_raw = form.get("entry_id", "").strip()
|
||||||
|
if not entry_id_raw:
|
||||||
|
return await make_response("Entry ID required", 400)
|
||||||
|
try:
|
||||||
|
entry_id = int(entry_id_raw)
|
||||||
|
except ValueError:
|
||||||
|
return await make_response("Invalid entry ID", 400)
|
||||||
|
|
||||||
|
# Load entry
|
||||||
|
entry = await g.s.scalar(
|
||||||
|
select(CalendarEntry)
|
||||||
|
.where(
|
||||||
|
CalendarEntry.id == entry_id,
|
||||||
|
CalendarEntry.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.options(selectinload(CalendarEntry.ticket_types))
|
||||||
|
)
|
||||||
|
if not entry:
|
||||||
|
return await make_response("Entry not found", 404)
|
||||||
|
if entry.ticket_price is None:
|
||||||
|
return await make_response("Tickets not available for this entry", 400)
|
||||||
|
|
||||||
|
# Ticket type (optional)
|
||||||
|
ticket_type_id = None
|
||||||
|
tt_raw = form.get("ticket_type_id", "").strip()
|
||||||
|
if tt_raw:
|
||||||
|
try:
|
||||||
|
ticket_type_id = int(tt_raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
target = max(int(form.get("count", 0)), 0)
|
||||||
|
ident = current_cart_identity()
|
||||||
|
|
||||||
|
current = await get_user_reserved_count(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=ticket_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
if target > current:
|
||||||
|
# Need to add tickets
|
||||||
|
to_add = target - current
|
||||||
|
available = await get_available_ticket_count(g.s, entry_id)
|
||||||
|
if available is not None and to_add > available:
|
||||||
|
return await make_response(
|
||||||
|
f"Only {available} ticket(s) remaining", 400
|
||||||
|
)
|
||||||
|
for _ in range(to_add):
|
||||||
|
await create_ticket(
|
||||||
|
g.s,
|
||||||
|
entry_id=entry_id,
|
||||||
|
ticket_type_id=ticket_type_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
state="reserved",
|
||||||
|
)
|
||||||
|
elif target < current:
|
||||||
|
# Need to remove tickets
|
||||||
|
to_remove = current - target
|
||||||
|
for _ in range(to_remove):
|
||||||
|
await cancel_latest_reserved_ticket(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=ticket_type_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build context for re-rendering the buy form
|
||||||
|
ticket_remaining = await get_available_ticket_count(g.s, entry_id)
|
||||||
|
ticket_sold_count = await get_sold_ticket_count(g.s, entry_id)
|
||||||
|
user_ticket_count = await get_user_reserved_count(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Per-type counts for multi-type entries
|
||||||
|
user_ticket_counts_by_type = {}
|
||||||
|
if entry.ticket_types:
|
||||||
|
for tt in entry.ticket_types:
|
||||||
|
if tt.deleted_at is None:
|
||||||
|
user_ticket_counts_by_type[tt.id] = await get_user_reserved_count(
|
||||||
|
g.s, entry_id,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
ticket_type_id=tt.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compute cart count for OOB mini-cart update
|
||||||
|
from shared.services.registry import services
|
||||||
|
summary = await services.cart.cart_summary(
|
||||||
|
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
cart_count = summary.count + summary.calendar_count + summary.ticket_count
|
||||||
|
|
||||||
|
html = await render_template(
|
||||||
|
"_types/tickets/_adjust_response.html",
|
||||||
|
entry=entry,
|
||||||
|
ticket_remaining=ticket_remaining,
|
||||||
|
ticket_sold_count=ticket_sold_count,
|
||||||
|
user_ticket_count=user_ticket_count,
|
||||||
|
user_ticket_counts_by_type=user_ticket_counts_by_type,
|
||||||
|
cart_count=cart_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
return await make_response(html, 200)
|
||||||
|
|
||||||
return bp
|
return bp
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import selectinload
|
|||||||
from models.calendars import Ticket, TicketType, CalendarEntry
|
from models.calendars import Ticket, TicketType, CalendarEntry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def create_ticket(
|
async def create_ticket(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@@ -181,6 +182,80 @@ async def get_tickets_for_entry(
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_sold_ticket_count(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""Count all non-cancelled tickets for an entry (total sold/reserved)."""
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.count(Ticket.id)).where(
|
||||||
|
Ticket.entry_id == entry_id,
|
||||||
|
Ticket.state != "cancelled",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_reserved_count(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
ticket_type_id: Optional[int] = None,
|
||||||
|
) -> int:
|
||||||
|
"""Count reserved tickets for a specific user/session + entry + optional type."""
|
||||||
|
filters = [
|
||||||
|
Ticket.entry_id == entry_id,
|
||||||
|
Ticket.state == "reserved",
|
||||||
|
]
|
||||||
|
if user_id is not None:
|
||||||
|
filters.append(Ticket.user_id == user_id)
|
||||||
|
elif session_id is not None:
|
||||||
|
filters.append(Ticket.session_id == session_id)
|
||||||
|
else:
|
||||||
|
return 0
|
||||||
|
if ticket_type_id is not None:
|
||||||
|
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||||
|
result = await session.scalar(
|
||||||
|
select(func.count(Ticket.id)).where(*filters)
|
||||||
|
)
|
||||||
|
return result or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def cancel_latest_reserved_ticket(
|
||||||
|
session: AsyncSession,
|
||||||
|
entry_id: int,
|
||||||
|
user_id: Optional[int] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
|
ticket_type_id: Optional[int] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Cancel the most recently created reserved ticket. Returns True if one was cancelled."""
|
||||||
|
filters = [
|
||||||
|
Ticket.entry_id == entry_id,
|
||||||
|
Ticket.state == "reserved",
|
||||||
|
]
|
||||||
|
if user_id is not None:
|
||||||
|
filters.append(Ticket.user_id == user_id)
|
||||||
|
elif session_id is not None:
|
||||||
|
filters.append(Ticket.session_id == session_id)
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
if ticket_type_id is not None:
|
||||||
|
filters.append(Ticket.ticket_type_id == ticket_type_id)
|
||||||
|
|
||||||
|
ticket = await session.scalar(
|
||||||
|
select(Ticket)
|
||||||
|
.where(*filters)
|
||||||
|
.order_by(Ticket.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if ticket:
|
||||||
|
ticket.state = "cancelled"
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_available_ticket_count(
|
async def get_available_ticket_count(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
entry_id: int,
|
entry_id: int,
|
||||||
|
|||||||
84
config/app-config.yaml
Normal file
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 sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
# Add the shared library submodule to the Python path
|
_app_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
_shared = os.path.join(os.path.dirname(os.path.abspath(__file__)), "shared_lib")
|
_project_root = os.path.dirname(_app_dir)
|
||||||
if _shared not in sys.path:
|
|
||||||
sys.path.insert(0, _shared)
|
for _p in (_project_root, _app_dir):
|
||||||
|
if _p not in sys.path:
|
||||||
|
sys.path.insert(0, _p)
|
||||||
|
|||||||
29
services/__init__.py
Normal file
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 %}
|
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||||
<a
|
<a
|
||||||
href="{{ coop_url('/' + entry_post.slug + '/') }}"
|
href="{{ blog_url('/' + entry_post.slug + '/') }}"
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
|
class="flex items-center gap-2 px-3 py-2 hover:bg-stone-100 rounded transition text-sm border sm:whitespace-nowrap sm:flex-shrink-0">
|
||||||
{% if entry_post.feature_image %}
|
{% if entry_post.feature_image %}
|
||||||
<img src="{{ entry_post.feature_image }}"
|
<img src="{{ entry_post.feature_image }}"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
{% from 'macros/scrolling_menu.html' import scrolling_menu with context %}
|
||||||
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
{% call(entry_post) scrolling_menu('entry-posts-container', entry_posts) %}
|
||||||
<a
|
<a
|
||||||
href="{{ coop_url('/' + entry_post.slug + '/') }}"
|
href="{{ blog_url('/' + entry_post.slug + '/') }}"
|
||||||
class="{{styles.nav_button}}"
|
class="{{styles.nav_button}}"
|
||||||
>
|
>
|
||||||
{% if entry_post.feature_image %}
|
{% if entry_post.feature_image %}
|
||||||
|
|||||||
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 %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if g.rights.admin %}
|
{% if g.rights.admin %}
|
||||||
<a href="{{ coop_url('/' + post.slug + '/admin/') }}" class="{{styles.nav_button}}">
|
<a href="{{ blog_url('/' + post.slug + '/admin/') }}" class="{{styles.nav_button}}">
|
||||||
<i class="fa fa-cog" aria-hidden="true"></i>
|
<i class="fa fa-cog" aria-hidden="true"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative nav-group">
|
<div class="relative nav-group">
|
||||||
<a href="{{ coop_url('/' + post.slug + '/admin/entries/') }}" class="{{styles.nav_button}}">
|
<a href="{{ blog_url('/' + post.slug + '/admin/entries/') }}" class="{{styles.nav_button}}">
|
||||||
entries
|
entries
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative nav-group">
|
<div class="relative nav-group">
|
||||||
<a href="{{ coop_url('/' + post.slug + '/admin/data/') }}" class="{{styles.nav_button}}">
|
<a href="{{ blog_url('/' + post.slug + '/admin/data/') }}" class="{{styles.nav_button}}">
|
||||||
data
|
data
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative nav-group">
|
<div class="relative nav-group">
|
||||||
<a href="{{ coop_url('/' + post.slug + '/admin/edit/') }}" class="{{styles.nav_button}}">
|
<a href="{{ blog_url('/' + post.slug + '/admin/edit/') }}" class="{{styles.nav_button}}">
|
||||||
edit
|
edit
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative nav-group">
|
<div class="relative nav-group">
|
||||||
<a href="{{ coop_url('/' + post.slug + '/admin/settings/') }}" class="{{styles.nav_button}}">
|
<a href="{{ blog_url('/' + post.slug + '/admin/settings/') }}" class="{{styles.nav_button}}">
|
||||||
settings
|
settings
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{% import 'macros/links.html' as links %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='post-admin-row', oob=oob) %}
|
{% call links.menu_row(id='post-admin-row', oob=oob) %}
|
||||||
<a href="{{ coop_url('/' + post.slug + '/admin/') }}"
|
<a href="{{ blog_url('/' + post.slug + '/admin/') }}"
|
||||||
class="flex items-center gap-2 px-3 py-2 rounded">
|
class="flex items-center gap-2 px-3 py-2 rounded">
|
||||||
{{ links.admin() }}
|
{{ links.admin() }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
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 %}
|
{% import 'macros/links.html' as links %}
|
||||||
{% macro header_row(oob=False) %}
|
{% macro header_row(oob=False) %}
|
||||||
{% call links.menu_row(id='post_entries-row', oob=oob) %}
|
{% call links.menu_row(id='post_entries-row', oob=oob) %}
|
||||||
{% call links.link(coop_url('/' + post.slug + '/admin/entries/'), hx_select_search) %}
|
{% call links.link(blog_url('/' + post.slug + '/admin/entries/'), hx_select_search) %}
|
||||||
<i class="fa fa-clock" aria-hidden="true"></i>
|
<i class="fa fa-clock" aria-hidden="true"></i>
|
||||||
<div>
|
<div>
|
||||||
entries
|
entries
|
||||||
|
|||||||
@@ -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">
|
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-stone-200 bg-white p-4">
|
||||||
<h3 class="text-sm font-semibold text-stone-700 mb-3">
|
<h3 class="text-sm font-semibold text-stone-700 mb-3">
|
||||||
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
||||||
Buy Tickets
|
Tickets
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{# Sold / remaining info #}
|
||||||
|
<div class="flex items-center gap-3 mb-3 text-xs text-stone-500">
|
||||||
|
{% if ticket_sold_count is defined and ticket_sold_count %}
|
||||||
|
<span>{{ ticket_sold_count }} sold</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if ticket_remaining is not none %}
|
||||||
|
<span>{{ ticket_remaining }} remaining</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if user_ticket_count is defined and user_ticket_count %}
|
||||||
|
<span class="text-emerald-600 font-medium">
|
||||||
|
<i class="fa fa-shopping-cart text-[0.6rem]" aria-hidden="true"></i>
|
||||||
|
{{ user_ticket_count }} in basket
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if entry.ticket_types %}
|
{% if entry.ticket_types %}
|
||||||
{# Multiple ticket types #}
|
{# Multiple ticket types #}
|
||||||
<div class="space-y-2 mb-4">
|
<div class="space-y-2">
|
||||||
{% for tt in entry.ticket_types %}
|
{% for tt in entry.ticket_types %}
|
||||||
{% if tt.deleted_at is none %}
|
{% if tt.deleted_at is none %}
|
||||||
|
{% set type_count = user_ticket_counts_by_type.get(tt.id, 0) if user_ticket_counts_by_type is defined else 0 %}
|
||||||
<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">
|
<div class="flex items-center justify-between p-3 rounded-lg bg-stone-50 border border-stone-100">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium text-sm">{{ tt.name }}</div>
|
<div class="font-medium text-sm">{{ tt.name }}</div>
|
||||||
@@ -18,34 +35,83 @@
|
|||||||
£{{ '%.2f'|format(tt.cost) }}
|
£{{ '%.2f'|format(tt.cost) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if type_count == 0 %}
|
||||||
|
{# Add to basket button #}
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for('tickets.buy_tickets') }}"
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
hx-target="#ticket-buy-{{ entry.id }}"
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
|
<input type="hidden" name="ticket_type_id" value="{{ tt.id }}" />
|
||||||
<input
|
<input type="hidden" name="count" value="1" />
|
||||||
type="number"
|
|
||||||
name="quantity"
|
|
||||||
value="1"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
class="w-16 px-2 py-1 text-sm border rounded text-center"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="px-3 py-1 bg-emerald-600 text-white text-sm rounded hover:bg-emerald-700 transition"
|
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50 rounded p-1"
|
||||||
>
|
>
|
||||||
Buy
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
{# Simple ticket (single price) #}
|
{# Simple ticket (single price) #}
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
@@ -55,38 +121,80 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-stone-500 ml-2">per ticket</span>
|
<span class="text-sm text-stone-500 ml-2">per ticket</span>
|
||||||
</div>
|
</div>
|
||||||
{% if ticket_remaining is not none %}
|
|
||||||
<span class="text-xs text-stone-500">
|
|
||||||
{{ ticket_remaining }} remaining
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set qty = user_ticket_count if user_ticket_count is defined else 0 %}
|
||||||
|
|
||||||
|
{% if qty == 0 %}
|
||||||
|
{# Add to basket button #}
|
||||||
<form
|
<form
|
||||||
hx-post="{{ url_for('tickets.buy_tickets') }}"
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
hx-target="#ticket-buy-{{ entry.id }}"
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
hx-swap="outerHTML"
|
hx-swap="outerHTML"
|
||||||
class="flex items-center gap-3"
|
class="flex items-center"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
<label class="text-sm text-stone-600">Qty:</label>
|
<input type="hidden" name="count" value="1" />
|
||||||
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="px-4 py-2 bg-emerald-600 text-white text-sm rounded-lg hover:bg-emerald-700 transition font-medium"
|
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-ticket mr-1" aria-hidden="true"></i>
|
<span class="relative inline-flex items-center justify-center">
|
||||||
Buy Tickets
|
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# +/- controls #}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
|
<input type="hidden" name="count" value="{{ qty - 1 }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="relative inline-flex items-center justify-center text-emerald-700"
|
||||||
|
href="{{ url_for('tickets.my_tickets') }}"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa-solid fa-shopping-cart text-2xl" aria-hidden="true"></i>
|
||||||
|
<span class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
||||||
|
<span class="flex items-center justify-center bg-black text-white rounded-full w-4 h-4 text-xs font-bold">
|
||||||
|
{{ qty }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form
|
||||||
|
hx-post="{{ url_for('tickets.adjust_quantity') }}"
|
||||||
|
hx-target="#ticket-buy-{{ entry.id }}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
|
||||||
|
<input type="hidden" name="entry_id" value="{{ entry.id }}" />
|
||||||
|
<input type="hidden" name="count" value="{{ qty + 1 }}" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% elif entry.ticket_price is not none %}
|
{% elif entry.ticket_price is not none %}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
{# Shown after ticket purchase — replaces the buy form #}
|
{# Shown after ticket purchase — replaces the buy form #}
|
||||||
|
{# OOB: refresh cart badge to reflect new ticket count #}
|
||||||
|
{% from 'macros/cart_icon.html' import cart_icon %}
|
||||||
|
{{ cart_icon(count=cart_count|default(0), oob='true') }}
|
||||||
|
|
||||||
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
<div id="ticket-buy-{{ entry.id }}" class="rounded-xl border border-emerald-200 bg-emerald-50 p-4">
|
||||||
<div class="flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>
|
<i class="fa fa-check-circle text-emerald-600" aria-hidden="true"></i>
|
||||||
|
|||||||
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