Compare commits
142 Commits
main
...
decoupling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
268f033396 | ||
|
|
26e146e3e3 | ||
|
|
1c6dda0cb7 | ||
|
|
b809b12994 | ||
|
|
afadb3aa0b | ||
|
|
24e26f1f18 | ||
|
|
1de0af4fed | ||
|
|
1154d82791 | ||
|
|
17117096c9 | ||
|
|
a9b982c8c7 | ||
|
|
c23c131024 | ||
|
|
e0046403d8 | ||
|
|
641178855d | ||
|
|
a72598f75e | ||
|
|
0b2e59bc2c | ||
|
|
ac458ba96c | ||
|
|
39a9dc34fb | ||
|
|
a1d6b635e5 | ||
|
|
f229dac2db | ||
|
|
533590f733 | ||
|
|
d4a31a3877 | ||
|
|
c4e0667ee8 | ||
|
|
36374793ec | ||
|
|
a739c0d0b1 | ||
|
|
89989cdd24 | ||
|
|
37d8754a7f | ||
|
|
dca45a3fbb | ||
|
|
3fbed403fc | ||
|
|
0417e8abff | ||
|
|
c8248575ab | ||
|
|
5d930e96f6 | ||
|
|
42acfde023 | ||
|
|
77634c513b | ||
|
|
a59bc859cf | ||
|
|
85d18fd418 | ||
|
|
e0f4679805 | ||
|
|
2d1ed47d2c | ||
|
|
43ae80ec02 | ||
|
|
28d173d9fd | ||
|
|
4e295e3fbd | ||
|
|
66d1727b0f | ||
|
|
938bee5197 | ||
|
|
41e13513e1 | ||
|
|
8526c54840 | ||
|
|
c226ab99d3 | ||
|
|
14de629a29 | ||
|
|
a53c50feee | ||
|
|
2f55f3762b | ||
|
|
ae52edb928 | ||
|
|
c5e2578534 | ||
|
|
5877d7e382 | ||
|
|
ed6363295a | ||
|
|
2212f3d069 | ||
|
|
e5e01dad27 | ||
|
|
eee50809e8 | ||
|
|
32ae7fc6cc | ||
|
|
fbd1ce214d | ||
|
|
ba51b36f87 | ||
|
|
c84e02e623 | ||
|
|
b16c97e070 | ||
|
|
cb518b9cd0 | ||
|
|
13798358fc | ||
|
|
89cc2af958 | ||
|
|
8abb0c65d6 | ||
|
|
c0f9162e07 | ||
|
|
739ad0451e | ||
|
|
d93e624b45 | ||
|
|
dfb8651a39 | ||
|
|
62236233b4 | ||
|
|
822c62411f | ||
|
|
592c48f1cd | ||
|
|
e05de6e1fe | ||
|
|
5eeb14a457 | ||
|
|
fae68ebafa | ||
|
|
0e66c37a69 | ||
|
|
0d47a094a6 | ||
|
|
a6cfd02832 | ||
|
|
41d198630e | ||
|
|
881ea09e71 | ||
|
|
ca9ee83ae4 | ||
|
|
e5de05dd79 | ||
|
|
9df23276a1 | ||
|
|
058f0a1d8a | ||
|
|
e86c4a0cc8 | ||
|
|
e41d22746e | ||
|
|
a6f7bfdc7b | ||
|
|
28c10a3411 | ||
|
|
fd89426ed9 | ||
|
|
dc249ea9ce | ||
|
|
cd41b6c8ef | ||
|
|
b3efed2f60 | ||
|
|
d61b9573d3 | ||
|
|
32c9c98d74 | ||
|
|
0cdc289d00 | ||
|
|
d2d8156461 | ||
|
|
52d973ff01 | ||
|
|
f299805c22 | ||
|
|
17ab7f09c7 | ||
|
|
8c72664e1f | ||
|
|
6140c727c6 | ||
|
|
8498807597 | ||
|
|
72062930f0 | ||
|
|
edad7b299d | ||
|
|
cf04a3a9fd | ||
|
|
e5c686643c | ||
|
|
ad7f933278 | ||
|
|
377396283d | ||
|
|
049b35479b | ||
|
|
b7f09d638d | ||
|
|
52105def89 | ||
|
|
8527ddb84b | ||
|
|
d6d82664d6 | ||
|
|
56230eff0a | ||
|
|
7f25e6b63f | ||
|
|
91f05d41ca | ||
|
|
bd14e2564a | ||
|
|
1256755a3a | ||
|
|
298b5cd0a7 | ||
|
|
e341df5836 | ||
|
|
d81d116be8 | ||
|
|
de219aa870 | ||
|
|
93ffffac16 | ||
|
|
fb70c4c76d | ||
|
|
2af4dd2073 | ||
|
|
6aa2919f34 | ||
|
|
61686fd70c | ||
|
|
4f9f482c6c | ||
|
|
34032160f9 | ||
|
|
d407957928 | ||
|
|
cd332b2544 | ||
|
|
9cf8ff1114 | ||
|
|
14838ebbaa | ||
|
|
dc379b30a2 | ||
|
|
d8bec5317a | ||
|
|
f4cd2f41c7 | ||
|
|
029b02ff18 | ||
|
|
908f92464e | ||
|
|
c4fbfa4c53 | ||
|
|
3adf268ffe | ||
|
|
e97eea816f | ||
|
|
25fc3a933c | ||
|
|
5d0653bf2e |
@@ -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: cart
|
IMAGE: cart
|
||||||
REPO_DIR: /root/rose-ash/cart
|
REPO_DIR: /root/rose-ash/cart
|
||||||
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
|
||||||
|
|||||||
@@ -5,6 +5,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
|
||||||
@@ -17,14 +18,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
|
||||||
|
|||||||
109
README.md
109
README.md
@@ -1,71 +1,76 @@
|
|||||||
# Cart App
|
# Cart App
|
||||||
|
|
||||||
Shopping cart, checkout, and order management service for the Rose Ash cooperative marketplace.
|
Shopping cart, checkout, and order management service for the Rose Ash cooperative.
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This is the **cart** microservice, split from the Rose Ash monolith. It handles:
|
|
||||||
|
|
||||||
- **Shopping cart** - Add/remove products, view cart, cart summary API
|
|
||||||
- **Checkout** - SumUp payment integration with hosted checkout
|
|
||||||
- **Orders** - Order listing, detail view, payment status tracking
|
|
||||||
- **Calendar bookings** - Calendar entry cart items and checkout integration
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Framework:** Quart (async Flask)
|
One of five Quart microservices sharing a single PostgreSQL database:
|
||||||
- **Database:** PostgreSQL 16 via SQLAlchemy 2.0 (async)
|
|
||||||
- **Payments:** SumUp Hosted Checkout
|
|
||||||
- **Frontend:** HTMX + Jinja2 templates + Tailwind CSS
|
|
||||||
|
|
||||||
## Directory Structure
|
| App | Port | Domain |
|
||||||
|
|-----|------|--------|
|
||||||
|
| blog (coop) | 8000 | Auth, blog, admin, menus, snippets |
|
||||||
|
| market | 8001 | Product browsing, Suma scraping |
|
||||||
|
| **cart** | 8002 | Shopping cart, checkout, orders |
|
||||||
|
| events | 8003 | Calendars, bookings, tickets |
|
||||||
|
| federation | 8004 | ActivityPub, fediverse social |
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
app.py # Quart application factory
|
app.py # Application factory (create_base_app + blueprints)
|
||||||
|
path_setup.py # Adds project root + app dir to sys.path
|
||||||
|
config/app-config.yaml # App URLs, SumUp config
|
||||||
|
models/ # Cart-domain models (Order, OrderItem, PageConfig)
|
||||||
bp/
|
bp/
|
||||||
cart/ # Cart blueprint (add, view, checkout, webhooks)
|
cart/ # Cart blueprint
|
||||||
routes.py
|
global_routes.py # Add to cart, checkout, webhooks, return page
|
||||||
api.py # Internal API (server-to-server, CSRF-exempt)
|
page_routes.py # Page-scoped cart and checkout
|
||||||
login_helper.py # Cart merge on login
|
overview_routes.py # Cart overview / summary page
|
||||||
services/ # Business logic layer
|
services/ # Business logic
|
||||||
order/ # Single order detail blueprint
|
checkout.py # Order creation, SumUp integration
|
||||||
routes.py
|
check_sumup_status.py # Payment status polling
|
||||||
filters/qs.py # Query string helpers
|
calendar_cart.py # Calendar entry cart queries
|
||||||
orders/ # Order listing blueprint
|
page_cart.py # Page-scoped cart queries
|
||||||
routes.py
|
get_cart.py # Cart item queries
|
||||||
filters/qs.py
|
identity.py # Cart identity (user_id / session_id)
|
||||||
templates/
|
total.py # Price calculations
|
||||||
_types/cart/ # Cart templates
|
clear_cart_for_order.py # Soft-delete cart after checkout
|
||||||
_types/order/ # Single order templates
|
order/ # Single order detail view
|
||||||
_types/orders/ # Order listing templates
|
orders/ # Order listing view
|
||||||
entrypoint.sh # Docker entrypoint (migrations + server start)
|
services/ # register_domain_services() — wires cart + calendar + market
|
||||||
Dockerfile # Container build
|
shared/ # Submodule -> git.rose-ash.com/coop/shared.git
|
||||||
.gitea/workflows/ci.yml # CI/CD pipeline
|
```
|
||||||
|
|
||||||
|
## Cross-Domain Communication
|
||||||
|
|
||||||
|
- `services.calendar.*` — claim/confirm entries for orders, adopt on login
|
||||||
|
- `services.market.*` — marketplace queries for page-scoped carts
|
||||||
|
- `services.blog.*` — post lookup for page context
|
||||||
|
- `shared.services.navigation` — site navigation tree
|
||||||
|
|
||||||
|
## Domain Events
|
||||||
|
|
||||||
|
- `checkout.py` emits `order.created` via `shared.events.emit_event`
|
||||||
|
- `check_sumup_status.py` emits `order.paid` via `shared.events.emit_event`
|
||||||
|
|
||||||
|
## Checkout Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User clicks "Checkout"
|
||||||
|
2. create_order_from_cart() creates Order + OrderItems
|
||||||
|
3. services.calendar.claim_entries_for_order() marks entries as "ordered"
|
||||||
|
4. emit: order.created event
|
||||||
|
5. SumUp hosted checkout created, user redirected
|
||||||
|
6. SumUp webhook / return page triggers check_sumup_status()
|
||||||
|
7. If PAID: services.calendar.confirm_entries_for_order(), emit: order.paid
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set environment variables
|
|
||||||
export APP_MODULE=app:app
|
|
||||||
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop
|
||||||
export REDIS_URL=redis://localhost:6379/0
|
export REDIS_URL=redis://localhost:6379/0
|
||||||
export SECRET_KEY=your-secret-key
|
export SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
# Run the server
|
hypercorn app:app --bind 0.0.0.0:8002
|
||||||
hypercorn app:app --reload --bind 0.0.0.0:8002
|
|
||||||
```
|
|
||||||
|
|
||||||
## Cross-App Communication
|
|
||||||
|
|
||||||
The cart app exposes internal API endpoints at `/internal/cart/` for other services:
|
|
||||||
|
|
||||||
- `GET /internal/cart/summary` - Cart count and total for the current session/user
|
|
||||||
- `POST /internal/cart/adopt` - Adopt anonymous cart items after user login
|
|
||||||
|
|
||||||
## Docker
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t cart:latest .
|
|
||||||
docker run -p 8002:8000 --env-file .env cart:latest
|
|
||||||
```
|
```
|
||||||
|
|||||||
0
__init__.py
Normal file
0
__init__.py
Normal file
126
app.py
126
app.py
@@ -1,31 +1,36 @@
|
|||||||
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 decimal import Decimal
|
||||||
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 sqlalchemy import select
|
||||||
|
|
||||||
from shared.factory import create_base_app
|
from shared.infrastructure.factory import create_base_app
|
||||||
|
|
||||||
from suma_browser.app.bp import (
|
from bp import (
|
||||||
register_cart_overview,
|
register_cart_overview,
|
||||||
register_page_cart,
|
register_page_cart,
|
||||||
register_cart_global,
|
register_cart_global,
|
||||||
register_cart_api,
|
|
||||||
register_orders,
|
register_orders,
|
||||||
|
register_fragments,
|
||||||
)
|
)
|
||||||
from suma_browser.app.bp.cart.services import (
|
from bp.cart.services import (
|
||||||
get_cart,
|
get_cart,
|
||||||
total,
|
total,
|
||||||
get_calendar_cart_entries,
|
get_calendar_cart_entries,
|
||||||
calendar_total,
|
calendar_total,
|
||||||
|
get_ticket_cart_entries,
|
||||||
|
ticket_total,
|
||||||
)
|
)
|
||||||
from suma_browser.app.bp.cart.services.page_cart import (
|
from bp.cart.services.page_cart import (
|
||||||
get_cart_for_page,
|
get_cart_for_page,
|
||||||
get_calendar_entries_for_page,
|
get_calendar_entries_for_page,
|
||||||
|
get_tickets_for_page,
|
||||||
)
|
)
|
||||||
|
from bp.cart.services.ticket_groups import group_tickets
|
||||||
|
|
||||||
|
|
||||||
async def _load_cart():
|
async def _load_cart():
|
||||||
@@ -40,56 +45,68 @@ async def cart_context() -> dict:
|
|||||||
- cart / calendar_cart_entries / total / calendar_total: direct DB
|
- cart / calendar_cart_entries / total / calendar_total: direct DB
|
||||||
(cart app owns this data)
|
(cart app owns this data)
|
||||||
- cart_count: derived from cart + calendar entries (for _mini.html)
|
- cart_count: derived from cart + calendar entries (for _mini.html)
|
||||||
- menu_items: fetched from coop internal API
|
- nav_tree_html: fetched from blog as fragment
|
||||||
|
|
||||||
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
|
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
|
||||||
Global cart_count / cart_total stay global for cart-mini.
|
Global cart_count / cart_total stay global for cart-mini.
|
||||||
"""
|
"""
|
||||||
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.infrastructure.fragments import fetch_fragment
|
||||||
|
|
||||||
ctx = await base_context()
|
ctx = await base_context()
|
||||||
|
|
||||||
|
ctx["nav_tree_html"] = await fetch_fragment(
|
||||||
|
"blog", "nav-tree",
|
||||||
|
params={"app_name": "cart", "path": request.path},
|
||||||
|
)
|
||||||
|
# Fallback for _nav.html when nav-tree fragment fetch fails
|
||||||
|
ctx["menu_items"] = await get_navigation_tree(g.s)
|
||||||
|
|
||||||
# Cart app owns cart data — use g.cart from _load_cart
|
# Cart app owns cart data — use g.cart from _load_cart
|
||||||
all_cart = getattr(g, "cart", None) or []
|
all_cart = getattr(g, "cart", None) or []
|
||||||
all_cal = await get_calendar_cart_entries(g.s)
|
all_cal = await get_calendar_cart_entries(g.s)
|
||||||
|
all_tickets = await get_ticket_cart_entries(g.s)
|
||||||
|
|
||||||
# Global counts for cart-mini (always global)
|
# Global counts for cart-mini (always global)
|
||||||
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
|
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
|
||||||
ctx["cart_count"] = cart_qty + len(all_cal)
|
ctx["cart_count"] = cart_qty + len(all_cal) + len(all_tickets)
|
||||||
ctx["cart_total"] = (total(all_cart) or 0) + (calendar_total(all_cal) or 0)
|
ctx["cart_total"] = (total(all_cart) or Decimal(0)) + (calendar_total(all_cal) or Decimal(0)) + (ticket_total(all_tickets) or Decimal(0))
|
||||||
|
|
||||||
# Page-scoped data when viewing a page cart
|
# Page-scoped data when viewing a page cart
|
||||||
page_post = getattr(g, "page_post", None)
|
page_post = getattr(g, "page_post", None)
|
||||||
if page_post:
|
if page_post:
|
||||||
page_cart = await get_cart_for_page(g.s, page_post.id)
|
page_cart = await get_cart_for_page(g.s, page_post.id)
|
||||||
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
|
page_cal = await get_calendar_entries_for_page(g.s, page_post.id)
|
||||||
|
page_tickets = await get_tickets_for_page(g.s, page_post.id)
|
||||||
ctx["cart"] = page_cart
|
ctx["cart"] = page_cart
|
||||||
ctx["calendar_cart_entries"] = page_cal
|
ctx["calendar_cart_entries"] = page_cal
|
||||||
|
ctx["ticket_cart_entries"] = page_tickets
|
||||||
ctx["page_post"] = page_post
|
ctx["page_post"] = page_post
|
||||||
ctx["page_config"] = getattr(g, "page_config", None)
|
ctx["page_config"] = getattr(g, "page_config", None)
|
||||||
else:
|
else:
|
||||||
ctx["cart"] = all_cart
|
ctx["cart"] = all_cart
|
||||||
ctx["calendar_cart_entries"] = all_cal
|
ctx["calendar_cart_entries"] = all_cal
|
||||||
|
ctx["ticket_cart_entries"] = all_tickets
|
||||||
|
|
||||||
|
ctx["ticket_groups"] = group_tickets(ctx.get("ticket_cart_entries", []))
|
||||||
ctx["total"] = total
|
ctx["total"] = total
|
||||||
ctx["calendar_total"] = calendar_total
|
ctx["calendar_total"] = calendar_total
|
||||||
|
ctx["ticket_total"] = ticket_total
|
||||||
# Menu items from coop API (wrapped for attribute access in templates)
|
|
||||||
menu_data = await api_get("coop", "/internal/menu-items")
|
|
||||||
ctx["menu_items"] = dictobj(menu_data) if menu_data else []
|
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> "Quart":
|
def create_app() -> "Quart":
|
||||||
from models.ghost_content import Post
|
from shared.models.page_config import PageConfig
|
||||||
from models.page_config import PageConfig
|
from shared.services.registry import services
|
||||||
|
from services import register_domain_services
|
||||||
|
|
||||||
app = create_base_app(
|
app = create_base_app(
|
||||||
"cart",
|
"cart",
|
||||||
context_fn=cart_context,
|
context_fn=cart_context,
|
||||||
before_request_fns=[_load_cart],
|
before_request_fns=[_load_cart],
|
||||||
|
domain_services_fn=register_domain_services,
|
||||||
)
|
)
|
||||||
|
|
||||||
# App-specific templates override shared templates
|
# App-specific templates override shared templates
|
||||||
@@ -99,6 +116,11 @@ def create_app() -> "Quart":
|
|||||||
app.jinja_loader,
|
app.jinja_loader,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
app.jinja_env.globals["cart_quantity_url"] = lambda product_id: f"/quantity/{product_id}/"
|
||||||
|
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
|
||||||
|
|
||||||
|
app.register_blueprint(register_fragments())
|
||||||
|
|
||||||
# --- Page slug hydration (follows events/market app pattern) ---
|
# --- Page slug hydration (follows events/market app pattern) ---
|
||||||
|
|
||||||
@app.url_value_preprocessor
|
@app.url_value_preprocessor
|
||||||
@@ -118,26 +140,22 @@ def create_app() -> "Quart":
|
|||||||
slug = getattr(g, "page_slug", None)
|
slug = getattr(g, "page_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(
|
if not post or not post.is_page:
|
||||||
select(Post).where(Post.slug == slug, Post.is_page == True) # noqa: E712
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if not post:
|
|
||||||
abort(404)
|
abort(404)
|
||||||
g.page_post = post
|
g.page_post = post
|
||||||
g.page_config = (
|
g.page_config = (
|
||||||
await g.s.execute(
|
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()
|
||||||
|
|
||||||
# --- Blueprint registration ---
|
# --- Blueprint registration ---
|
||||||
# Static prefixes first, dynamic (page_slug) last
|
# Static prefixes first, dynamic (page_slug) last
|
||||||
|
|
||||||
# Internal API (server-to-server, CSRF-exempt)
|
|
||||||
app.register_blueprint(register_cart_api())
|
|
||||||
|
|
||||||
# Orders blueprint
|
# Orders blueprint
|
||||||
app.register_blueprint(register_orders(url_prefix="/orders"))
|
app.register_blueprint(register_orders(url_prefix="/orders"))
|
||||||
|
|
||||||
@@ -159,6 +177,58 @@ def create_app() -> "Quart":
|
|||||||
url_prefix="/<page_slug>",
|
url_prefix="/<page_slug>",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Reconcile stale pending orders on startup ---
|
||||||
|
@app.before_serving
|
||||||
|
async def _reconcile_pending_orders():
|
||||||
|
"""Check SumUp status for orders stuck in 'pending' with a checkout ID.
|
||||||
|
|
||||||
|
Handles the case where SumUp webhooks fired while the service was down
|
||||||
|
or were rejected (e.g. CSRF). Runs once on boot.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from shared.db.session import get_session
|
||||||
|
from shared.models.order import Order
|
||||||
|
from bp.cart.services.check_sumup_status import check_sumup_status
|
||||||
|
|
||||||
|
log = logging.getLogger("cart.reconcile")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with get_session() as sess:
|
||||||
|
async with sess.begin():
|
||||||
|
# Orders that are pending, have a SumUp checkout, and are
|
||||||
|
# older than 2 minutes (avoid racing with in-flight checkouts)
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
|
||||||
|
result = await sess.execute(
|
||||||
|
select(Order)
|
||||||
|
.where(
|
||||||
|
Order.status == "pending",
|
||||||
|
Order.sumup_checkout_id.isnot(None),
|
||||||
|
Order.created_at < cutoff,
|
||||||
|
)
|
||||||
|
.options(selectinload(Order.page_config))
|
||||||
|
.limit(50)
|
||||||
|
)
|
||||||
|
stale_orders = result.scalars().all()
|
||||||
|
|
||||||
|
if not stale_orders:
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("Reconciling %d stale pending orders", len(stale_orders))
|
||||||
|
for order in stale_orders:
|
||||||
|
try:
|
||||||
|
await check_sumup_status(sess, order)
|
||||||
|
log.info(
|
||||||
|
"Order %d reconciled: %s",
|
||||||
|
order.id, order.status,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Failed to reconcile order %d", order.id)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Order reconciliation failed")
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from .cart.overview_routes import register as register_cart_overview
|
from .cart.overview_routes import register as register_cart_overview
|
||||||
from .cart.page_routes import register as register_page_cart
|
from .cart.page_routes import register as register_page_cart
|
||||||
from .cart.global_routes import register as register_cart_global
|
from .cart.global_routes import register as register_cart_global
|
||||||
from .cart.api import register as register_cart_api
|
|
||||||
from .order.routes import register as register_order
|
from .order.routes import register as register_order
|
||||||
from .orders.routes import register as register_orders
|
from .orders.routes import register as register_orders
|
||||||
|
from .fragments import register_fragments
|
||||||
|
|||||||
174
bp/cart/api.py
174
bp/cart/api.py
@@ -1,174 +0,0 @@
|
|||||||
"""
|
|
||||||
Internal JSON API for the cart app.
|
|
||||||
|
|
||||||
These endpoints are called by other apps (coop, market) 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.market import CartItem
|
|
||||||
from models.market_place import MarketPlace
|
|
||||||
from models.calendars import CalendarEntry, Calendar
|
|
||||||
from models.ghost_content import Post
|
|
||||||
from suma_browser.app.csrf import csrf_exempt
|
|
||||||
from shared.cart_identity import current_cart_identity
|
|
||||||
|
|
||||||
|
|
||||||
def register() -> Blueprint:
|
|
||||||
bp = Blueprint("cart_api", __name__, url_prefix="/internal/cart")
|
|
||||||
|
|
||||||
@bp.get("/summary")
|
|
||||||
@csrf_exempt
|
|
||||||
async def summary():
|
|
||||||
"""
|
|
||||||
Return a lightweight cart summary (count + total) for the
|
|
||||||
current session/user. Called by coop and market apps to
|
|
||||||
populate the cart-mini widget without importing cart services.
|
|
||||||
|
|
||||||
Optional query param: ?page_slug=<slug>
|
|
||||||
When provided, returns only items scoped to that page.
|
|
||||||
"""
|
|
||||||
ident = current_cart_identity()
|
|
||||||
|
|
||||||
# Resolve optional page filter
|
|
||||||
page_slug = request.args.get("page_slug")
|
|
||||||
page_post_id = None
|
|
||||||
if page_slug:
|
|
||||||
post = (
|
|
||||||
await g.s.execute(
|
|
||||||
select(Post).where(Post.slug == page_slug, Post.is_page == True) # noqa: E712
|
|
||||||
)
|
|
||||||
).scalar_one_or_none()
|
|
||||||
if post:
|
|
||||||
page_post_id = post.id
|
|
||||||
|
|
||||||
# --- product cart ---
|
|
||||||
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
|
|
||||||
if ident["user_id"] is not None:
|
|
||||||
cart_q = cart_q.where(CartItem.user_id == ident["user_id"])
|
|
||||||
else:
|
|
||||||
cart_q = cart_q.where(CartItem.session_id == ident["session_id"])
|
|
||||||
|
|
||||||
if page_post_id is not None:
|
|
||||||
mp_ids = select(MarketPlace.id).where(
|
|
||||||
MarketPlace.post_id == page_post_id,
|
|
||||||
MarketPlace.deleted_at.is_(None),
|
|
||||||
).scalar_subquery()
|
|
||||||
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
|
|
||||||
|
|
||||||
cart_q = cart_q.options(selectinload(CartItem.product)).order_by(CartItem.created_at.desc())
|
|
||||||
|
|
||||||
result = await g.s.execute(cart_q)
|
|
||||||
cart_items = result.scalars().all()
|
|
||||||
|
|
||||||
cart_count = sum(ci.quantity for ci in cart_items)
|
|
||||||
cart_total = sum(
|
|
||||||
(ci.product.special_price or ci.product.regular_price or 0) * ci.quantity
|
|
||||||
for ci in cart_items
|
|
||||||
if ci.product and (ci.product.special_price or ci.product.regular_price)
|
|
||||||
)
|
|
||||||
|
|
||||||
# --- calendar entries ---
|
|
||||||
cal_q = select(CalendarEntry).where(
|
|
||||||
CalendarEntry.deleted_at.is_(None),
|
|
||||||
CalendarEntry.state == "pending",
|
|
||||||
)
|
|
||||||
if ident["user_id"] is not None:
|
|
||||||
cal_q = cal_q.where(CalendarEntry.user_id == ident["user_id"])
|
|
||||||
else:
|
|
||||||
cal_q = cal_q.where(CalendarEntry.session_id == ident["session_id"])
|
|
||||||
|
|
||||||
if page_post_id is not None:
|
|
||||||
cal_ids = select(Calendar.id).where(
|
|
||||||
Calendar.post_id == page_post_id,
|
|
||||||
Calendar.deleted_at.is_(None),
|
|
||||||
).scalar_subquery()
|
|
||||||
cal_q = cal_q.where(CalendarEntry.calendar_id.in_(cal_ids))
|
|
||||||
|
|
||||||
cal_result = await g.s.execute(cal_q)
|
|
||||||
cal_entries = cal_result.scalars().all()
|
|
||||||
|
|
||||||
calendar_count = len(cal_entries)
|
|
||||||
calendar_total = sum((e.cost or 0) for e in cal_entries if e.cost is not None)
|
|
||||||
|
|
||||||
items = [
|
|
||||||
{
|
|
||||||
"slug": ci.product.slug if ci.product else None,
|
|
||||||
"title": ci.product.title if ci.product else None,
|
|
||||||
"image": ci.product.image if ci.product else None,
|
|
||||||
"quantity": ci.quantity,
|
|
||||||
"price": float(ci.product.special_price or ci.product.regular_price or 0)
|
|
||||||
if ci.product
|
|
||||||
else 0,
|
|
||||||
}
|
|
||||||
for ci in cart_items
|
|
||||||
]
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"count": cart_count,
|
|
||||||
"total": float(cart_total),
|
|
||||||
"calendar_count": calendar_count,
|
|
||||||
"calendar_total": float(calendar_total),
|
|
||||||
"items": items,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@bp.post("/adopt")
|
|
||||||
@csrf_exempt
|
|
||||||
async def adopt():
|
|
||||||
"""
|
|
||||||
Adopt anonymous cart items + calendar entries for a user.
|
|
||||||
Called by the coop app after successful 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
|
|
||||||
|
|
||||||
# --- adopt cart items ---
|
|
||||||
anon_result = await g.s.execute(
|
|
||||||
select(CartItem).where(
|
|
||||||
CartItem.deleted_at.is_(None),
|
|
||||||
CartItem.user_id.is_(None),
|
|
||||||
CartItem.session_id == session_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
anon_items = anon_result.scalars().all()
|
|
||||||
|
|
||||||
if anon_items:
|
|
||||||
# Soft-delete existing user cart
|
|
||||||
await g.s.execute(
|
|
||||||
update(CartItem)
|
|
||||||
.where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id)
|
|
||||||
.values(deleted_at=func.now())
|
|
||||||
)
|
|
||||||
for ci in anon_items:
|
|
||||||
ci.user_id = user_id
|
|
||||||
|
|
||||||
# --- adopt calendar entries ---
|
|
||||||
await g.s.execute(
|
|
||||||
update(CalendarEntry)
|
|
||||||
.where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id)
|
|
||||||
.values(deleted_at=func.now())
|
|
||||||
)
|
|
||||||
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})
|
|
||||||
|
|
||||||
return bp
|
|
||||||
@@ -5,15 +5,18 @@ from __future__ import annotations
|
|||||||
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
|
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from models.order import Order
|
from shared.models.market import CartItem
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.models.order import Order
|
||||||
|
from shared.models.market_place import MarketPlace
|
||||||
|
from shared.services.registry import services
|
||||||
from .services import (
|
from .services import (
|
||||||
current_cart_identity,
|
current_cart_identity,
|
||||||
get_cart,
|
get_cart,
|
||||||
total,
|
total,
|
||||||
clear_cart_for_order,
|
|
||||||
get_calendar_cart_entries,
|
get_calendar_cart_entries,
|
||||||
calendar_total,
|
calendar_total,
|
||||||
|
get_ticket_cart_entries,
|
||||||
|
ticket_total,
|
||||||
check_sumup_status,
|
check_sumup_status,
|
||||||
)
|
)
|
||||||
from .services.checkout import (
|
from .services.checkout import (
|
||||||
@@ -26,8 +29,8 @@ from .services.checkout import (
|
|||||||
validate_webhook_secret,
|
validate_webhook_secret,
|
||||||
get_order_with_details,
|
get_order_with_details,
|
||||||
)
|
)
|
||||||
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||||
from config import config
|
from shared.browser.app.csrf import csrf_exempt
|
||||||
|
|
||||||
|
|
||||||
def register(url_prefix: str) -> Blueprint:
|
def register(url_prefix: str) -> Blueprint:
|
||||||
@@ -53,24 +56,96 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
return redirect(url_for("cart_overview.overview"))
|
return redirect(url_for("cart_overview.overview"))
|
||||||
|
|
||||||
|
@bp.post("/quantity/<int:product_id>/")
|
||||||
|
async def update_quantity(product_id: int):
|
||||||
|
ident = current_cart_identity()
|
||||||
|
form = await request.form
|
||||||
|
count = int(form.get("count", 0))
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
CartItem.deleted_at.is_(None),
|
||||||
|
CartItem.product_id == product_id,
|
||||||
|
]
|
||||||
|
if ident["user_id"] is not None:
|
||||||
|
filters.append(CartItem.user_id == ident["user_id"])
|
||||||
|
else:
|
||||||
|
filters.append(CartItem.session_id == ident["session_id"])
|
||||||
|
|
||||||
|
existing = await g.s.scalar(select(CartItem).where(*filters))
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.quantity = max(count, 0)
|
||||||
|
await g.s.flush()
|
||||||
|
|
||||||
|
resp = await make_response("", 200)
|
||||||
|
resp.headers["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@bp.post("/ticket-quantity/")
|
||||||
|
async def update_ticket_quantity():
|
||||||
|
"""Adjust reserved ticket count (+/- pattern, like products)."""
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
await g.s.flush()
|
||||||
|
|
||||||
|
resp = await make_response("", 200)
|
||||||
|
resp.headers["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@bp.post("/delete/<int:product_id>/")
|
||||||
|
async def delete_item(product_id: int):
|
||||||
|
ident = current_cart_identity()
|
||||||
|
|
||||||
|
filters = [
|
||||||
|
CartItem.deleted_at.is_(None),
|
||||||
|
CartItem.product_id == product_id,
|
||||||
|
]
|
||||||
|
if ident["user_id"] is not None:
|
||||||
|
filters.append(CartItem.user_id == ident["user_id"])
|
||||||
|
else:
|
||||||
|
filters.append(CartItem.session_id == ident["session_id"])
|
||||||
|
|
||||||
|
existing = await g.s.scalar(select(CartItem).where(*filters))
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
await g.s.delete(existing)
|
||||||
|
await g.s.flush()
|
||||||
|
|
||||||
|
resp = await make_response("", 200)
|
||||||
|
resp.headers["HX-Refresh"] = "true"
|
||||||
|
return resp
|
||||||
|
|
||||||
@bp.post("/checkout/")
|
@bp.post("/checkout/")
|
||||||
async def checkout():
|
async def checkout():
|
||||||
"""Legacy global checkout (for orphan items without page scope)."""
|
"""Legacy global checkout (for orphan items without page scope)."""
|
||||||
cart = await get_cart(g.s)
|
cart = await get_cart(g.s)
|
||||||
calendar_entries = await get_calendar_cart_entries(g.s)
|
calendar_entries = await get_calendar_cart_entries(g.s)
|
||||||
|
tickets = await get_ticket_cart_entries(g.s)
|
||||||
|
|
||||||
if not cart and not calendar_entries:
|
if not cart and not calendar_entries and not tickets:
|
||||||
return redirect(url_for("cart_overview.overview"))
|
return redirect(url_for("cart_overview.overview"))
|
||||||
|
|
||||||
product_total = total(cart) or 0
|
product_total = total(cart) or 0
|
||||||
calendar_amount = calendar_total(calendar_entries) or 0
|
calendar_amount = calendar_total(calendar_entries) or 0
|
||||||
cart_total = product_total + calendar_amount
|
ticket_amount = ticket_total(tickets) or 0
|
||||||
|
cart_total = product_total + calendar_amount + ticket_amount
|
||||||
|
|
||||||
if cart_total <= 0:
|
if cart_total <= 0:
|
||||||
return redirect(url_for("cart_overview.overview"))
|
return redirect(url_for("cart_overview.overview"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
page_config = await resolve_page_config(g.s, cart, calendar_entries)
|
page_config = await resolve_page_config(g.s, cart, calendar_entries, tickets)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
"_types/cart/checkout_error.html",
|
"_types/cart/checkout_error.html",
|
||||||
@@ -88,6 +163,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
ident.get("session_id"),
|
ident.get("session_id"),
|
||||||
product_total,
|
product_total,
|
||||||
calendar_amount,
|
calendar_amount,
|
||||||
|
ticket_total=ticket_amount,
|
||||||
)
|
)
|
||||||
|
|
||||||
if page_config:
|
if page_config:
|
||||||
@@ -95,7 +171,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
||||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
||||||
description = build_sumup_description(cart, order.id)
|
description = build_sumup_description(cart, order.id, ticket_count=len(tickets))
|
||||||
|
|
||||||
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
||||||
webhook_url = build_webhook_url(webhook_base_url)
|
webhook_url = build_webhook_url(webhook_base_url)
|
||||||
@@ -107,8 +183,6 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
description=description,
|
description=description,
|
||||||
page_config=page_config,
|
page_config=page_config,
|
||||||
)
|
)
|
||||||
await clear_cart_for_order(g.s, order)
|
|
||||||
|
|
||||||
order.sumup_checkout_id = checkout_data.get("id")
|
order.sumup_checkout_id = checkout_data.get("id")
|
||||||
order.sumup_status = checkout_data.get("status")
|
order.sumup_status = checkout_data.get("status")
|
||||||
order.description = checkout_data.get("description")
|
order.description = checkout_data.get("description")
|
||||||
@@ -129,6 +203,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
return redirect(hosted_url)
|
return redirect(hosted_url)
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
@bp.post("/checkout/webhook/<int:order_id>/")
|
@bp.post("/checkout/webhook/<int:order_id>/")
|
||||||
async def checkout_webhook(order_id: int):
|
async def checkout_webhook(order_id: int):
|
||||||
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
|
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
|
||||||
@@ -179,15 +254,32 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
)
|
)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
status = (order.status or "pending").lower()
|
# Resolve page/market slugs so product links render correctly
|
||||||
|
if order.page_config:
|
||||||
|
post = await services.blog.get_post_by_id(g.s, order.page_config.container_id)
|
||||||
|
if post:
|
||||||
|
g.page_slug = post.slug
|
||||||
|
result = await g.s.execute(
|
||||||
|
select(MarketPlace).where(
|
||||||
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == post.id,
|
||||||
|
MarketPlace.deleted_at.is_(None),
|
||||||
|
).limit(1)
|
||||||
|
)
|
||||||
|
mp = result.scalar_one_or_none()
|
||||||
|
if mp:
|
||||||
|
g.market_slug = mp.slug
|
||||||
|
|
||||||
if order.sumup_checkout_id:
|
if order.sumup_checkout_id:
|
||||||
try:
|
try:
|
||||||
await check_sumup_status(g.s, order)
|
await check_sumup_status(g.s, order)
|
||||||
except Exception:
|
except Exception:
|
||||||
status = status or "pending"
|
pass
|
||||||
|
|
||||||
calendar_entries = order.calendar_entries or []
|
status = (order.status or "pending").lower()
|
||||||
|
|
||||||
|
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
|
||||||
|
order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id)
|
||||||
await g.s.flush()
|
await g.s.flush()
|
||||||
|
|
||||||
html = await render_template(
|
html = await render_template(
|
||||||
@@ -195,6 +287,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
order=order,
|
order=order,
|
||||||
status=status,
|
status=status,
|
||||||
calendar_entries=calendar_entries,
|
calendar_entries=calendar_entries,
|
||||||
|
order_tickets=order_tickets,
|
||||||
)
|
)
|
||||||
return await make_response(html)
|
return await make_response(html)
|
||||||
|
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
# app/cart_merge.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from quart import g, session as qsession
|
|
||||||
from sqlalchemy import select
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from models.market import CartItem
|
|
||||||
|
|
||||||
|
|
||||||
async def merge_anonymous_cart_into_user(user_id: int) -> None:
|
|
||||||
"""
|
|
||||||
When a user logs in, move any anonymous cart (session_id) items onto their user_id.
|
|
||||||
"""
|
|
||||||
sid: Optional[str] = qsession.get("cart_sid")
|
|
||||||
if not sid:
|
|
||||||
return
|
|
||||||
|
|
||||||
# get all anon cart items for this session
|
|
||||||
anon_items = (
|
|
||||||
await g.s.execute(
|
|
||||||
select(CartItem).where(
|
|
||||||
CartItem.deleted_at.is_(None),
|
|
||||||
CartItem.session_id == sid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
if not anon_items:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Existing user items keyed by product_id for quick merge
|
|
||||||
user_items_by_product = {
|
|
||||||
ci.product_id: ci
|
|
||||||
for ci in (
|
|
||||||
await g.s.execute(
|
|
||||||
select(CartItem).where(
|
|
||||||
CartItem.deleted_at.is_(None),
|
|
||||||
CartItem.user_id == user_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).scalars().all()
|
|
||||||
}
|
|
||||||
|
|
||||||
for anon in anon_items:
|
|
||||||
existing = user_items_by_product.get(anon.product_id)
|
|
||||||
if existing:
|
|
||||||
# merge quantities then soft-delete the anon row
|
|
||||||
existing.quantity += anon.quantity
|
|
||||||
anon.deleted_at = func.now()
|
|
||||||
else:
|
|
||||||
# reassign anonymous cart row to this user
|
|
||||||
anon.user_id = user_id
|
|
||||||
anon.session_id = None
|
|
||||||
|
|
||||||
# clear the anonymous session id now that it's "claimed"
|
|
||||||
qsession.pop("cart_sid", None)
|
|
||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from quart import Blueprint, render_template, make_response
|
from quart import Blueprint, render_template, make_response
|
||||||
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from .services import get_cart_grouped_by_page
|
from .services import get_cart_grouped_by_page
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,22 +4,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from quart import Blueprint, g, render_template, redirect, make_response, url_for
|
from quart import Blueprint, g, render_template, redirect, make_response, url_for
|
||||||
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||||
from config import config
|
from shared.config import config
|
||||||
from .services import (
|
from .services import (
|
||||||
total,
|
total,
|
||||||
clear_cart_for_order,
|
|
||||||
calendar_total,
|
calendar_total,
|
||||||
check_sumup_status,
|
ticket_total,
|
||||||
)
|
)
|
||||||
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page
|
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
|
||||||
|
from .services.ticket_groups import group_tickets
|
||||||
from .services.checkout import (
|
from .services.checkout import (
|
||||||
create_order_from_cart,
|
create_order_from_cart,
|
||||||
build_sumup_description,
|
build_sumup_description,
|
||||||
build_sumup_reference,
|
build_sumup_reference,
|
||||||
build_webhook_url,
|
build_webhook_url,
|
||||||
get_order_with_details,
|
|
||||||
)
|
)
|
||||||
from .services import current_cart_identity
|
from .services import current_cart_identity
|
||||||
|
|
||||||
@@ -32,14 +31,20 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
post = g.page_post
|
post = g.page_post
|
||||||
cart = await get_cart_for_page(g.s, post.id)
|
cart = await get_cart_for_page(g.s, post.id)
|
||||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||||
|
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||||
|
|
||||||
|
ticket_groups = group_tickets(page_tickets)
|
||||||
|
|
||||||
tpl_ctx = dict(
|
tpl_ctx = dict(
|
||||||
page_post=post,
|
page_post=post,
|
||||||
page_config=getattr(g, "page_config", None),
|
page_config=getattr(g, "page_config", None),
|
||||||
cart=cart,
|
cart=cart,
|
||||||
calendar_cart_entries=cal_entries,
|
calendar_cart_entries=cal_entries,
|
||||||
|
ticket_cart_entries=page_tickets,
|
||||||
|
ticket_groups=ticket_groups,
|
||||||
total=total,
|
total=total,
|
||||||
calendar_total=calendar_total,
|
calendar_total=calendar_total,
|
||||||
|
ticket_total=ticket_total,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_htmx_request():
|
if not is_htmx_request():
|
||||||
@@ -55,13 +60,15 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
|
|
||||||
cart = await get_cart_for_page(g.s, post.id)
|
cart = await get_cart_for_page(g.s, post.id)
|
||||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||||
|
page_tickets = await get_tickets_for_page(g.s, post.id)
|
||||||
|
|
||||||
if not cart and not cal_entries:
|
if not cart and not cal_entries and not page_tickets:
|
||||||
return redirect(url_for("page_cart.page_view"))
|
return redirect(url_for("page_cart.page_view"))
|
||||||
|
|
||||||
product_total = total(cart) or 0
|
product_total = total(cart) or 0
|
||||||
calendar_amount = calendar_total(cal_entries) or 0
|
calendar_amount = calendar_total(cal_entries) or 0
|
||||||
cart_total = product_total + calendar_amount
|
ticket_amount = ticket_total(page_tickets) or 0
|
||||||
|
cart_total = product_total + calendar_amount + ticket_amount
|
||||||
|
|
||||||
if cart_total <= 0:
|
if cart_total <= 0:
|
||||||
return redirect(url_for("page_cart.page_view"))
|
return redirect(url_for("page_cart.page_view"))
|
||||||
@@ -76,6 +83,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
ident.get("session_id"),
|
ident.get("session_id"),
|
||||||
product_total,
|
product_total,
|
||||||
calendar_amount,
|
calendar_amount,
|
||||||
|
ticket_total=ticket_amount,
|
||||||
page_post_id=post.id,
|
page_post_id=post.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,7 +94,7 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
# Build SumUp checkout details — webhook/return use global routes
|
# Build SumUp checkout details — webhook/return use global routes
|
||||||
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
||||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
||||||
description = build_sumup_description(cart, order.id)
|
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
|
||||||
|
|
||||||
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
||||||
webhook_url = build_webhook_url(webhook_base_url)
|
webhook_url = build_webhook_url(webhook_base_url)
|
||||||
@@ -98,8 +106,6 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
description=description,
|
description=description,
|
||||||
page_config=page_config,
|
page_config=page_config,
|
||||||
)
|
)
|
||||||
await clear_cart_for_order(g.s, order, page_post_id=post.id)
|
|
||||||
|
|
||||||
order.sumup_checkout_id = checkout_data.get("id")
|
order.sumup_checkout_id = checkout_data.get("id")
|
||||||
order.sumup_status = checkout_data.get("status")
|
order.sumup_status = checkout_data.get("status")
|
||||||
order.description = checkout_data.get("description")
|
order.description = checkout_data.get("description")
|
||||||
|
|||||||
@@ -1,253 +0,0 @@
|
|||||||
# app/bp/cart/routes.py
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from quart import Blueprint, g, request, render_template, redirect, url_for, make_response
|
|
||||||
from sqlalchemy import select, update
|
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from models.market import Product, CartItem
|
|
||||||
from models.order import Order, OrderItem
|
|
||||||
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
|
||||||
from .services import (
|
|
||||||
current_cart_identity,
|
|
||||||
get_cart,
|
|
||||||
total,
|
|
||||||
clear_cart_for_order,
|
|
||||||
get_calendar_cart_entries, # NEW
|
|
||||||
calendar_total, # NEW
|
|
||||||
check_sumup_status
|
|
||||||
)
|
|
||||||
from .services.checkout import (
|
|
||||||
find_or_create_cart_item,
|
|
||||||
create_order_from_cart,
|
|
||||||
resolve_page_config,
|
|
||||||
build_sumup_description,
|
|
||||||
build_sumup_reference,
|
|
||||||
build_webhook_url,
|
|
||||||
validate_webhook_secret,
|
|
||||||
get_order_with_details,
|
|
||||||
)
|
|
||||||
from config import config
|
|
||||||
from models.calendars import CalendarEntry # NEW
|
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
|
||||||
|
|
||||||
def register(url_prefix: str) -> Blueprint:
|
|
||||||
bp = Blueprint("cart", __name__, url_prefix=url_prefix)
|
|
||||||
|
|
||||||
# NOTE: load_cart moved to shared/cart_loader.py
|
|
||||||
# and registered in shared/factory.py as an app-level before_request
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#@bp.context_processor
|
|
||||||
#async def inject_root():
|
|
||||||
|
|
||||||
# return {
|
|
||||||
# "total": total,
|
|
||||||
# "calendar_total": calendar_total, # NEW helper
|
|
||||||
#
|
|
||||||
# }
|
|
||||||
|
|
||||||
@bp.get("/")
|
|
||||||
async def view_cart():
|
|
||||||
if not is_htmx_request():
|
|
||||||
# Normal browser request: full page with layout
|
|
||||||
html = await render_template(
|
|
||||||
"_types/cart/index.html",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
|
|
||||||
html = await render_template(
|
|
||||||
"_types/cart/_oob_elements.html",
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/add/<int:product_id>/")
|
|
||||||
async def add_to_cart(product_id: int):
|
|
||||||
ident = current_cart_identity()
|
|
||||||
|
|
||||||
cart_item = await find_or_create_cart_item(
|
|
||||||
g.s,
|
|
||||||
product_id,
|
|
||||||
ident["user_id"],
|
|
||||||
ident["session_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
if not cart_item:
|
|
||||||
return await make_response("Product not found", 404)
|
|
||||||
|
|
||||||
# htmx support (optional)
|
|
||||||
if request.headers.get("HX-Request") == "true":
|
|
||||||
return await view_cart()
|
|
||||||
|
|
||||||
# normal POST: go to cart page
|
|
||||||
return redirect(url_for("cart.view_cart"))
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/checkout/")
|
|
||||||
async def checkout():
|
|
||||||
"""Create an Order from the current cart and redirect to SumUp Hosted Checkout."""
|
|
||||||
# Build cart
|
|
||||||
cart = await get_cart(g.s)
|
|
||||||
calendar_entries = await get_calendar_cart_entries(g.s)
|
|
||||||
|
|
||||||
if not cart and not calendar_entries:
|
|
||||||
return redirect(url_for("cart.view_cart"))
|
|
||||||
|
|
||||||
product_total = total(cart) or 0
|
|
||||||
calendar_amount = calendar_total(calendar_entries) or 0
|
|
||||||
cart_total = product_total + calendar_amount
|
|
||||||
|
|
||||||
if cart_total <= 0:
|
|
||||||
return redirect(url_for("cart.view_cart"))
|
|
||||||
|
|
||||||
# Resolve per-page credentials
|
|
||||||
try:
|
|
||||||
page_config = await resolve_page_config(g.s, cart, calendar_entries)
|
|
||||||
except ValueError as e:
|
|
||||||
html = await render_template(
|
|
||||||
"_types/cart/checkout_error.html",
|
|
||||||
order=None,
|
|
||||||
error=str(e),
|
|
||||||
)
|
|
||||||
return await make_response(html, 400)
|
|
||||||
|
|
||||||
# Create order from cart
|
|
||||||
ident = current_cart_identity()
|
|
||||||
order = await create_order_from_cart(
|
|
||||||
g.s,
|
|
||||||
cart,
|
|
||||||
calendar_entries,
|
|
||||||
ident.get("user_id"),
|
|
||||||
ident.get("session_id"),
|
|
||||||
product_total,
|
|
||||||
calendar_amount,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set page_config on order if resolved
|
|
||||||
if page_config:
|
|
||||||
order.page_config_id = page_config.id
|
|
||||||
|
|
||||||
# Build SumUp checkout details
|
|
||||||
redirect_url = url_for("cart.checkout_return", order_id=order.id, _external=True)
|
|
||||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
|
||||||
description = build_sumup_description(cart, order.id)
|
|
||||||
|
|
||||||
webhook_base_url = url_for("cart.checkout_webhook", order_id=order.id, _external=True)
|
|
||||||
webhook_url = build_webhook_url(webhook_base_url)
|
|
||||||
|
|
||||||
checkout_data = await sumup_create_checkout(
|
|
||||||
order,
|
|
||||||
redirect_url=redirect_url,
|
|
||||||
webhook_url=webhook_url,
|
|
||||||
description=description,
|
|
||||||
page_config=page_config,
|
|
||||||
)
|
|
||||||
await clear_cart_for_order(g.s, order)
|
|
||||||
|
|
||||||
order.sumup_checkout_id = checkout_data.get("id")
|
|
||||||
order.sumup_status = checkout_data.get("status")
|
|
||||||
order.description = checkout_data.get("description")
|
|
||||||
|
|
||||||
hosted_cfg = checkout_data.get("hosted_checkout") or {}
|
|
||||||
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
|
|
||||||
order.sumup_hosted_url = hosted_url
|
|
||||||
|
|
||||||
await g.s.flush()
|
|
||||||
|
|
||||||
if not hosted_url:
|
|
||||||
html = await render_template(
|
|
||||||
"_types/cart/checkout_error.html",
|
|
||||||
order=order,
|
|
||||||
error="No hosted checkout URL returned from SumUp.",
|
|
||||||
)
|
|
||||||
return await make_response(html, 500)
|
|
||||||
|
|
||||||
return redirect(hosted_url)
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/checkout/webhook/<int:order_id>/")
|
|
||||||
async def checkout_webhook(order_id: int):
|
|
||||||
"""
|
|
||||||
Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events.
|
|
||||||
|
|
||||||
Security:
|
|
||||||
- Optional shared secret in ?token=... (checked against config sumup.webhook_secret)
|
|
||||||
- We *always* verify the event by calling SumUp's API.
|
|
||||||
"""
|
|
||||||
# Optional shared secret check
|
|
||||||
if not validate_webhook_secret(request.args.get("token")):
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
try:
|
|
||||||
payload = await request.get_json()
|
|
||||||
except Exception:
|
|
||||||
payload = None
|
|
||||||
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
checkout_id = payload.get("id")
|
|
||||||
if not checkout_id:
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
# Look up our order
|
|
||||||
result = await g.s.execute(select(Order).where(Order.id == order_id))
|
|
||||||
order = result.scalar_one_or_none()
|
|
||||||
if not order:
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
# Make sure the checkout id matches the one we stored
|
|
||||||
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
# Verify with SumUp
|
|
||||||
try:
|
|
||||||
await check_sumup_status(g.s, order)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return "", 204
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/checkout/return/<int:order_id>/")
|
|
||||||
async def checkout_return(order_id: int):
|
|
||||||
"""Handle the browser returning from SumUp after payment."""
|
|
||||||
order = await get_order_with_details(g.s, order_id)
|
|
||||||
|
|
||||||
if not order:
|
|
||||||
html = await render_template(
|
|
||||||
"_types/cart/checkout_return.html",
|
|
||||||
order=None,
|
|
||||||
status="missing",
|
|
||||||
calendar_entries=[],
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
status = (order.status or "pending").lower()
|
|
||||||
|
|
||||||
# Optionally refresh status from SumUp
|
|
||||||
if order.sumup_checkout_id:
|
|
||||||
try:
|
|
||||||
await check_sumup_status(g.s, order)
|
|
||||||
except Exception:
|
|
||||||
status = status or "pending"
|
|
||||||
|
|
||||||
calendar_entries = order.calendar_entries or []
|
|
||||||
await g.s.flush()
|
|
||||||
|
|
||||||
html = await render_template(
|
|
||||||
"_types/cart/checkout_return.html",
|
|
||||||
order=order,
|
|
||||||
status=status,
|
|
||||||
calendar_entries=calendar_entries,
|
|
||||||
)
|
|
||||||
return await make_response(html)
|
|
||||||
|
|
||||||
return bp
|
|
||||||
@@ -2,12 +2,12 @@ from .get_cart import get_cart
|
|||||||
from .identity import current_cart_identity
|
from .identity import current_cart_identity
|
||||||
from .total import total
|
from .total import total
|
||||||
from .clear_cart_for_order import clear_cart_for_order
|
from .clear_cart_for_order import clear_cart_for_order
|
||||||
from .adopt_session_cart_for_user import adopt_session_cart_for_user
|
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
|
||||||
from .calendar_cart import get_calendar_cart_entries, calendar_total
|
|
||||||
from .check_sumup_status import check_sumup_status
|
from .check_sumup_status import check_sumup_status
|
||||||
from .page_cart import (
|
from .page_cart import (
|
||||||
get_cart_for_page,
|
get_cart_for_page,
|
||||||
get_calendar_entries_for_page,
|
get_calendar_entries_for_page,
|
||||||
|
get_tickets_for_page,
|
||||||
get_cart_grouped_by_page,
|
get_cart_grouped_by_page,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
from sqlalchemy import select, update, func
|
|
||||||
|
|
||||||
from models.market import CartItem
|
|
||||||
|
|
||||||
|
|
||||||
async def adopt_session_cart_for_user(session, user_id: int, session_id: str | None) -> None:
|
|
||||||
"""
|
|
||||||
When a user logs in or registers:
|
|
||||||
- If there are cart items for this anonymous session, take them over.
|
|
||||||
- Replace any existing cart items for this user with the anonymous cart.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 1) Find anonymous cart items for this session
|
|
||||||
result = await session.execute(
|
|
||||||
select(CartItem)
|
|
||||||
.where(
|
|
||||||
CartItem.deleted_at.is_(None),
|
|
||||||
CartItem.user_id.is_(None),
|
|
||||||
CartItem.session_id == session_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
anon_items = result.scalars().all()
|
|
||||||
if not anon_items:
|
|
||||||
# nothing to adopt
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2) Soft-delete any existing cart for this user
|
|
||||||
await session.execute(
|
|
||||||
update(CartItem)
|
|
||||||
.where(
|
|
||||||
CartItem.deleted_at.is_(None),
|
|
||||||
CartItem.user_id == user_id,
|
|
||||||
)
|
|
||||||
.values(deleted_at=func.now())
|
|
||||||
)
|
|
||||||
|
|
||||||
# 3) Reassign anonymous cart items to the user
|
|
||||||
for ci in anon_items:
|
|
||||||
ci.user_id = user_id
|
|
||||||
# optional: you can keep the session_id as well, but user_id will take precedence
|
|
||||||
# ci.session_id = session_id
|
|
||||||
|
|
||||||
# No explicit commit here; caller's transaction will handle it
|
|
||||||
@@ -1,46 +1,45 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import select
|
from decimal import Decimal
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
|
|
||||||
from models.calendars import CalendarEntry
|
from shared.services.registry import services
|
||||||
from .identity import current_cart_identity
|
from .identity import current_cart_identity
|
||||||
|
|
||||||
|
|
||||||
async def get_calendar_cart_entries(session):
|
async def get_calendar_cart_entries(session):
|
||||||
"""
|
"""
|
||||||
Return all *pending* calendar entries for the current cart identity
|
Return all *pending* calendar entries (as CalendarEntryDTOs) for the
|
||||||
(user or anonymous session).
|
current cart identity (user or anonymous session).
|
||||||
"""
|
"""
|
||||||
ident = current_cart_identity()
|
ident = current_cart_identity()
|
||||||
|
return await services.calendar.pending_entries(
|
||||||
filters = [
|
session,
|
||||||
CalendarEntry.deleted_at.is_(None),
|
user_id=ident["user_id"],
|
||||||
CalendarEntry.state == "pending",
|
session_id=ident["session_id"],
|
||||||
]
|
|
||||||
|
|
||||||
if ident["user_id"] is not None:
|
|
||||||
filters.append(CalendarEntry.user_id == ident["user_id"])
|
|
||||||
else:
|
|
||||||
filters.append(CalendarEntry.session_id == ident["session_id"])
|
|
||||||
|
|
||||||
result = await session.execute(
|
|
||||||
select(CalendarEntry)
|
|
||||||
.where(*filters)
|
|
||||||
.order_by(CalendarEntry.start_at.asc())
|
|
||||||
.options(
|
|
||||||
selectinload(CalendarEntry.calendar),
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
def calendar_total(entries) -> float:
|
def calendar_total(entries) -> Decimal:
|
||||||
"""
|
"""
|
||||||
Total cost of pending calendar entries.
|
Total cost of pending calendar entries.
|
||||||
"""
|
"""
|
||||||
return sum(
|
return sum(
|
||||||
(e.cost or 0)
|
(Decimal(str(e.cost)) if e.cost else Decimal(0))
|
||||||
for e in entries
|
for e in entries
|
||||||
if e.cost is not None
|
if e.cost is not None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_ticket_cart_entries(session):
|
||||||
|
"""Return all reserved tickets (as TicketDTOs) for the current identity."""
|
||||||
|
ident = current_cart_identity()
|
||||||
|
return await services.calendar.pending_tickets(
|
||||||
|
session,
|
||||||
|
user_id=ident["user_id"],
|
||||||
|
session_id=ident["session_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ticket_total(tickets) -> Decimal:
|
||||||
|
"""Total cost of reserved tickets."""
|
||||||
|
return sum((Decimal(str(t.price)) if t.price else Decimal(0) for t in tickets), Decimal(0))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout
|
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
|
||||||
from sqlalchemy import update
|
from shared.events import emit_activity
|
||||||
from models.calendars import CalendarEntry
|
from shared.services.registry import services
|
||||||
|
from .clear_cart_for_order import clear_cart_for_order
|
||||||
|
|
||||||
|
|
||||||
async def check_sumup_status(session, order):
|
async def check_sumup_status(session, order):
|
||||||
@@ -13,20 +14,26 @@ async def check_sumup_status(session, order):
|
|||||||
if sumup_status == "PAID":
|
if sumup_status == "PAID":
|
||||||
if order.status != "paid":
|
if order.status != "paid":
|
||||||
order.status = "paid"
|
order.status = "paid"
|
||||||
filters = [
|
await services.calendar.confirm_entries_for_order(
|
||||||
CalendarEntry.deleted_at.is_(None),
|
session, order.id, order.user_id, order.session_id
|
||||||
CalendarEntry.state == "ordered",
|
)
|
||||||
CalendarEntry.order_id==order.id,
|
await services.calendar.confirm_tickets_for_order(session, order.id)
|
||||||
]
|
|
||||||
if order.user_id is not None:
|
|
||||||
filters.append(CalendarEntry.user_id == order.user_id)
|
|
||||||
elif order.session_id is not None:
|
|
||||||
filters.append(CalendarEntry.session_id == order.session_id)
|
|
||||||
|
|
||||||
await session.execute(
|
# Clear cart only after payment is confirmed
|
||||||
update(CalendarEntry)
|
page_post_id = page_config.container_id if page_config else None
|
||||||
.where(*filters)
|
await clear_cart_for_order(session, order, page_post_id=page_post_id)
|
||||||
.values(state="provisional")
|
|
||||||
|
await emit_activity(
|
||||||
|
session,
|
||||||
|
activity_type="rose:OrderPaid",
|
||||||
|
actor_uri="internal:cart",
|
||||||
|
object_type="rose:Order",
|
||||||
|
object_data={
|
||||||
|
"order_id": order.id,
|
||||||
|
"user_id": order.user_id,
|
||||||
|
},
|
||||||
|
source_type="order",
|
||||||
|
source_id=order.id,
|
||||||
)
|
)
|
||||||
elif sumup_status == "FAILED":
|
elif sumup_status == "FAILED":
|
||||||
order.status = "failed"
|
order.status = "failed"
|
||||||
|
|||||||
@@ -3,16 +3,18 @@ from __future__ import annotations
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from sqlalchemy import select, update
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.market import Product, CartItem
|
from shared.models.market import Product, CartItem
|
||||||
from models.order import Order, OrderItem
|
from shared.models.order import Order, OrderItem
|
||||||
from models.calendars import CalendarEntry, Calendar
|
from shared.models.page_config import PageConfig
|
||||||
from models.page_config import PageConfig
|
from shared.models.market_place import MarketPlace
|
||||||
from models.market_place import MarketPlace
|
from shared.config import config
|
||||||
from config import config
|
from shared.contracts.dtos import CalendarEntryDTO
|
||||||
|
from shared.events import emit_activity
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
|
||||||
async def find_or_create_cart_item(
|
async def find_or_create_cart_item(
|
||||||
@@ -62,7 +64,8 @@ async def find_or_create_cart_item(
|
|||||||
async def resolve_page_config(
|
async def resolve_page_config(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
cart: list[CartItem],
|
cart: list[CartItem],
|
||||||
calendar_entries: list[CalendarEntry],
|
calendar_entries: list[CalendarEntryDTO],
|
||||||
|
tickets=None,
|
||||||
) -> Optional["PageConfig"]:
|
) -> Optional["PageConfig"]:
|
||||||
"""Determine the PageConfig for this order.
|
"""Determine the PageConfig for this order.
|
||||||
|
|
||||||
@@ -76,13 +79,17 @@ async def resolve_page_config(
|
|||||||
if ci.market_place_id:
|
if ci.market_place_id:
|
||||||
mp = await session.get(MarketPlace, ci.market_place_id)
|
mp = await session.get(MarketPlace, ci.market_place_id)
|
||||||
if mp:
|
if mp:
|
||||||
post_ids.add(mp.post_id)
|
post_ids.add(mp.container_id)
|
||||||
|
|
||||||
# From calendar entries via calendar
|
# From calendar entries via calendar
|
||||||
for entry in calendar_entries:
|
for entry in calendar_entries:
|
||||||
cal = await session.get(Calendar, entry.calendar_id)
|
if entry.calendar_container_id:
|
||||||
if cal and cal.post_id:
|
post_ids.add(entry.calendar_container_id)
|
||||||
post_ids.add(cal.post_id)
|
|
||||||
|
# From tickets via calendar_container_id
|
||||||
|
for tk in (tickets or []):
|
||||||
|
if tk.calendar_container_id:
|
||||||
|
post_ids.add(tk.calendar_container_id)
|
||||||
|
|
||||||
if len(post_ids) > 1:
|
if len(post_ids) > 1:
|
||||||
raise ValueError("Cannot checkout items from multiple pages")
|
raise ValueError("Cannot checkout items from multiple pages")
|
||||||
@@ -92,7 +99,10 @@ async def resolve_page_config(
|
|||||||
|
|
||||||
post_id = post_ids.pop()
|
post_id = post_ids.pop()
|
||||||
pc = (await session.execute(
|
pc = (await session.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 pc
|
return pc
|
||||||
|
|
||||||
@@ -100,22 +110,23 @@ async def resolve_page_config(
|
|||||||
async def create_order_from_cart(
|
async def create_order_from_cart(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
cart: list[CartItem],
|
cart: list[CartItem],
|
||||||
calendar_entries: list[CalendarEntry],
|
calendar_entries: list[CalendarEntryDTO],
|
||||||
user_id: Optional[int],
|
user_id: Optional[int],
|
||||||
session_id: Optional[str],
|
session_id: Optional[str],
|
||||||
product_total: float,
|
product_total: float,
|
||||||
calendar_total: float,
|
calendar_total: float,
|
||||||
*,
|
*,
|
||||||
|
ticket_total: float = 0,
|
||||||
page_post_id: int | None = None,
|
page_post_id: int | None = None,
|
||||||
) -> Order:
|
) -> Order:
|
||||||
"""
|
"""
|
||||||
Create an Order and OrderItems from the current cart + calendar entries.
|
Create an Order and OrderItems from the current cart + calendar entries + tickets.
|
||||||
|
|
||||||
When *page_post_id* is given, only calendar entries whose calendar
|
When *page_post_id* is given, only calendar entries/tickets whose calendar
|
||||||
belongs to that page are marked as "ordered". Otherwise all pending
|
belongs to that page are marked as "ordered". Otherwise all pending
|
||||||
entries are updated (legacy behaviour).
|
entries are updated (legacy behaviour).
|
||||||
"""
|
"""
|
||||||
cart_total = product_total + calendar_total
|
cart_total = product_total + calendar_total + ticket_total
|
||||||
|
|
||||||
# Determine currency from first product
|
# Determine currency from first product
|
||||||
first_product = cart[0].product if cart else None
|
first_product = cart[0].product if cart else None
|
||||||
@@ -145,50 +156,51 @@ async def create_order_from_cart(
|
|||||||
)
|
)
|
||||||
session.add(oi)
|
session.add(oi)
|
||||||
|
|
||||||
# Update calendar entries to reference this order
|
# Mark pending calendar entries as "ordered" via calendar service
|
||||||
calendar_filters = [
|
await services.calendar.claim_entries_for_order(
|
||||||
CalendarEntry.deleted_at.is_(None),
|
session, order.id, user_id, session_id, page_post_id
|
||||||
CalendarEntry.state == "pending",
|
|
||||||
]
|
|
||||||
|
|
||||||
if order.user_id is not None:
|
|
||||||
calendar_filters.append(CalendarEntry.user_id == order.user_id)
|
|
||||||
elif order.session_id is not None:
|
|
||||||
calendar_filters.append(CalendarEntry.session_id == order.session_id)
|
|
||||||
|
|
||||||
if page_post_id is not None:
|
|
||||||
cal_ids = select(Calendar.id).where(
|
|
||||||
Calendar.post_id == page_post_id,
|
|
||||||
Calendar.deleted_at.is_(None),
|
|
||||||
).scalar_subquery()
|
|
||||||
calendar_filters.append(CalendarEntry.calendar_id.in_(cal_ids))
|
|
||||||
|
|
||||||
await session.execute(
|
|
||||||
update(CalendarEntry)
|
|
||||||
.where(*calendar_filters)
|
|
||||||
.values(
|
|
||||||
state="ordered",
|
|
||||||
order_id=order.id,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Claim reserved tickets for this order
|
||||||
|
await services.calendar.claim_tickets_for_order(
|
||||||
|
session, order.id, user_id, session_id, page_post_id
|
||||||
|
)
|
||||||
|
|
||||||
|
await emit_activity(
|
||||||
|
session,
|
||||||
|
activity_type="Create",
|
||||||
|
actor_uri="internal:cart",
|
||||||
|
object_type="rose:Order",
|
||||||
|
object_data={
|
||||||
|
"order_id": order.id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"session_id": session_id,
|
||||||
|
},
|
||||||
|
source_type="order",
|
||||||
|
source_id=order.id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return order
|
return order
|
||||||
|
|
||||||
|
|
||||||
def build_sumup_description(cart: list[CartItem], order_id: int) -> str:
|
def build_sumup_description(cart: list[CartItem], order_id: int, *, ticket_count: int = 0) -> str:
|
||||||
"""Build a human-readable description for SumUp checkout."""
|
"""Build a human-readable description for SumUp checkout."""
|
||||||
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
|
titles = [ci.product.title for ci in cart if ci.product and ci.product.title]
|
||||||
item_count = sum(ci.quantity for ci in cart)
|
item_count = sum(ci.quantity for ci in cart)
|
||||||
|
|
||||||
|
parts = []
|
||||||
if titles:
|
if titles:
|
||||||
if len(titles) <= 3:
|
if len(titles) <= 3:
|
||||||
summary = ", ".join(titles)
|
parts.append(", ".join(titles))
|
||||||
else:
|
else:
|
||||||
summary = ", ".join(titles[:3]) + f" + {len(titles) - 3} more"
|
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
|
||||||
else:
|
if ticket_count:
|
||||||
summary = "order items"
|
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
|
||||||
|
|
||||||
return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}"
|
summary = ", ".join(parts) if parts else "order items"
|
||||||
|
total_count = item_count + ticket_count
|
||||||
|
|
||||||
|
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
|
||||||
|
|
||||||
|
|
||||||
def build_sumup_reference(order_id: int, page_config=None) -> str:
|
def build_sumup_reference(order_id: int, page_config=None) -> str:
|
||||||
@@ -230,7 +242,6 @@ async def get_order_with_details(session: AsyncSession, order_id: int) -> Option
|
|||||||
select(Order)
|
select(Order)
|
||||||
.options(
|
.options(
|
||||||
selectinload(Order.items).selectinload(OrderItem.product),
|
selectinload(Order.items).selectinload(OrderItem.product),
|
||||||
selectinload(Order.calendar_entries),
|
|
||||||
)
|
)
|
||||||
.where(Order.id == order_id)
|
.where(Order.id == order_id)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from sqlalchemy import update, func, select
|
from sqlalchemy import update, func, select
|
||||||
|
|
||||||
from models.market import CartItem
|
from shared.models.market import CartItem
|
||||||
from models.market_place import MarketPlace
|
from shared.models.market_place import MarketPlace
|
||||||
from models.order import Order
|
from shared.models.order import Order
|
||||||
|
|
||||||
|
|
||||||
async def clear_cart_for_order(session, order: Order, *, page_post_id: int | None = None) -> None:
|
async def clear_cart_for_order(session, order: Order, *, page_post_id: int | None = None) -> None:
|
||||||
@@ -24,7 +24,8 @@ async def clear_cart_for_order(session, order: Order, *, page_post_id: int | Non
|
|||||||
|
|
||||||
if page_post_id is not None:
|
if page_post_id is not None:
|
||||||
mp_ids = select(MarketPlace.id).where(
|
mp_ids = select(MarketPlace.id).where(
|
||||||
MarketPlace.post_id == page_post_id,
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == page_post_id,
|
||||||
MarketPlace.deleted_at.is_(None),
|
MarketPlace.deleted_at.is_(None),
|
||||||
).scalar_subquery()
|
).scalar_subquery()
|
||||||
filters.append(CartItem.market_place_id.in_(mp_ids))
|
filters.append(CartItem.market_place_id.in_(mp_ids))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.market import CartItem
|
from shared.models.market import CartItem
|
||||||
from .identity import current_cart_identity
|
from .identity import current_cart_identity
|
||||||
|
|
||||||
async def get_cart(session):
|
async def get_cart(session):
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Re-export from canonical shared location
|
# Re-export from canonical shared location
|
||||||
from shared.cart_identity import CartIdentity, current_cart_identity
|
from shared.infrastructure.cart_identity import CartIdentity, current_cart_identity
|
||||||
|
|
||||||
__all__ = ["CartIdentity", "current_cart_identity"]
|
__all__ = ["CartIdentity", "current_cart_identity"]
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
Page-scoped cart queries.
|
Page-scoped cart queries.
|
||||||
|
|
||||||
Groups cart items and calendar entries by their owning page (Post),
|
Groups cart items and calendar entries by their owning page (Post),
|
||||||
determined via CartItem.market_place.post_id and CalendarEntry.calendar.post_id.
|
determined via CartItem.market_place.container_id and CalendarEntry.calendar.container_id
|
||||||
|
(where container_type == "page").
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -11,21 +12,21 @@ from collections import defaultdict
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from models.market import CartItem
|
from shared.models.market import CartItem
|
||||||
from models.market_place import MarketPlace
|
from shared.models.market_place import MarketPlace
|
||||||
from models.calendars import CalendarEntry, Calendar
|
from shared.models.page_config import PageConfig
|
||||||
from models.ghost_content import Post
|
from shared.services.registry import services
|
||||||
from models.page_config import PageConfig
|
|
||||||
from .identity import current_cart_identity
|
from .identity import current_cart_identity
|
||||||
|
|
||||||
|
|
||||||
async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
|
async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
|
||||||
"""Return cart items scoped to a specific page (via MarketPlace.post_id)."""
|
"""Return cart items scoped to a specific page (via MarketPlace.container_id)."""
|
||||||
ident = current_cart_identity()
|
ident = current_cart_identity()
|
||||||
|
|
||||||
filters = [
|
filters = [
|
||||||
CartItem.deleted_at.is_(None),
|
CartItem.deleted_at.is_(None),
|
||||||
MarketPlace.post_id == post_id,
|
MarketPlace.container_type == "page",
|
||||||
|
MarketPlace.container_id == post_id,
|
||||||
MarketPlace.deleted_at.is_(None),
|
MarketPlace.deleted_at.is_(None),
|
||||||
]
|
]
|
||||||
if ident["user_id"] is not None:
|
if ident["user_id"] is not None:
|
||||||
@@ -46,40 +47,36 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
async def get_calendar_entries_for_page(session, post_id: int) -> list[CalendarEntry]:
|
async def get_calendar_entries_for_page(session, post_id: int):
|
||||||
"""Return pending calendar entries scoped to a specific page (via Calendar.post_id)."""
|
"""Return pending calendar entries (DTOs) scoped to a specific page."""
|
||||||
ident = current_cart_identity()
|
ident = current_cart_identity()
|
||||||
|
return await services.calendar.entries_for_page(
|
||||||
filters = [
|
session, post_id,
|
||||||
CalendarEntry.deleted_at.is_(None),
|
user_id=ident["user_id"],
|
||||||
CalendarEntry.state == "pending",
|
session_id=ident["session_id"],
|
||||||
Calendar.post_id == post_id,
|
)
|
||||||
Calendar.deleted_at.is_(None),
|
|
||||||
]
|
|
||||||
if ident["user_id"] is not None:
|
async def get_tickets_for_page(session, post_id: int):
|
||||||
filters.append(CalendarEntry.user_id == ident["user_id"])
|
"""Return reserved tickets (DTOs) scoped to a specific page."""
|
||||||
else:
|
ident = current_cart_identity()
|
||||||
filters.append(CalendarEntry.session_id == ident["session_id"])
|
return await services.calendar.tickets_for_page(
|
||||||
|
session, post_id,
|
||||||
result = await session.execute(
|
user_id=ident["user_id"],
|
||||||
select(CalendarEntry)
|
session_id=ident["session_id"],
|
||||||
.join(Calendar, CalendarEntry.calendar_id == Calendar.id)
|
|
||||||
.where(*filters)
|
|
||||||
.order_by(CalendarEntry.start_at.asc())
|
|
||||||
.options(selectinload(CalendarEntry.calendar))
|
|
||||||
)
|
)
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
async def get_cart_grouped_by_page(session) -> list[dict]:
|
async def get_cart_grouped_by_page(session) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Load all cart items + calendar entries for the current identity,
|
Load all cart items + calendar entries for the current identity,
|
||||||
grouped by owning page (post_id).
|
grouped by market_place (one card per market).
|
||||||
|
|
||||||
Returns a list of dicts:
|
Returns a list of dicts:
|
||||||
{
|
{
|
||||||
"post": Post | None,
|
"post": Post | None,
|
||||||
"page_config": PageConfig | None,
|
"page_config": PageConfig | None,
|
||||||
|
"market_place": MarketPlace | None,
|
||||||
"cart_items": [...],
|
"cart_items": [...],
|
||||||
"calendar_entries": [...],
|
"calendar_entries": [...],
|
||||||
"product_count": int,
|
"product_count": int,
|
||||||
@@ -89,76 +86,127 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
|
|||||||
"total": float,
|
"total": float,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Calendar entries (no market concept) attach to a page-level group.
|
||||||
Items without a market_place go in an orphan bucket (post=None).
|
Items without a market_place go in an orphan bucket (post=None).
|
||||||
"""
|
"""
|
||||||
from .get_cart import get_cart
|
from .get_cart import get_cart
|
||||||
from .calendar_cart import get_calendar_cart_entries
|
from .calendar_cart import get_calendar_cart_entries, get_ticket_cart_entries
|
||||||
from .total import total as calc_product_total
|
from .total import total as calc_product_total
|
||||||
from .calendar_cart import calendar_total as calc_calendar_total
|
from .calendar_cart import calendar_total as calc_calendar_total, ticket_total as calc_ticket_total
|
||||||
|
|
||||||
cart_items = await get_cart(session)
|
cart_items = await get_cart(session)
|
||||||
cal_entries = await get_calendar_cart_entries(session)
|
cal_entries = await get_calendar_cart_entries(session)
|
||||||
|
all_tickets = await get_ticket_cart_entries(session)
|
||||||
|
|
||||||
# Group by post_id
|
# Group cart items by market_place_id
|
||||||
groups: dict[int | None, dict] = defaultdict(lambda: {
|
market_groups: dict[int | None, dict] = {}
|
||||||
"post_id": None,
|
for ci in cart_items:
|
||||||
|
mp_id = ci.market_place_id if ci.market_place else None
|
||||||
|
if mp_id not in market_groups:
|
||||||
|
market_groups[mp_id] = {
|
||||||
|
"market_place": ci.market_place,
|
||||||
|
"post_id": ci.market_place.container_id if ci.market_place else None,
|
||||||
"cart_items": [],
|
"cart_items": [],
|
||||||
"calendar_entries": [],
|
"calendar_entries": [],
|
||||||
})
|
"tickets": [],
|
||||||
|
}
|
||||||
|
market_groups[mp_id]["cart_items"].append(ci)
|
||||||
|
|
||||||
for ci in cart_items:
|
# Attach calendar entries to an existing market group for the same page,
|
||||||
if ci.market_place and ci.market_place.post_id:
|
# or create a page-level group if no market group exists for that page.
|
||||||
pid = ci.market_place.post_id
|
page_to_market: dict[int | None, int | None] = {}
|
||||||
else:
|
for mp_id, grp in market_groups.items():
|
||||||
pid = None
|
pid = grp["post_id"]
|
||||||
groups[pid]["post_id"] = pid
|
if pid is not None and pid not in page_to_market:
|
||||||
groups[pid]["cart_items"].append(ci)
|
page_to_market[pid] = mp_id
|
||||||
|
|
||||||
for ce in cal_entries:
|
for ce in cal_entries:
|
||||||
if ce.calendar and ce.calendar.post_id:
|
pid = ce.calendar_container_id or None
|
||||||
pid = ce.calendar.post_id
|
if pid in page_to_market:
|
||||||
|
market_groups[page_to_market[pid]]["calendar_entries"].append(ce)
|
||||||
else:
|
else:
|
||||||
pid = None
|
# Create a page-level group for calendar-only entries
|
||||||
groups[pid]["post_id"] = pid
|
key = ("cal", pid)
|
||||||
groups[pid]["calendar_entries"].append(ce)
|
if key not in market_groups:
|
||||||
|
market_groups[key] = {
|
||||||
|
"market_place": None,
|
||||||
|
"post_id": pid,
|
||||||
|
"cart_items": [],
|
||||||
|
"calendar_entries": [],
|
||||||
|
"tickets": [],
|
||||||
|
}
|
||||||
|
if pid is not None:
|
||||||
|
page_to_market[pid] = key
|
||||||
|
market_groups[key]["calendar_entries"].append(ce)
|
||||||
|
|
||||||
# Batch-load Post and PageConfig objects
|
# Attach tickets to page groups (via calendar_container_id)
|
||||||
post_ids = [pid for pid in groups if pid is not None]
|
for tk in all_tickets:
|
||||||
posts_by_id: dict[int, Post] = {}
|
pid = tk.calendar_container_id or None
|
||||||
|
if pid in page_to_market:
|
||||||
|
market_groups[page_to_market[pid]]["tickets"].append(tk)
|
||||||
|
else:
|
||||||
|
key = ("tk", pid)
|
||||||
|
if key not in market_groups:
|
||||||
|
market_groups[key] = {
|
||||||
|
"market_place": None,
|
||||||
|
"post_id": pid,
|
||||||
|
"cart_items": [],
|
||||||
|
"calendar_entries": [],
|
||||||
|
"tickets": [],
|
||||||
|
}
|
||||||
|
if pid is not None:
|
||||||
|
page_to_market[pid] = key
|
||||||
|
market_groups[key]["tickets"].append(tk)
|
||||||
|
|
||||||
|
# Batch-load Post DTOs and PageConfig objects
|
||||||
|
post_ids = list({
|
||||||
|
grp["post_id"] for grp in market_groups.values()
|
||||||
|
if grp["post_id"] is not None
|
||||||
|
})
|
||||||
|
posts_by_id: dict[int, object] = {}
|
||||||
configs_by_post: dict[int, PageConfig] = {}
|
configs_by_post: dict[int, PageConfig] = {}
|
||||||
|
|
||||||
if post_ids:
|
if post_ids:
|
||||||
post_result = await session.execute(
|
for p in await services.blog.get_posts_by_ids(session, post_ids):
|
||||||
select(Post).where(Post.id.in_(post_ids))
|
|
||||||
)
|
|
||||||
for p in post_result.scalars().all():
|
|
||||||
posts_by_id[p.id] = p
|
posts_by_id[p.id] = p
|
||||||
|
|
||||||
pc_result = await session.execute(
|
pc_result = await session.execute(
|
||||||
select(PageConfig).where(PageConfig.post_id.in_(post_ids))
|
select(PageConfig).where(
|
||||||
|
PageConfig.container_type == "page",
|
||||||
|
PageConfig.container_id.in_(post_ids),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for pc in pc_result.scalars().all():
|
for pc in pc_result.scalars().all():
|
||||||
configs_by_post[pc.post_id] = pc
|
configs_by_post[pc.container_id] = pc
|
||||||
|
|
||||||
# Build result list (pages first, orphan last)
|
# Build result list (markets with pages first, orphan last)
|
||||||
result = []
|
result = []
|
||||||
for pid in sorted(groups, key=lambda x: (x is None, x)):
|
for _key, grp in sorted(
|
||||||
grp = groups[pid]
|
market_groups.items(),
|
||||||
|
key=lambda kv: (kv[1]["post_id"] is None, kv[1]["post_id"] or 0),
|
||||||
|
):
|
||||||
items = grp["cart_items"]
|
items = grp["cart_items"]
|
||||||
entries = grp["calendar_entries"]
|
entries = grp["calendar_entries"]
|
||||||
|
tks = grp["tickets"]
|
||||||
prod_total = calc_product_total(items) or 0
|
prod_total = calc_product_total(items) or 0
|
||||||
cal_total = calc_calendar_total(entries) or 0
|
cal_total = calc_calendar_total(entries) or 0
|
||||||
|
tk_total = calc_ticket_total(tks) or 0
|
||||||
|
pid = grp["post_id"]
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
"post": posts_by_id.get(pid) if pid else None,
|
"post": posts_by_id.get(pid) if pid else None,
|
||||||
"page_config": configs_by_post.get(pid) if pid else None,
|
"page_config": configs_by_post.get(pid) if pid else None,
|
||||||
|
"market_place": grp["market_place"],
|
||||||
"cart_items": items,
|
"cart_items": items,
|
||||||
"calendar_entries": entries,
|
"calendar_entries": entries,
|
||||||
|
"tickets": tks,
|
||||||
"product_count": sum(ci.quantity for ci in items),
|
"product_count": sum(ci.quantity for ci in items),
|
||||||
"product_total": prod_total,
|
"product_total": prod_total,
|
||||||
"calendar_count": len(entries),
|
"calendar_count": len(entries),
|
||||||
"calendar_total": cal_total,
|
"calendar_total": cal_total,
|
||||||
"total": prod_total + cal_total,
|
"ticket_count": len(tks),
|
||||||
|
"ticket_total": tk_total,
|
||||||
|
"total": prod_total + cal_total + tk_total,
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|||||||
43
bp/cart/services/ticket_groups.py
Normal file
43
bp/cart/services/ticket_groups.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""Group individual TicketDTOs by (entry_id, ticket_type_id) for cart display."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import OrderedDict
|
||||||
|
|
||||||
|
|
||||||
|
def group_tickets(tickets) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Group a flat list of TicketDTOs into aggregate rows.
|
||||||
|
|
||||||
|
Returns list of dicts:
|
||||||
|
{
|
||||||
|
"entry_id": int,
|
||||||
|
"entry_name": str,
|
||||||
|
"entry_start_at": datetime,
|
||||||
|
"entry_end_at": datetime | None,
|
||||||
|
"ticket_type_id": int | None,
|
||||||
|
"ticket_type_name": str | None,
|
||||||
|
"price": Decimal | None,
|
||||||
|
"quantity": int,
|
||||||
|
"line_total": float,
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
groups: OrderedDict[tuple, dict] = OrderedDict()
|
||||||
|
|
||||||
|
for tk in tickets:
|
||||||
|
key = (tk.entry_id, getattr(tk, "ticket_type_id", None))
|
||||||
|
if key not in groups:
|
||||||
|
groups[key] = {
|
||||||
|
"entry_id": tk.entry_id,
|
||||||
|
"entry_name": tk.entry_name,
|
||||||
|
"entry_start_at": tk.entry_start_at,
|
||||||
|
"entry_end_at": tk.entry_end_at,
|
||||||
|
"ticket_type_id": getattr(tk, "ticket_type_id", None),
|
||||||
|
"ticket_type_name": tk.ticket_type_name,
|
||||||
|
"price": tk.price,
|
||||||
|
"quantity": 0,
|
||||||
|
"line_total": 0,
|
||||||
|
}
|
||||||
|
groups[key]["quantity"] += 1
|
||||||
|
groups[key]["line_total"] += float(tk.price or 0)
|
||||||
|
|
||||||
|
return list(groups.values())
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
def total(cart):
|
def total(cart):
|
||||||
return sum(
|
return sum(
|
||||||
(item.product.special_price or item.product.regular_price) * item.quantity
|
(
|
||||||
|
Decimal(str(item.product.special_price or item.product.regular_price))
|
||||||
|
* item.quantity
|
||||||
|
)
|
||||||
for item in cart
|
for item in cart
|
||||||
if (item.product.special_price or item.product.regular_price) is not None
|
if (item.product.special_price or item.product.regular_price) is not None
|
||||||
)
|
)
|
||||||
|
|||||||
1
bp/fragments/__init__.py
Normal file
1
bp/fragments/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from .routes import register as register_fragments
|
||||||
70
bp/fragments/routes.py
Normal file
70
bp/fragments/routes.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
"""Cart app fragment endpoints.
|
||||||
|
|
||||||
|
Exposes HTML fragments at ``/internal/fragments/<type>`` for consumption
|
||||||
|
by other coop apps via the fragment client.
|
||||||
|
|
||||||
|
Fragments:
|
||||||
|
cart-mini Cart icon with badge (or logo when empty)
|
||||||
|
account-nav-item "orders" link for account dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, request, render_template, g
|
||||||
|
|
||||||
|
from shared.infrastructure.fragments import FRAGMENT_HEADER
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Fragment handlers
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _cart_mini():
|
||||||
|
from shared.services.registry import services
|
||||||
|
|
||||||
|
user_id = request.args.get("user_id", type=int)
|
||||||
|
session_id = request.args.get("session_id")
|
||||||
|
|
||||||
|
summary = await services.cart.cart_summary(
|
||||||
|
g.s, user_id=user_id, session_id=session_id,
|
||||||
|
)
|
||||||
|
count = summary.count + summary.calendar_count + summary.ticket_count
|
||||||
|
return await render_template("fragments/cart_mini.html", cart_count=count)
|
||||||
|
|
||||||
|
async def _account_nav_item():
|
||||||
|
from shared.infrastructure.urls import cart_url
|
||||||
|
|
||||||
|
href = cart_url("/orders/")
|
||||||
|
return (
|
||||||
|
'<div class="relative nav-group">'
|
||||||
|
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
|
||||||
|
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
|
||||||
|
'orders</a></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
_handlers = {
|
||||||
|
"cart-mini": _cart_mini,
|
||||||
|
"account-nav-item": _account_nav_item,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# Routing
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
return bp
|
||||||
@@ -3,8 +3,8 @@ from quart import request
|
|||||||
|
|
||||||
from typing import Iterable, Optional, Union
|
from typing import Iterable, Optional, Union
|
||||||
|
|
||||||
from suma_browser.app.filters.qs_base import KEEP, build_qs
|
from shared.browser.app.filters.qs_base import KEEP, build_qs
|
||||||
from suma_browser.app.filters.query_types import OrderQuery
|
from shared.browser.app.filters.query_types import OrderQuery
|
||||||
|
|
||||||
|
|
||||||
def decode() -> OrderQuery:
|
def decode() -> OrderQuery:
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ from sqlalchemy import select, func, or_, cast, String, exists
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
||||||
from models.market import Product
|
from shared.models.market import Product
|
||||||
from models.order import Order, OrderItem
|
from shared.models.order import Order, OrderItem
|
||||||
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||||
from suma_browser.app.bp.cart.services import check_sumup_status
|
from bp.cart.services import check_sumup_status
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
|
|
||||||
from .filters.qs import makeqs_factory, decode
|
from .filters.qs import makeqs_factory, decode
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ from quart import request
|
|||||||
|
|
||||||
from typing import Iterable, Optional, Union
|
from typing import Iterable, Optional, Union
|
||||||
|
|
||||||
from suma_browser.app.filters.qs_base import KEEP, build_qs
|
from shared.browser.app.filters.qs_base import KEEP, build_qs
|
||||||
from suma_browser.app.filters.query_types import OrderQuery
|
from shared.browser.app.filters.query_types import OrderQuery
|
||||||
|
|
||||||
|
|
||||||
def decode() -> OrderQuery:
|
def decode() -> OrderQuery:
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ from sqlalchemy import select, func, or_, cast, String, exists
|
|||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
|
||||||
from models.market import Product
|
from shared.models.market import Product
|
||||||
from models.order import Order, OrderItem
|
from shared.models.order import Order, OrderItem
|
||||||
from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||||
from config import config
|
from shared.config import config
|
||||||
|
|
||||||
from shared.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
|
||||||
from suma_browser.app.bp.cart.services import check_sumup_status
|
from bp.cart.services import check_sumup_status
|
||||||
from suma_browser.app.utils.htmx import is_htmx_request
|
from shared.browser.app.utils.htmx import is_htmx_request
|
||||||
from suma_browser.app.bp import register_order
|
from bp import register_order
|
||||||
|
|
||||||
from .filters.qs import makeqs_factory, decode
|
from .filters.qs import makeqs_factory, decode
|
||||||
|
|
||||||
@@ -25,6 +25,18 @@ def register(url_prefix: str) -> Blueprint:
|
|||||||
)
|
)
|
||||||
ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference
|
ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference
|
||||||
|
|
||||||
|
oob = {
|
||||||
|
"extends": "_types/root/_index.html",
|
||||||
|
"child_id": "auth-header-child",
|
||||||
|
"header": "_types/auth/header/_header.html",
|
||||||
|
"nav": "_types/auth/_nav.html",
|
||||||
|
"main": "_types/auth/_main_panel.html",
|
||||||
|
}
|
||||||
|
|
||||||
|
@bp.context_processor
|
||||||
|
def inject_oob():
|
||||||
|
return {"oob": oob}
|
||||||
|
|
||||||
@bp.before_request
|
@bp.before_request
|
||||||
def route():
|
def route():
|
||||||
# this is the crucial bit for the |qs filter
|
# this is the crucial bit for the |qs filter
|
||||||
|
|||||||
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-'
|
||||||
|
|
||||||
2
models/__init__.py
Normal file
2
models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
from .order import Order, OrderItem
|
||||||
|
from .page_config import PageConfig
|
||||||
1
models/order.py
Normal file
1
models/order.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.order import Order, OrderItem # noqa: F401
|
||||||
1
models/page_config.py
Normal file
1
models/page_config.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from shared.models.page_config import PageConfig # noqa: F401
|
||||||
@@ -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)
|
||||||
|
|||||||
28
services/__init__.py
Normal file
28
services/__init__.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""Cart app service registration."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def register_domain_services() -> None:
|
||||||
|
"""Register services for the cart app.
|
||||||
|
|
||||||
|
Cart owns: Order, OrderItem.
|
||||||
|
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.cart = SqlCartService()
|
||||||
|
if not services.has("blog"):
|
||||||
|
services.blog = SqlBlogService()
|
||||||
|
if not services.has("calendar"):
|
||||||
|
services.calendar = SqlCalendarService()
|
||||||
|
if not services.has("market"):
|
||||||
|
services.market = SqlMarketService()
|
||||||
|
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
12
templates/_types/auth/header/_header.html
Normal file
12
templates/_types/auth/header/_header.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% import 'macros/links.html' as links %}
|
||||||
|
{% macro header_row(oob=False) %}
|
||||||
|
{% call links.menu_row(id='auth-row', oob=oob) %}
|
||||||
|
{% call links.link(account_url('/'), hx_select_search ) %}
|
||||||
|
<i class="fa-solid fa-user"></i>
|
||||||
|
<div>account</div>
|
||||||
|
{% endcall %}
|
||||||
|
{% call links.desktop_nav() %}
|
||||||
|
{% include "_types/auth/_nav.html" %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endmacro %}
|
||||||
18
templates/_types/auth/index.html
Normal file
18
templates/_types/auth/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends oob.extends %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block root_header_child %}
|
||||||
|
{% from '_types/root/_n/macros.html' import index_row with context %}
|
||||||
|
{% call index_row(oob.child_id, oob.header) %}
|
||||||
|
{% block auth_header_child %}
|
||||||
|
{% endblock %}
|
||||||
|
{% endcall %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block _main_mobile_menu %}
|
||||||
|
{% include oob.nav %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% include oob.main %}
|
||||||
|
{% endblock %}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% macro show_cart(oob=False) %}
|
{% macro show_cart(oob=False) %}
|
||||||
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
|
<div id="cart" {% if oob %} hx-swap-oob="{{oob}}" {% endif%}>
|
||||||
{# Empty cart #}
|
{# Empty cart #}
|
||||||
{% if not cart and not calendar_cart_entries %}
|
{% if not cart and not calendar_cart_entries and not ticket_cart_entries %}
|
||||||
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
|
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-6 sm:p-8 text-center">
|
||||||
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
|
<div class="inline-flex h-10 w-10 sm:h-12 sm:w-12 items-center justify-center rounded-full bg-stone-100 mb-3">
|
||||||
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
|
<i class="fa fa-shopping-cart text-stone-500 text-sm sm:text-base" aria-hidden="true"></i>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<li class="flex items-start justify-between text-sm">
|
<li class="flex items-start justify-between text-sm">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
{{ entry.name or entry.calendar.name }}
|
{{ entry.name or entry.calendar_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-stone-500">
|
<div class="text-xs text-stone-500">
|
||||||
{{ entry.start_at }}
|
{{ entry.start_at }}
|
||||||
@@ -60,8 +60,106 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if ticket_groups is defined and ticket_groups %}
|
||||||
|
<div class="mt-6 border-t border-stone-200 pt-4">
|
||||||
|
<h2 class="text-base font-semibold mb-2">
|
||||||
|
<i class="fa fa-ticket mr-1" aria-hidden="true"></i>
|
||||||
|
Event tickets
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for tg in ticket_groups %}
|
||||||
|
<article class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
{{ tg.entry_name }}
|
||||||
|
</h3>
|
||||||
|
{% if tg.ticket_type_name %}
|
||||||
|
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||||
|
{{ tg.ticket_type_name }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||||
|
{{ tg.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% if tg.entry_end_at %}
|
||||||
|
– {{ tg.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-left sm:text-right">
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
£{{ "%.2f"|format(tg.price or 0) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||||
|
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||||
|
{% set qty_url = url_for('cart_global.update_ticket_quantity') %}
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="{{ qty_url }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ qty_url }}"
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
|
||||||
|
{% if tg.ticket_type_id %}
|
||||||
|
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="count" value="{{ [tg.quantity - 1, 0] | max }}">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
|
||||||
|
{{ tg.quantity }}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<form
|
||||||
|
action="{{ qty_url }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ qty_url }}"
|
||||||
|
hx-swap="none"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="entry_id" value="{{ tg.entry_id }}">
|
||||||
|
{% if tg.ticket_type_id %}
|
||||||
|
<input type="hidden" name="ticket_type_id" value="{{ tg.ticket_type_id }}">
|
||||||
|
{% endif %}
|
||||||
|
<input type="hidden" name="count" value="{{ tg.quantity + 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>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between sm:justify-end gap-3">
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
Line total:
|
||||||
|
£{{ "%.2f"|format(tg.line_total) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
{{summary(cart, total, calendar_total, calendar_cart_entries,)}}
|
{{summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries)}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +168,7 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro summary(cart, total, calendar_total, calendar_cart_entries, oob=False) %}
|
{% macro summary(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries, oob=False) %}
|
||||||
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
|
<aside id="cart-summary" class="lg:pl-2" {% if oob %} hx-swap-oob="{{oob}}" {% endif %}>
|
||||||
<div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">
|
<div class="rounded-2xl bg-white shadow-sm border border-stone-200 p-4 sm:p-5">
|
||||||
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
|
<h2 class="text-sm sm:text-base font-semibold text-stone-900 mb-3 sm:mb-4">
|
||||||
@@ -81,13 +179,15 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<dt class="text-stone-600">Items</dt>
|
<dt class="text-stone-600">Items</dt>
|
||||||
<dd class="text-stone-900">
|
<dd class="text-stone-900">
|
||||||
{{ cart | sum(attribute="quantity") }}
|
{% set product_qty = cart | sum(attribute="quantity") %}
|
||||||
|
{% set ticket_qty = ticket_cart_entries | length if ticket_cart_entries else 0 %}
|
||||||
|
{{ product_qty + ticket_qty }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<dt class="text-stone-600">Subtotal</dt>
|
<dt class="text-stone-600">Subtotal</dt>
|
||||||
<dd class="text-stone-900">
|
<dd class="text-stone-900">
|
||||||
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries ) }}
|
{{ cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) }}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -117,23 +217,13 @@
|
|||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set href=login_url(request.url) %}
|
{% set href=login_url(request.url) %}
|
||||||
<div
|
<div class="w-full flex">
|
||||||
class="w-full flex"
|
|
||||||
>
|
|
||||||
<a
|
<a
|
||||||
href="{{ href }}"
|
href="{{ href }}"
|
||||||
hx-get="{{ href }}"
|
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black hover:bg-stone-300 transition"
|
||||||
hx-target="#main-panel"
|
|
||||||
hx-select ="{{hx_select_search}}"
|
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-push-url="true"
|
|
||||||
aria-selected="{{ 'true' if local_href == request.path else 'false' }}"
|
|
||||||
class="w-full cursor-pointer flex flex-row items-center justify-center p-3 gap-2 rounded bg-stone-200 text-black {{select_colours}}"
|
|
||||||
data-close-details
|
|
||||||
>
|
>
|
||||||
<i class="fa-solid fa-key"></i>
|
<i class="fa-solid fa-key"></i>
|
||||||
<span>sign in or register to checkout</span>
|
<span>sign in or register to checkout</span>
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,10 +244,11 @@
|
|||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries) %}
|
{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries, ticket_total, ticket_cart_entries) %}
|
||||||
{% set product_total = total(cart) or 0 %}
|
{% set product_total = total(cart) or 0 %}
|
||||||
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
|
{% set cal_total = calendar_total(calendar_cart_entries) or 0 %}
|
||||||
{% set grand = product_total + cal_total %}
|
{% set tk_total = ticket_total(ticket_cart_entries) or 0 %}
|
||||||
|
{% set grand = product_total + cal_total + tk_total %}
|
||||||
|
|
||||||
{% if cart and cart[0].product.regular_price_currency %}
|
{% if cart and cart[0].product.regular_price_currency %}
|
||||||
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
|
{% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
{% macro mini(oob=False) %}
|
{% macro mini(oob=False, count=None) %}
|
||||||
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} >
|
<div id="cart-mini" {% if oob %}hx-swap-oob="{{oob}}"{% endif %} >
|
||||||
{# cart_count is set by the context processor in all apps.
|
{# cart_count is set by the context processor in all apps.
|
||||||
Cart app computes it from g.cart + calendar_cart_entries;
|
Cart app computes it from g.cart + calendar_cart_entries;
|
||||||
other apps get it from the cart internal API. #}
|
other apps get it from the cart internal API.
|
||||||
{% if cart_count is defined and cart_count is not none %}
|
count param allows explicit override when macro is imported without context. #}
|
||||||
|
{% if count is not none %}
|
||||||
|
{% set _count = count %}
|
||||||
|
{% elif cart_count is defined and cart_count is not none %}
|
||||||
{% set _count = cart_count %}
|
{% set _count = cart_count %}
|
||||||
{% elif cart is defined and cart is not none %}
|
{% elif cart is defined and cart is not none %}
|
||||||
{% set _count = (cart | sum(attribute="quantity")) + ((calendar_cart_entries | length) if calendar_cart_entries else 0) %}
|
{% set _count = (cart | sum(attribute="quantity")) + ((calendar_cart_entries | length) if calendar_cart_entries else 0) %}
|
||||||
@@ -14,7 +17,7 @@
|
|||||||
{% if _count == 0 %}
|
{% if _count == 0 %}
|
||||||
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||||
<a
|
<a
|
||||||
href="{{ {'clear_filters': True}|qs|host }}"
|
href="{{ blog_url('/') }}"
|
||||||
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% include '_types/order/_items.html' %}
|
{% include '_types/order/_items.html' %}
|
||||||
{% include '_types/order/_calendar_items.html' %}
|
{% include '_types/order/_calendar_items.html' %}
|
||||||
|
{% include '_types/order/_ticket_items.html' %}
|
||||||
|
|
||||||
{% if order.status == 'failed' and order %}
|
{% if order.status == 'failed' and order %}
|
||||||
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
|
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{# Check if there are any items at all across all groups #}
|
{# Check if there are any items at all across all groups #}
|
||||||
{% set ns = namespace(has_items=false) %}
|
{% set ns = namespace(has_items=false) %}
|
||||||
{% for grp in page_groups %}
|
{% for grp in page_groups %}
|
||||||
{% if grp.cart_items or grp.calendar_entries %}
|
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
|
||||||
{% set ns.has_items = true %}
|
{% set ns.has_items = true %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{% for grp in page_groups %}
|
{% for grp in page_groups %}
|
||||||
{% if grp.cart_items or grp.calendar_entries %}
|
{% if grp.cart_items or grp.calendar_entries or grp.get('tickets') %}
|
||||||
|
|
||||||
{% if grp.post %}
|
{% if grp.post %}
|
||||||
{# Page cart card #}
|
{# Market / page cart card #}
|
||||||
<a
|
<a
|
||||||
href="{{ cart_url('/' + grp.post.slug + '/') }}"
|
href="{{ cart_url('/' + grp.post.slug + '/') }}"
|
||||||
class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
|
class="block rounded-2xl border border-stone-200 bg-white shadow-sm hover:shadow-md hover:border-stone-300 transition p-4 sm:p-5"
|
||||||
@@ -53,8 +53,15 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
|
<h3 class="text-base sm:text-lg font-semibold text-stone-900 truncate">
|
||||||
|
{% if grp.market_place %}
|
||||||
|
{{ grp.market_place.name }}
|
||||||
|
{% else %}
|
||||||
{{ grp.post.title }}
|
{{ grp.post.title }}
|
||||||
|
{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
{% if grp.market_place %}
|
||||||
|
<p class="text-xs text-stone-500 truncate">{{ grp.post.title }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
|
<div class="mt-1 flex flex-wrap gap-2 text-xs text-stone-600">
|
||||||
{% if grp.product_count > 0 %}
|
{% if grp.product_count > 0 %}
|
||||||
@@ -69,6 +76,12 @@
|
|||||||
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-stone-100">
|
||||||
|
<i class="fa fa-ticket" aria-hidden="true"></i>
|
||||||
|
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,6 +121,12 @@
|
|||||||
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
{{ grp.calendar_count }} booking{{ 's' if grp.calendar_count != 1 }}
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if grp.ticket_count is defined and grp.ticket_count > 0 %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100">
|
||||||
|
<i class="fa fa-ticket" aria-hidden="true"></i>
|
||||||
|
{{ grp.ticket_count }} ticket{{ 's' if grp.ticket_count != 1 }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<ul class="divide-y divide-stone-100 text-xs sm:text-sm">
|
<ul class="divide-y divide-stone-100 text-xs sm:text-sm">
|
||||||
{% for item in order.items %}
|
{% for item in order.items %}
|
||||||
<li>
|
<li>
|
||||||
<a class="w-full py-2 flex gap-3" href="{{ market_url('/product/' + item.product.slug + '/') }}">
|
<a class="w-full py-2 flex gap-3" href="{{ market_product_url(item.product.slug) }}">
|
||||||
{# Thumbnail #}
|
{# Thumbnail #}
|
||||||
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">
|
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">
|
||||||
{% if item.product and item.product.image %}
|
{% if item.product and item.product.image %}
|
||||||
|
|||||||
49
templates/_types/order/_ticket_items.html
Normal file
49
templates/_types/order/_ticket_items.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
{# --- Tickets in this order --- #}
|
||||||
|
{% if order and order_tickets %}
|
||||||
|
<section class="mt-6 space-y-3">
|
||||||
|
<h2 class="text-base sm:text-lg font-semibold">
|
||||||
|
Event tickets in this order
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
|
||||||
|
{% for tk in order_tickets %}
|
||||||
|
<li class="px-4 py-3 flex items-start justify-between text-sm">
|
||||||
|
<div>
|
||||||
|
<div class="font-medium flex items-center gap-2">
|
||||||
|
{{ tk.entry_name }}
|
||||||
|
{# Small status pill #}
|
||||||
|
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
|
||||||
|
{% if tk.state == 'confirmed' %}
|
||||||
|
bg-emerald-100 text-emerald-800
|
||||||
|
{% elif tk.state == 'reserved' %}
|
||||||
|
bg-amber-100 text-amber-800
|
||||||
|
{% elif tk.state == 'checked_in' %}
|
||||||
|
bg-blue-100 text-blue-800
|
||||||
|
{% else %}
|
||||||
|
bg-stone-100 text-stone-700
|
||||||
|
{% endif %}
|
||||||
|
">
|
||||||
|
{{ tk.state|replace('_', ' ')|capitalize }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if tk.ticket_type_name %}
|
||||||
|
<div class="text-xs text-stone-500">{{ tk.ticket_type_name }}</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="text-xs text-stone-500">
|
||||||
|
{{ tk.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% if tk.entry_end_at %}
|
||||||
|
– {{ tk.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-stone-400 font-mono mt-0.5">
|
||||||
|
{{ tk.code }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 font-medium">
|
||||||
|
£{{ "%.2f"|format(tk.price or 0) }}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
@@ -18,8 +18,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block aside %}
|
{% block aside %}
|
||||||
{% import '_types/browse/desktop/_filter/search.html' as s %}
|
{% from 'macros/search.html' import search_desktop %}
|
||||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block filter %}
|
{% block filter %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
{% import '_types/browse/mobile/_filter/search.html' as s %}
|
{% from 'macros/search.html' import search_mobile %}
|
||||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
{{ search_mobile(current_local_href, search, search_count, hx_select) }}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block aside %}
|
{% block aside %}
|
||||||
{% import '_types/browse/desktop/_filter/search.html' as s %}
|
{% from 'macros/search.html' import search_desktop %}
|
||||||
{{ s.search(current_local_href, search, search_count, hx_select) }}
|
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
250
templates/_types/product/_cart.html
Normal file
250
templates/_types/product/_cart.html
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
{% macro add(slug, cart, oob='false') %}
|
||||||
|
{% set quantity = cart
|
||||||
|
| selectattr('product.slug', 'equalto', slug)
|
||||||
|
| sum(attribute='quantity') %}
|
||||||
|
|
||||||
|
<div id="cart-{{ slug }}" {% if oob=='true' %} hx-swap-oob="{{oob}}" {% endif %}>
|
||||||
|
|
||||||
|
{% if not quantity %}
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
class="rounded flex items-center"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="1"
|
||||||
|
>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="relative inline-flex items-center justify-center text-sm font-medium text-stone-500 hover:bg-emerald-50"
|
||||||
|
>
|
||||||
|
<span class="relative inline-flex items-center justify-center">
|
||||||
|
<i class="fa fa-cart-plus text-4xl" aria-hidden="true"></i>
|
||||||
|
|
||||||
|
<!-- black + overlaid in the center -->
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded flex items-center gap-2">
|
||||||
|
<!-- minus -->
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ quantity - 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>
|
||||||
|
|
||||||
|
<!-- basket with quantity badge -->
|
||||||
|
<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-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"
|
||||||
|
>
|
||||||
|
{{ quantity }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- plus -->
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ quantity + 1 }}"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 text-sm font-medium rounded-full border border-emerald-600 text-emerald-700 hover:bg-emerald-50 text-xl"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{% macro cart_item(oob=False) %}
|
||||||
|
|
||||||
|
{% set p = item.product %}
|
||||||
|
{% set unit_price = p.special_price or p.regular_price %}
|
||||||
|
<article
|
||||||
|
id="cart-item-{{p.slug}}"
|
||||||
|
{% if oob %}
|
||||||
|
hx-swap-oob="{{oob}}"
|
||||||
|
{% endif %}
|
||||||
|
class="flex flex-col sm:flex-row gap-3 sm:gap-4 rounded-2xl bg-white shadow-sm border border-stone-200 p-3 sm:p-4 md:p-5"
|
||||||
|
>
|
||||||
|
<div class="w-full sm:w-32 shrink-0 flex justify-center sm:block">
|
||||||
|
{% if p.image %}
|
||||||
|
<img
|
||||||
|
src="{{ p.image }}"
|
||||||
|
alt="{{ p.title }}"
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-28 object-cover rounded-xl border border-stone-100"
|
||||||
|
loading="lazy"
|
||||||
|
>
|
||||||
|
{% else %}
|
||||||
|
<div
|
||||||
|
class="w-24 h-24 sm:w-32 sm:h-28 rounded-xl border border-dashed border-stone-300 flex items-center justify-center text-xs text-stone-400"
|
||||||
|
>
|
||||||
|
No image
|
||||||
|
</div>'market', 'product', p.slug
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Details #}
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-start justify-between gap-2 sm:gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h2 class="text-sm sm:text-base md:text-lg font-semibold text-stone-900">
|
||||||
|
{% set href=url_for('market.browse.product.product_detail', product_slug=p.slug) %}
|
||||||
|
<a
|
||||||
|
href="{{ href }}"
|
||||||
|
hx_get="{{href}}"
|
||||||
|
hx-target="#main-panel"
|
||||||
|
hx-select ="{{hx_select_search}}"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="true"
|
||||||
|
class="hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
{{ p.title }}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{% if p.brand %}
|
||||||
|
<p class="mt-0.5 text-[0.7rem] sm:text-xs text-stone-500">
|
||||||
|
{{ p.brand }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.is_deleted %}
|
||||||
|
<p class="mt-2 inline-flex items-center gap-1 text-[0.65rem] sm:text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2 py-0.5">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-[0.6rem]" aria-hidden="true"></i>
|
||||||
|
This item is no longer available or price has changed
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Unit price #}
|
||||||
|
<div class="text-left sm:text-right">
|
||||||
|
{% if unit_price %}
|
||||||
|
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
{{ symbol }}{{ "%.2f"|format(unit_price) }}
|
||||||
|
</p>
|
||||||
|
{% if p.special_price and p.special_price != p.regular_price %}
|
||||||
|
<p class="text-xs text-stone-400 line-through">
|
||||||
|
{{ symbol }}{{ "%.2f"|format(p.regular_price) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-stone-500">No price</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-col sm:flex-row sm:items-center justify-between gap-2 sm:gap-4">
|
||||||
|
<div class="flex items-center gap-2 text-xs sm:text-sm text-stone-700">
|
||||||
|
<span class="text-[0.65rem] sm:text-xs uppercase tracking-wide text-stone-500">Quantity</span>
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ item.quantity - 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>
|
||||||
|
<span class="inline-flex items-center justify-center px-2 py-1 rounded-full bg-stone-100 text-[0.7rem] sm:text-xs font-medium">
|
||||||
|
{{ item.quantity }}
|
||||||
|
</span>
|
||||||
|
<form
|
||||||
|
action="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
method="post"
|
||||||
|
hx-post="{{ url_for('market.browse.product.cart', product_slug=p.slug) }}"
|
||||||
|
hx-target="#cart-mini"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input
|
||||||
|
type="hidden"
|
||||||
|
name="count"
|
||||||
|
value="{{ item.quantity + 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>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between sm:justify-end gap-3">
|
||||||
|
{% if unit_price %}
|
||||||
|
{% set line_total = unit_price * item.quantity %}
|
||||||
|
{% set symbol = "£" if p.regular_price_currency == "GBP" else p.regular_price_currency %}
|
||||||
|
<p class="text-sm sm:text-base font-semibold text-stone-900">
|
||||||
|
Line total:
|
||||||
|
{{ symbol }}{{ "%.2f"|format(line_total) }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% endmacro %}
|
||||||
27
templates/fragments/cart_mini.html
Normal file
27
templates/fragments/cart_mini.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<div id="cart-mini">
|
||||||
|
{% if cart_count == 0 %}
|
||||||
|
<div class="h-12 w-12 rounded-full overflow-hidden border border-stone-300 flex-shrink-0">
|
||||||
|
<a
|
||||||
|
href="{{ blog_url('/') }}"
|
||||||
|
class="h-full w-full font-bold text-5xl flex-shrink-0 flex flex-row items-center gap-1"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="{{ blog_url('/static/img/logo.jpg') }}"
|
||||||
|
class="h-full w-full rounded-full object-cover border border-stone-300 flex-shrink-0"
|
||||||
|
>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a
|
||||||
|
href="{{ cart_url('/') }}"
|
||||||
|
class="relative inline-flex items-center justify-center text-stone-700 hover:text-emerald-700"
|
||||||
|
>
|
||||||
|
<i class="fa fa-shopping-cart text-5xl" aria-hidden="true"></i>
|
||||||
|
<span
|
||||||
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 inline-flex items-center justify-center rounded-full bg-emerald-600 text-white text-sm w-5 h-5"
|
||||||
|
>
|
||||||
|
{{ cart_count }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user