From 967303093de07efa0e15d7478817967cef9a09b0 Mon Sep 17 00:00:00 2001 From: giles Date: Mon, 9 Feb 2026 23:17:41 +0000 Subject: [PATCH] feat: initialize cart app with blueprints, templates, and CI Extract cart, order, and orders blueprints with their service layer, templates, Dockerfile (APP_MODULE=app:app, IMAGE=cart), entrypoint, and Gitea CI workflow from the coop monolith. Co-Authored-By: Claude Opus 4.6 --- .gitea/workflows/ci.yml | 63 +++++ .gitignore | 8 + Dockerfile | 33 +++ README.md | 71 ++++++ app.py | 80 ++++++ bp/cart/api.py | 148 +++++++++++ bp/cart/login_helper.py | 57 +++++ bp/cart/routes.py | 236 ++++++++++++++++++ bp/cart/services/__init__.py | 8 + .../services/adopt_session_cart_for_user.py | 46 ++++ bp/cart/services/calendar_cart.py | 46 ++++ bp/cart/services/check_sumup_status.py | 35 +++ bp/cart/services/checkout.py | 182 ++++++++++++++ bp/cart/services/clear_cart_for_order.py | 27 ++ bp/cart/services/get_cart.py | 24 ++ bp/cart/services/identity.py | 4 + bp/cart/services/total.py | 7 + bp/order/filters/qs.py | 74 ++++++ bp/order/routes.py | 137 ++++++++++ bp/orders/filters/qs.py | 77 ++++++ bp/orders/routes.py | 139 +++++++++++ entrypoint.sh | 31 +++ templates/_types/cart/_cart.html | 169 +++++++++++++ templates/_types/cart/_main_panel.html | 4 + templates/_types/cart/_mini.html | 42 ++++ templates/_types/cart/_nav.html | 2 + templates/_types/cart/_oob_elements.html | 28 +++ templates/_types/cart/checkout_error.html | 38 +++ templates/_types/cart/checkout_return.html | 68 +++++ templates/_types/cart/header/_header.html | 12 + templates/_types/cart/index.html | 22 ++ templates/_types/order/_calendar_items.html | 43 ++++ templates/_types/order/_items.html | 51 ++++ templates/_types/order/_main_panel.html | 7 + templates/_types/order/_nav.html | 2 + templates/_types/order/_oob_elements.html | 30 +++ templates/_types/order/_summary.html | 52 ++++ templates/_types/order/header/_header.html | 17 ++ templates/_types/order/index.html | 68 +++++ templates/_types/orders/_main_panel.html | 26 ++ templates/_types/orders/_nav.html | 2 + templates/_types/orders/_oob_elements.html | 38 +++ templates/_types/orders/_rows.html | 164 ++++++++++++ templates/_types/orders/_summary.html | 11 + templates/_types/orders/header/_header.html | 14 ++ templates/_types/orders/index.html | 29 +++ 46 files changed, 2472 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app.py create mode 100644 bp/cart/api.py create mode 100644 bp/cart/login_helper.py create mode 100644 bp/cart/routes.py create mode 100644 bp/cart/services/__init__.py create mode 100644 bp/cart/services/adopt_session_cart_for_user.py create mode 100644 bp/cart/services/calendar_cart.py create mode 100644 bp/cart/services/check_sumup_status.py create mode 100644 bp/cart/services/checkout.py create mode 100644 bp/cart/services/clear_cart_for_order.py create mode 100644 bp/cart/services/get_cart.py create mode 100644 bp/cart/services/identity.py create mode 100644 bp/cart/services/total.py create mode 100644 bp/order/filters/qs.py create mode 100644 bp/order/routes.py create mode 100644 bp/orders/filters/qs.py create mode 100644 bp/orders/routes.py create mode 100644 entrypoint.sh create mode 100644 templates/_types/cart/_cart.html create mode 100644 templates/_types/cart/_main_panel.html create mode 100644 templates/_types/cart/_mini.html create mode 100644 templates/_types/cart/_nav.html create mode 100644 templates/_types/cart/_oob_elements.html create mode 100644 templates/_types/cart/checkout_error.html create mode 100644 templates/_types/cart/checkout_return.html create mode 100644 templates/_types/cart/header/_header.html create mode 100644 templates/_types/cart/index.html create mode 100644 templates/_types/order/_calendar_items.html create mode 100644 templates/_types/order/_items.html create mode 100644 templates/_types/order/_main_panel.html create mode 100644 templates/_types/order/_nav.html create mode 100644 templates/_types/order/_oob_elements.html create mode 100644 templates/_types/order/_summary.html create mode 100644 templates/_types/order/header/_header.html create mode 100644 templates/_types/order/index.html create mode 100644 templates/_types/orders/_main_panel.html create mode 100644 templates/_types/orders/_nav.html create mode 100644 templates/_types/orders/_oob_elements.html create mode 100644 templates/_types/orders/_rows.html create mode 100644 templates/_types/orders/_summary.html create mode 100644 templates/_types/orders/header/_header.html create mode 100644 templates/_types/orders/index.html diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f2f538f --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,63 @@ +name: Build and Deploy + +on: + push: + branches: [main] + +env: + REGISTRY: registry.rose-ash.com:5000 + IMAGE: cart + +jobs: + build-and-deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + apt-get update && apt-get install -y --no-install-recommends openssh-client + + - name: Set up SSH + env: + SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + mkdir -p ~/.ssh + echo "$SSH_KEY" > ~/.ssh/id_rsa + chmod 600 ~/.ssh/id_rsa + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true + + - name: Pull latest code on server + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd /root/cart + git fetch origin main + git reset --hard origin/main + " + + - name: Build and push image + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd /root/cart + docker build --build-arg CACHEBUST=\$(date +%s) -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest -t ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} . + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest + docker push ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }} + " + + - name: Deploy stack + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + run: | + ssh "root@$DEPLOY_HOST" " + cd /root/cart + source .env + docker stack deploy -c docker-compose.yml cart + echo 'Waiting for services to update...' + sleep 10 + docker stack services cart + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..be20105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.env +node_modules/ +*.egg-info/ +dist/ +build/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..706bdc7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1 + +# ---------- Python application ---------- +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + APP_PORT=8000 \ + APP_MODULE=app:app + +WORKDIR /app + +# Install system deps + psql client +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +# ---------- Runtime setup ---------- +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE ${APP_PORT} +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..75dd08e --- /dev/null +++ b/README.md @@ -0,0 +1,71 @@ +# Cart App + +Shopping cart, checkout, and order management service for the Rose Ash cooperative marketplace. + +## 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 + +- **Framework:** Quart (async Flask) +- **Database:** PostgreSQL 16 via SQLAlchemy 2.0 (async) +- **Payments:** SumUp Hosted Checkout +- **Frontend:** HTMX + Jinja2 templates + Tailwind CSS + +## Directory Structure + +``` +app.py # Quart application factory +bp/ + cart/ # Cart blueprint (add, view, checkout, webhooks) + routes.py + api.py # Internal API (server-to-server, CSRF-exempt) + login_helper.py # Cart merge on login + services/ # Business logic layer + order/ # Single order detail blueprint + routes.py + filters/qs.py # Query string helpers + orders/ # Order listing blueprint + routes.py + filters/qs.py +templates/ + _types/cart/ # Cart templates + _types/order/ # Single order templates + _types/orders/ # Order listing templates +entrypoint.sh # Docker entrypoint (migrations + server start) +Dockerfile # Container build +.gitea/workflows/ci.yml # CI/CD pipeline +``` + +## Running + +```bash +# Set environment variables +export APP_MODULE=app:app +export DATABASE_URL_ASYNC=postgresql+asyncpg://user:pass@localhost/coop +export REDIS_URL=redis://localhost:6379/0 +export SECRET_KEY=your-secret-key + +# Run the server +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 +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..797cafd --- /dev/null +++ b/app.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from quart import g + +from shared.factory import create_base_app + +from suma_browser.app.bp import register_cart_bp, register_orders, register_cart_api +from suma_browser.app.bp.cart.services import ( + get_cart, + total, + get_calendar_cart_entries, + calendar_total, +) + + +async def _load_cart(): + """Load the full cart for the cart app (before each request).""" + g.cart = await get_cart(g.s) + + +async def cart_context() -> dict: + """ + Cart app context processor. + + - cart / calendar_cart_entries / total / calendar_total: direct DB + (cart app owns this data) + - cart_count: derived from cart + calendar entries (for _mini.html) + - menu_items: fetched from coop internal API + """ + from shared.context import base_context + from shared.internal_api import get as api_get, dictobj + + ctx = await base_context() + + # Cart app owns cart data — use g.cart from _load_cart + cart = getattr(g, "cart", None) or [] + cal_entries = await get_calendar_cart_entries(g.s) + + ctx["cart"] = cart + ctx["calendar_cart_entries"] = cal_entries + ctx["total"] = total + ctx["calendar_total"] = calendar_total + + # Also set cart_count so _mini.html works the same way + cart_qty = sum(ci.quantity for ci in cart) if cart else 0 + ctx["cart_count"] = cart_qty + len(cal_entries) + ctx["cart_total"] = (total(cart) or 0) + (calendar_total(cal_entries) or 0) + + # 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 + + +def create_app() -> "Quart": + app = create_base_app( + "cart", + context_fn=cart_context, + before_request_fns=[_load_cart], + ) + + # Cart blueprint at root (was /cart in monolith) + app.register_blueprint( + register_cart_bp(url_prefix="/"), + url_prefix="/", + ) + + # Orders blueprint + app.register_blueprint( + register_orders(url_prefix="/orders"), + ) + + # Internal API (server-to-server, CSRF-exempt) + app.register_blueprint(register_cart_api()) + + return app + + +app = create_app() diff --git a/bp/cart/api.py b/bp/cart/api.py new file mode 100644 index 0000000..5fe25dc --- /dev/null +++ b/bp/cart/api.py @@ -0,0 +1,148 @@ +""" +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.calendars import CalendarEntry +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. + """ + ident = current_cart_identity() + + # --- product cart --- + cart_filters = [CartItem.deleted_at.is_(None)] + if ident["user_id"] is not None: + cart_filters.append(CartItem.user_id == ident["user_id"]) + else: + cart_filters.append(CartItem.session_id == ident["session_id"]) + + result = await g.s.execute( + select(CartItem) + .where(*cart_filters) + .options(selectinload(CartItem.product)) + .order_by(CartItem.created_at.desc()) + ) + 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_filters = [ + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "pending", + ] + if ident["user_id"] is not None: + cal_filters.append(CalendarEntry.user_id == ident["user_id"]) + else: + cal_filters.append(CalendarEntry.session_id == ident["session_id"]) + + cal_result = await g.s.execute( + select(CalendarEntry).where(*cal_filters) + ) + 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 diff --git a/bp/cart/login_helper.py b/bp/cart/login_helper.py new file mode 100644 index 0000000..c5576ca --- /dev/null +++ b/bp/cart/login_helper.py @@ -0,0 +1,57 @@ +# 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) diff --git a/bp/cart/routes.py b/bp/cart/routes.py new file mode 100644 index 0000000..489b576 --- /dev/null +++ b/bp/cart/routes.py @@ -0,0 +1,236 @@ +# 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, + 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//") + 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")) + + # 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, + ) + + # 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) + 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, + ) + 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//") + 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//") + 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 diff --git a/bp/cart/services/__init__.py b/bp/cart/services/__init__.py new file mode 100644 index 0000000..52c9d1a --- /dev/null +++ b/bp/cart/services/__init__.py @@ -0,0 +1,8 @@ +from .get_cart import get_cart +from .identity import current_cart_identity +from .total import total +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 +from .check_sumup_status import check_sumup_status + diff --git a/bp/cart/services/adopt_session_cart_for_user.py b/bp/cart/services/adopt_session_cart_for_user.py new file mode 100644 index 0000000..f28c2fd --- /dev/null +++ b/bp/cart/services/adopt_session_cart_for_user.py @@ -0,0 +1,46 @@ +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 diff --git a/bp/cart/services/calendar_cart.py b/bp/cart/services/calendar_cart.py new file mode 100644 index 0000000..1731fb4 --- /dev/null +++ b/bp/cart/services/calendar_cart.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from models.calendars import CalendarEntry +from .identity import current_cart_identity + + +async def get_calendar_cart_entries(session): + """ + Return all *pending* calendar entries for the current cart identity + (user or anonymous session). + """ + ident = current_cart_identity() + + filters = [ + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "pending", + ] + + 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: + """ + Total cost of pending calendar entries. + """ + return sum( + (e.cost or 0) + for e in entries + if e.cost is not None + ) diff --git a/bp/cart/services/check_sumup_status.py b/bp/cart/services/check_sumup_status.py new file mode 100644 index 0000000..afbf2cb --- /dev/null +++ b/bp/cart/services/check_sumup_status.py @@ -0,0 +1,35 @@ +from suma_browser.app.payments.sumup import get_checkout as sumup_get_checkout +from sqlalchemy import update +from models.calendars import CalendarEntry # NEW + + +async def check_sumup_status(session, order): + checkout_data = await sumup_get_checkout(order.sumup_checkout_id) + order.sumup_status = checkout_data.get("status") or order.sumup_status + sumup_status = (order.sumup_status or "").upper() + + if sumup_status == "PAID": + if order.status != "paid": + order.status = "paid" + filters = [ + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "ordered", + CalendarEntry.order_id==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( + update(CalendarEntry) + .where(*filters) + .values(state="provisional") + ) + # also clear cart for this user/session if it wasn't already + elif sumup_status == "FAILED": + order.status = "failed" + else: + order.status = sumup_status.lower() or order.status + + await g.s.flush() diff --git a/bp/cart/services/checkout.py b/bp/cart/services/checkout.py new file mode 100644 index 0000000..3762ce2 --- /dev/null +++ b/bp/cart/services/checkout.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +from typing import Optional +from urllib.parse import urlencode + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from models.market import Product, CartItem +from models.order import Order, OrderItem +from models.calendars import CalendarEntry +from config import config + + +async def find_or_create_cart_item( + session: AsyncSession, + product_id: int, + user_id: Optional[int], + session_id: Optional[str], +) -> Optional[CartItem]: + """ + Find an existing cart item for this product/identity, or create a new one. + Returns None if the product doesn't exist. + Increments quantity if item already exists. + """ + # Make sure product exists + product = await session.scalar( + select(Product).where(Product.id == product_id) + ) + if not product: + return None + + # Look for existing cart item + filters = [ + CartItem.deleted_at.is_(None), + CartItem.product_id == product_id, + ] + if user_id is not None: + filters.append(CartItem.user_id == user_id) + else: + filters.append(CartItem.session_id == session_id) + + existing = await session.scalar(select(CartItem).where(*filters)) + + if existing: + existing.quantity += 1 + return existing + else: + cart_item = CartItem( + user_id=user_id, + session_id=session_id, + product_id=product.id, + quantity=1, + ) + session.add(cart_item) + return cart_item + + +async def create_order_from_cart( + session: AsyncSession, + cart: list[CartItem], + calendar_entries: list[CalendarEntry], + user_id: Optional[int], + session_id: Optional[str], + product_total: float, + calendar_total: float, +) -> Order: + """ + Create an Order and OrderItems from the current cart + calendar entries. + Returns the created Order. + """ + cart_total = product_total + calendar_total + + # Determine currency from first product + first_product = cart[0].product if cart else None + currency = (first_product.regular_price_currency if first_product else None) or "GBP" + + # Create order + order = Order( + user_id=user_id, + session_id=session_id, + status="pending", + currency=currency, + total_amount=cart_total, + ) + session.add(order) + await session.flush() + + # Create order items from cart + for ci in cart: + price = ci.product.special_price or ci.product.regular_price or 0 + oi = OrderItem( + order=order, + product_id=ci.product.id, + product_title=ci.product.title, + quantity=ci.quantity, + unit_price=price, + currency=currency, + ) + session.add(oi) + + # Update calendar entries to reference this order + calendar_filters = [ + CalendarEntry.deleted_at.is_(None), + 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) + + await session.execute( + update(CalendarEntry) + .where(*calendar_filters) + .values( + state="ordered", + order_id=order.id, + ) + ) + + return order + + +def build_sumup_description(cart: list[CartItem], order_id: int) -> str: + """Build a human-readable description for SumUp checkout.""" + titles = [ci.product.title for ci in cart if ci.product and ci.product.title] + item_count = sum(ci.quantity for ci in cart) + + if titles: + if len(titles) <= 3: + summary = ", ".join(titles) + else: + summary = ", ".join(titles[:3]) + f" + {len(titles) - 3} more" + else: + summary = "order items" + + return f"Order {order_id} ({item_count} item{'s' if item_count != 1 else ''}): {summary}" + + +def build_sumup_reference(order_id: int) -> str: + """Build a SumUp reference with configured prefix.""" + sumup_cfg = config().get("sumup", {}) or {} + prefix = sumup_cfg.get("checkout_reference_prefix", "") + return f"{prefix}{order_id}" + + +def build_webhook_url(base_url: str) -> str: + """Add webhook secret token to URL if configured.""" + sumup_cfg = config().get("sumup", {}) or {} + webhook_secret = sumup_cfg.get("webhook_secret") + + if webhook_secret: + sep = "&" if "?" in base_url else "?" + return f"{base_url}{sep}{urlencode({'token': webhook_secret})}" + + return base_url + + +def validate_webhook_secret(token: Optional[str]) -> bool: + """Validate webhook token against configured secret.""" + sumup_cfg = config().get("sumup", {}) or {} + webhook_secret = sumup_cfg.get("webhook_secret") + + if not webhook_secret: + return True # No secret configured, allow all + + return token is not None and token == webhook_secret + + +async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]: + """Fetch an order with items and calendar entries eagerly loaded.""" + result = await session.execute( + select(Order) + .options( + selectinload(Order.items).selectinload(OrderItem.product), + selectinload(Order.calendar_entries), + ) + .where(Order.id == order_id) + ) + return result.scalar_one_or_none() diff --git a/bp/cart/services/clear_cart_for_order.py b/bp/cart/services/clear_cart_for_order.py new file mode 100644 index 0000000..51b7c9f --- /dev/null +++ b/bp/cart/services/clear_cart_for_order.py @@ -0,0 +1,27 @@ +from sqlalchemy import update, func + +from models.market import CartItem +from models.order import Order +# ... + +# helper function near the top of the file (outside register()) +async def clear_cart_for_order(session, order: Order) -> None: + """ + Soft-delete all CartItem rows belonging to this order's user_id/session_id. + Called when an order is marked as paid. + """ + filters = [CartItem.deleted_at.is_(None)] + if order.user_id is not None: + filters.append(CartItem.user_id == order.user_id) + if order.session_id is not None: + filters.append(CartItem.session_id == order.session_id) + + if len(filters) == 1: + # no user_id/session_id on order – nothing to clear + return + + await session.execute( + update(CartItem) + .where(*filters) + .values(deleted_at=func.now()) + ) diff --git a/bp/cart/services/get_cart.py b/bp/cart/services/get_cart.py new file mode 100644 index 0000000..79b83df --- /dev/null +++ b/bp/cart/services/get_cart.py @@ -0,0 +1,24 @@ +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from models.market import CartItem +from .identity import current_cart_identity + +async def get_cart(session): + ident = current_cart_identity() + + filters = [CartItem.deleted_at.is_(None)] + if ident["user_id"] is not None: + filters.append(CartItem.user_id == ident["user_id"]) + else: + filters.append(CartItem.session_id == ident["session_id"]) + + result = await session.execute( + select(CartItem) + .where(*filters) + .order_by(CartItem.created_at.desc()) + .options( + selectinload(CartItem.product), # <-- important bit + ) + ) + return result.scalars().all() diff --git a/bp/cart/services/identity.py b/bp/cart/services/identity.py new file mode 100644 index 0000000..a1f8594 --- /dev/null +++ b/bp/cart/services/identity.py @@ -0,0 +1,4 @@ +# Re-export from canonical shared location +from shared.cart_identity import CartIdentity, current_cart_identity + +__all__ = ["CartIdentity", "current_cart_identity"] diff --git a/bp/cart/services/total.py b/bp/cart/services/total.py new file mode 100644 index 0000000..3f9b484 --- /dev/null +++ b/bp/cart/services/total.py @@ -0,0 +1,7 @@ +def total(cart): + return sum( + (item.product.special_price or item.product.regular_price) * item.quantity + for item in cart + if (item.product.special_price or item.product.regular_price) is not None + ) + \ No newline at end of file diff --git a/bp/order/filters/qs.py b/bp/order/filters/qs.py new file mode 100644 index 0000000..7c00336 --- /dev/null +++ b/bp/order/filters/qs.py @@ -0,0 +1,74 @@ +# suma_browser/app/bp/order/filters/qs.py +from quart import request + +from typing import Iterable, Optional, Union + +from suma_browser.app.filters.qs_base import KEEP, build_qs +from suma_browser.app.filters.query_types import OrderQuery + + +def decode() -> OrderQuery: + """ + Decode current query string into an OrderQuery(page, search). + """ + try: + page = int(request.args.get("page", 1) or 1) + except ValueError: + page = 1 + + search = request.args.get("search") or None + return OrderQuery(page, search) + + +def makeqs_factory(): + """ + Build a makeqs(...) that starts from the current filters + page. + + Behaviour: + - If filters change and you don't explicitly pass page, + the page is reset to 1 (same pattern as browse/blog). + - You can clear search with search=None. + """ + q = decode() + base_search = q.search or None + base_page = int(q.page or 1) + + def makeqs( + *, + clear_filters: bool = False, + search: Union[str, None, object] = KEEP, + page: Union[int, None, object] = None, + extra: Optional[Iterable[tuple]] = None, + leading_q: bool = True, + ) -> str: + filters_changed = False + + # --- search logic --- + if search is KEEP and not clear_filters: + final_search = base_search + else: + filters_changed = True + final_search = (search or None) + + # --- page logic --- + if page is None: + final_page = 1 if filters_changed else base_page + else: + final_page = page + + # --- build params --- + params: list[tuple[str, str]] = [] + + if final_search: + params.append(("search", final_search)) + if final_page is not None: + params.append(("page", str(final_page))) + + if extra: + for k, v in extra: + if v is not None: + params.append((k, str(v))) + + return build_qs(params, leading_q=leading_q) + + return makeqs diff --git a/bp/order/routes.py b/bp/order/routes.py new file mode 100644 index 0000000..977b99e --- /dev/null +++ b/bp/order/routes.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from quart import Blueprint, g, render_template, redirect, url_for, make_response +from sqlalchemy import select, func, or_, cast, String, exists +from sqlalchemy.orm import selectinload + + +from models.market import Product +from models.order import Order, OrderItem +from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout +from config import config + +from shared.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 suma_browser.app.utils.htmx import is_htmx_request + +from .filters.qs import makeqs_factory, decode + + +def register() -> Blueprint: + bp = Blueprint("order", __name__, url_prefix='/') + + ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference + + @bp.before_request + def route(): + # this is the crucial bit for the |qs filter + g.makeqs_factory = makeqs_factory + + @bp.get("/") + async def order_detail(order_id: int): + """ + Show a single order + items. + """ + result = await g.s.execute( + select(Order) + .options( + selectinload(Order.items).selectinload(OrderItem.product) + ) + .where(Order.id == order_id) + ) + order = result.scalar_one_or_none() + if not order: + return await make_response("Order not found", 404) + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template("_types/order/index.html", order=order,) + else: + # HTMX navigation (page 1): main panel + OOB elements + html = await render_template("_types/order/_oob_elements.html", order=order,) + + return await make_response(html) + + @bp.get("/pay/") + async def order_pay(order_id: int): + """ + Re-open the SumUp payment page for this order. + If already paid, just go back to the order detail. + If not, (re)create a SumUp checkout and redirect. + """ + result = await g.s.execute(select(Order).where(Order.id == order_id)) + order = result.scalar_one_or_none() + if not order: + return await make_response("Order not found", 404) + + if order.status == "paid": + # Already paid; nothing to pay + return redirect(url_for("orders.order.order_detail", order_id=order.id)) + + # Prefer to reuse existing hosted URL if we have one + if order.sumup_hosted_url: + return redirect(order.sumup_hosted_url) + + # Otherwise, create a fresh checkout for this order + redirect_url = url_for("cart.checkout_return", order_id=order.id, _external=True) + + sumup_cfg = config().get("sumup", {}) or {} + webhook_secret = sumup_cfg.get("webhook_secret") + + webhook_url = url_for("cart.checkout_webhook", order_id=order.id, _external=True) + if webhook_secret: + from urllib.parse import urlencode + + sep = "&" if "?" in webhook_url else "?" + webhook_url = f"{webhook_url}{sep}{urlencode({'token': webhook_secret})}" + + checkout_data = await sumup_create_checkout( + order, + redirect_url=redirect_url, + webhook_url=webhook_url, + ) + + order.sumup_checkout_id = checkout_data.get("id") + order.sumup_status = checkout_data.get("status") + + 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 when trying to reopen payment.", + ) + return await make_response(html, 500) + + return redirect(hosted_url) + + @bp.post("/recheck/") + async def order_recheck(order_id: int): + """ + Manually re-check this order's status with SumUp. + Useful if the webhook hasn't fired or the user didn't return correctly. + """ + result = await g.s.execute(select(Order).where(Order.id == order_id)) + order = result.scalar_one_or_none() + if not order: + return await make_response("Order not found", 404) + + # If we don't have a checkout ID yet, nothing to query + if not order.sumup_checkout_id: + return redirect(url_for("orders.order.order_detail", order_id=order.id)) + + try: + await check_sumup_status(g.s, order) + except Exception: + # In a real app, log the error; here we just fall back to previous status + pass + + return redirect(url_for("orders.order.order_detail", order_id=order.id)) + + + return bp + diff --git a/bp/orders/filters/qs.py b/bp/orders/filters/qs.py new file mode 100644 index 0000000..8f10436 --- /dev/null +++ b/bp/orders/filters/qs.py @@ -0,0 +1,77 @@ +# suma_browser/app/bp/orders/filters/qs.py +from quart import request + +from typing import Iterable, Optional, Union + +from suma_browser.app.filters.qs_base import KEEP, build_qs +from suma_browser.app.filters.query_types import OrderQuery + + +def decode() -> OrderQuery: + """ + Decode current query string into an OrderQuery(page, search). + """ + try: + page = int(request.args.get("page", 1) or 1) + except ValueError: + page = 1 + + search = request.args.get("search") or None + return OrderQuery(page, search) + + +def makeqs_factory(): + """ + Build a makeqs(...) that starts from the current filters + page. + + Behaviour: + - If filters change and you don't explicitly pass page, + the page is reset to 1 (same pattern as browse/blog). + - You can clear search with search=None. + """ + q = decode() + base_search = q.search or None + base_page = int(q.page or 1) + + def makeqs( + *, + clear_filters: bool = False, + search: Union[str, None, object] = KEEP, + page: Union[int, None, object] = None, + extra: Optional[Iterable[tuple]] = None, + leading_q: bool = True, + ) -> str: + filters_changed = False + + # --- search logic --- + if search is KEEP and not clear_filters: + final_search = base_search + else: + filters_changed = True + if search is KEEP: + final_search = None + else: + final_search = (search or None) + + # --- page logic --- + if page is None: + final_page = 1 if filters_changed else base_page + else: + final_page = page + + # --- build params --- + params: list[tuple[str, str]] = [] + + if final_search: + params.append(("search", final_search)) + if final_page is not None: + params.append(("page", str(final_page))) + + if extra: + for k, v in extra: + if v is not None: + params.append((k, str(v))) + + return build_qs(params, leading_q=leading_q) + + return makeqs diff --git a/bp/orders/routes.py b/bp/orders/routes.py new file mode 100644 index 0000000..df88eb6 --- /dev/null +++ b/bp/orders/routes.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from quart import Blueprint, g, render_template, redirect, url_for, make_response +from sqlalchemy import select, func, or_, cast, String, exists +from sqlalchemy.orm import selectinload + + +from models.market import Product +from models.order import Order, OrderItem +from suma_browser.app.payments.sumup import create_checkout as sumup_create_checkout +from config import config + +from shared.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 suma_browser.app.utils.htmx import is_htmx_request +from suma_browser.app.bp import register_order + +from .filters.qs import makeqs_factory, decode + + +def register(url_prefix: str) -> Blueprint: + bp = Blueprint("orders", __name__, url_prefix=url_prefix) + bp.register_blueprint( + register_order(), + ) + ORDERS_PER_PAGE = 10 # keep in sync with browse page size / your preference + + @bp.before_request + def route(): + # this is the crucial bit for the |qs filter + g.makeqs_factory = makeqs_factory + + @bp.get("/") + async def list_orders(): + + # --- decode filters from query string (page + search) --- + q = decode() + page, search = q.page, q.search + + # sanity clamp page + if page < 1: + page = 1 + + # --- build where clause for search --- + where_clause = None + if search: + term = f"%{search.strip()}%" + conditions = [ + Order.status.ilike(term), + Order.currency.ilike(term), + Order.sumup_checkout_id.ilike(term), + Order.sumup_status.ilike(term), + Order.description.ilike(term), + ] + + conditions.append( + exists( + select(1) + .select_from(OrderItem) + .join(Product, Product.id == OrderItem.product_id) + .where( + OrderItem.order_id == Order.id, + or_( + OrderItem.product_title.ilike(term), + Product.title.ilike(term), + Product.description_short.ilike(term), + Product.description_html.ilike(term), + Product.slug.ilike(term), + Product.brand.ilike(term), + ), + ) + ) + ) + + # allow exact ID match or partial (string) match + try: + search_id = int(search) + except (TypeError, ValueError): + search_id = None + + if search_id is not None: + conditions.append(Order.id == search_id) + else: + conditions.append(cast(Order.id, String).ilike(term)) + + where_clause = or_(*conditions) + + # --- total count & total pages (respecting search) --- + count_stmt = select(func.count()).select_from(Order) + if where_clause is not None: + count_stmt = count_stmt.where(where_clause) + + total_count_result = await g.s.execute(count_stmt) + total_count = total_count_result.scalar_one() or 0 + total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE) + + # clamp page if beyond range (just in case) + if page > total_pages: + page = total_pages + + # --- paginated orders (respecting search) --- + offset = (page - 1) * ORDERS_PER_PAGE + stmt = ( + select(Order) + .order_by(Order.created_at.desc()) + .offset(offset) + .limit(ORDERS_PER_PAGE) + ) + if where_clause is not None: + stmt = stmt.where(where_clause) + + result = await g.s.execute(stmt) + orders = result.scalars().all() + + context = { + "orders": orders, + "page": page, + "total_pages": total_pages, + "search": search, + "search_count": total_count, # For search display + } + + # Determine which template to use based on request type and pagination + if not is_htmx_request(): + # Normal browser request: full page with layout + html = await render_template("_types/orders/index.html", **context) + elif page > 1: + # HTMX pagination: just table rows + sentinel + html = await render_template("_types/orders/_rows.html", **context) + else: + # HTMX navigation (page 1): main panel + OOB elements + html = await render_template("_types/orders/_oob_elements.html", **context) + + resp = await make_response(html) + resp.headers["Hx-Push-Url"] = _current_url_without_page() + return _vary(resp) + + return bp + diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..dd1352c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Optional: wait for Postgres to be reachable +if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then + echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..." + for i in {1..60}; do + (echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true + sleep 1 + done +fi + +# Run DB migrations (uses alembic.ini/env.py to resolve the DB URL) +echo "Running Alembic migrations..." +alembic upgrade head + +# Clear Redis page cache on deploy +if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then + echo "Flushing Redis cache..." + python3 -c " +import redis, os +r = redis.from_url(os.environ['REDIS_URL']) +r.flushall() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +# APP_MODULE can be overridden per-service (e.g. apps.market.app:app) +echo "Starting Hypercorn (${APP_MODULE:-suma_browser.app.app:app})..." +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-suma_browser.app.app:app}" --bind 0.0.0.0:${PORT:-8000} diff --git a/templates/_types/cart/_cart.html b/templates/_types/cart/_cart.html new file mode 100644 index 0000000..ec32510 --- /dev/null +++ b/templates/_types/cart/_cart.html @@ -0,0 +1,169 @@ +{% macro show_cart(oob=False) %} +
+ {# Empty cart #} + {% if not cart and not calendar_cart_entries %} +
+
+ +
+

+ Your cart is empty +

+ {# +

+ Add some items from the shop to see them here. +

+ #} +
+ + {% else %} + +
+ {# Items list #} +
+ {% for item in cart %} + {% from '_types/product/_cart.html' import cart_item with context %} + {{ cart_item()}} + {% endfor %} + {% if calendar_cart_entries %} +
+

+ Calendar bookings +

+ +
    + {% for entry in calendar_cart_entries %} +
  • +
    +
    + {{ entry.name or entry.calendar.name }} +
    +
    + {{ entry.start_at }} + {% if entry.end_at %} + – {{ entry.end_at }} + {% endif %} +
    +
    +
    + £{{ "%.2f"|format(entry.cost or 0) }} +
    +
  • + {% endfor %} +
+
+ {% endif %} +
+ {{summary(cart, total, calendar_total, calendar_cart_entries,)}} + +
+ + {% endif %} +
+{% endmacro %} + + +{% macro summary(cart, total, calendar_total, calendar_cart_entries, oob=False) %} + +{% endmacro %} + +{% macro cart_total(cart, total) %} + {% set cart_total = total(cart) %} + {% if cart_total %} + {% set symbol = "£" if cart[0].product.regular_price_currency == "GBP" else cart[0].product.regular_price_currency %} + {{ symbol }}{{ "%.2f"|format(cart_total) }} + {% else %} + – + {% endif %} +{% endmacro %} + + +{% macro cart_grand_total(cart, total, calendar_total, calendar_cart_entries) %} + {% set product_total = total(cart) or 0 %} + {% set cal_total = calendar_total(calendar_cart_entries) or 0 %} + {% set grand = product_total + cal_total %} + + {% 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 %} + {% else %} + {% set symbol = "£" %} + {% endif %} + + {{ symbol }}{{ "%.2f"|format(grand) }} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/cart/_main_panel.html b/templates/_types/cart/_main_panel.html new file mode 100644 index 0000000..3872387 --- /dev/null +++ b/templates/_types/cart/_main_panel.html @@ -0,0 +1,4 @@ +
+ {% from '_types/cart/_cart.html' import show_cart with context %} + {{ show_cart() }} +
\ No newline at end of file diff --git a/templates/_types/cart/_mini.html b/templates/_types/cart/_mini.html new file mode 100644 index 0000000..aa64d36 --- /dev/null +++ b/templates/_types/cart/_mini.html @@ -0,0 +1,42 @@ +{% macro mini(oob=False) %} +
+ {# cart_count is set by the context processor in all apps. + Cart app computes it from g.cart + calendar_cart_entries; + other apps get it from the cart internal API. #} + {% if cart_count is defined and cart_count is not none %} + {% set _count = cart_count %} + {% 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) %} + {% else %} + {% set _count = 0 %} + {% endif %} + + {% if _count == 0 %} +
+ + + +
+ {% else %} + + + + + + {{ _count }} + + + {% endif %} +
+{% endmacro %} diff --git a/templates/_types/cart/_nav.html b/templates/_types/cart/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/cart/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/cart/_oob_elements.html b/templates/_types/cart/_oob_elements.html new file mode 100644 index 0000000..6e54a8b --- /dev/null +++ b/templates/_types/cart/_oob_elements.html @@ -0,0 +1,28 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{# Header with app title - includes cart-mini, navigation, and market-specific header #} + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('root-header-child', 'cart-header-child', '_types/cart/header/_header.html')}} + + {% from '_types/root/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/cart/_nav.html' %} +{% endblock %} + + +{% block content %} + {% include "_types/cart/_main_panel.html" %} +{% endblock %} + + diff --git a/templates/_types/cart/checkout_error.html b/templates/_types/cart/checkout_error.html new file mode 100644 index 0000000..e642e8a --- /dev/null +++ b/templates/_types/cart/checkout_error.html @@ -0,0 +1,38 @@ +{% extends '_types/root/index.html' %} + +{% block filter %} +
+

+ Checkout error +

+

+ We tried to start your payment with SumUp but hit a problem. +

+
+{% endblock %} + +{% block content %} +
+
+

Something went wrong.

+

+ {{ error or "Unexpected error while creating the hosted checkout session." }} +

+ {% if order %} +

+ Order ID: #{{ order.id }} +

+ {% endif %} +
+ + +
+{% endblock %} diff --git a/templates/_types/cart/checkout_return.html b/templates/_types/cart/checkout_return.html new file mode 100644 index 0000000..326a469 --- /dev/null +++ b/templates/_types/cart/checkout_return.html @@ -0,0 +1,68 @@ +{% extends '_types/root/index.html' %} + +{% block filter %} +
+
+

+ {% if order.status == 'paid' %} + Payment received + {% elif order.status == 'failed' %} + Payment failed + {% elif order.status == 'missing' %} + Order not found + {% else %} + Payment status: {{ order.status|default('pending')|capitalize }} + {% endif %} +

+

+ {% if order.status == 'paid' %} + Thanks for your order. + {% elif order.status == 'failed' %} + Something went wrong while processing your payment. You can try again below. + {% elif order.status == 'missing' %} + We couldn't find that order – it may have expired or never been created. + {% else %} + We’re still waiting for a final confirmation from SumUp. + {% endif %} +

+
+ +
+{% endblock %} + +{% block aside %} + {# no aside content for now #} +{% endblock %} + +{% block content %} +
+ {% if order %} +
+ {% include '_types/order/_summary.html' %} +
+ {% else %} +
+ We couldn’t find that order. If you reached this page from an old link, please start a new order. +
+ {% endif %} + {% include '_types/order/_items.html' %} + {% include '_types/order/_calendar_items.html' %} + + + {% if order.status == 'failed' and order %} +
+

Your payment was not completed.

+

+ You can go back to your cart and try checkout again. If the problem persists, + please contact us and mention order #{{ order.id }}. +

+
+ {% elif order.status == 'paid' %} +
+

All done!

+

We’ll start processing your order shortly.

+
+ {% endif %} + +
+{% endblock %} diff --git a/templates/_types/cart/header/_header.html b/templates/_types/cart/header/_header.html new file mode 100644 index 0000000..b27181d --- /dev/null +++ b/templates/_types/cart/header/_header.html @@ -0,0 +1,12 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='cart-row', oob=oob) %} + {% call links.link(url_for('cart.view_cart'), hx_select_search ) %} + +

cart

+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/cart/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/cart/index.html b/templates/_types/cart/index.html new file mode 100644 index 0000000..78570d9 --- /dev/null +++ b/templates/_types/cart/index.html @@ -0,0 +1,22 @@ +{% extends '_types/root/_index.html' %} + +{% block root_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('cart-header-child', '_types/cart/header/_header.html') %} + {% block cart_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + + +{% block _main_mobile_menu %} +{% include '_types/cart/_nav.html' %} +{% endblock %} + + +{% block aside %} +{% endblock %} + +{% block content %} + {% include '_types/cart/_main_panel.html' %} +{% endblock %} diff --git a/templates/_types/order/_calendar_items.html b/templates/_types/order/_calendar_items.html new file mode 100644 index 0000000..019f048 --- /dev/null +++ b/templates/_types/order/_calendar_items.html @@ -0,0 +1,43 @@ +{# --- NEW: calendar bookings in this order --- #} + {% if order and calendar_entries %} +
+

+ Calendar bookings in this order +

+ +
    + {% for entry in calendar_entries %} +
  • +
    +
    + {{ entry.name }} + {# Small status pill #} + + {{ entry.state|capitalize }} + +
    +
    + {{ entry.start_at.strftime('%-d %b %Y, %H:%M') }} + {% if entry.end_at %} + – {{ entry.end_at.strftime('%-d %b %Y, %H:%M') }} + {% endif %} +
    +
    +
    + £{{ "%.2f"|format(entry.cost or 0) }} +
    +
  • + {% endfor %} +
+
+ {% endif %} \ No newline at end of file diff --git a/templates/_types/order/_items.html b/templates/_types/order/_items.html new file mode 100644 index 0000000..fdf0a0c --- /dev/null +++ b/templates/_types/order/_items.html @@ -0,0 +1,51 @@ +{# Items list #} +{% if order and order.items %} + +{% endif %} \ No newline at end of file diff --git a/templates/_types/order/_main_panel.html b/templates/_types/order/_main_panel.html new file mode 100644 index 0000000..679b846 --- /dev/null +++ b/templates/_types/order/_main_panel.html @@ -0,0 +1,7 @@ +
+ {# Order summary card #} + {% include '_types/order/_summary.html' %} + {% include '_types/order/_items.html' %} + {% include '_types/order/_calendar_items.html' %} + +
\ No newline at end of file diff --git a/templates/_types/order/_nav.html b/templates/_types/order/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/order/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/order/_oob_elements.html b/templates/_types/order/_oob_elements.html new file mode 100644 index 0000000..31d1e17 --- /dev/null +++ b/templates/_types/order/_oob_elements.html @@ -0,0 +1,30 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} +{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{# Header with app title - includes cart-mini, navigation, and market-specific header #} + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('orders-header-child', 'order-header-child', '_types/order/header/_header.html')}} + + {% from '_types/order/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + + +{% block mobile_menu %} + {% include '_types/order/_nav.html' %} +{% endblock %} + + +{% block content %} + {% include "_types/order/_main_panel.html" %} +{% endblock %} + + diff --git a/templates/_types/order/_summary.html b/templates/_types/order/_summary.html new file mode 100644 index 0000000..ffe560b --- /dev/null +++ b/templates/_types/order/_summary.html @@ -0,0 +1,52 @@ +
+

+ Order ID: + #{{ order.id }} +

+ +

+ Created: + {% if order.created_at %} + {{ order.created_at.strftime('%-d %b %Y, %H:%M') }} + {% else %} + — + {% endif %} +

+ +

+ Description: + {{ order.description or '–' }} +

+ +

+ Status: + + {{ order.status or 'pending' }} + +

+ +

+ Currency: + {{ order.currency or 'GBP' }} +

+ +

+ Total: + {% if order.total_amount %} + {{ order.currency or 'GBP' }} {{ '%.2f'|format(order.total_amount) }} + {% else %} + – + {% endif %} +

+ +
+ + \ No newline at end of file diff --git a/templates/_types/order/header/_header.html b/templates/_types/order/header/_header.html new file mode 100644 index 0000000..4d7f74b --- /dev/null +++ b/templates/_types/order/header/_header.html @@ -0,0 +1,17 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='order-row', oob=oob) %} + {% call links.link(url_for('orders.order.order_detail', order_id=order.id), hx_select_search ) %} + +
+ Order +
+
+ {{ order.id }} +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/order/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/order/index.html b/templates/_types/order/index.html new file mode 100644 index 0000000..c3d301e --- /dev/null +++ b/templates/_types/order/index.html @@ -0,0 +1,68 @@ +{% extends '_types/orders/index.html' %} + + +{% block orders_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('order-header-child', '_types/order/header/_header.html') %} + {% block order_header_child %} + {% endblock %} + {% endcall %} +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/order/_nav.html' %} +{% endblock %} + + + +{% block filter %} +
+
+

+ Placed {% if order.created_at %}{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}{% else %}—{% endif %} · Status: {{ order.status or 'pending' }} +

+
+
+ + + All orders + + + {# Re-check status button #} +
+ + +
+ + {% if order.status != 'paid' %} + + + Open payment page + + {% endif %} +
+
+{% endblock %} + +{% block content %} + {% include '_types/order/_main_panel.html' %} +{% endblock %} + +{% block aside %} +{% endblock %} diff --git a/templates/_types/orders/_main_panel.html b/templates/_types/orders/_main_panel.html new file mode 100644 index 0000000..01ad410 --- /dev/null +++ b/templates/_types/orders/_main_panel.html @@ -0,0 +1,26 @@ +
+ {% if not orders %} +
+ No orders yet. +
+ {% else %} +
+ + + + + + + + + + + + + {# rows + infinite-scroll sentinel #} + {% include "_types/orders/_rows.html" %} + +
OrderCreatedDescriptionTotalStatus
+
+ {% endif %} +
diff --git a/templates/_types/orders/_nav.html b/templates/_types/orders/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/templates/_types/orders/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/templates/_types/orders/_oob_elements.html b/templates/_types/orders/_oob_elements.html new file mode 100644 index 0000000..ab50cb1 --- /dev/null +++ b/templates/_types/orders/_oob_elements.html @@ -0,0 +1,38 @@ +{% extends 'oob_elements.html' %} + +{# OOB elements for HTMX navigation - all elements that need updating #} + +{# Import shared OOB macros #} +{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %} +{% from '_types/root/_oob_menu.html' import mobile_menu with context %} + +{# Header with app title - includes cart-mini, navigation, and market-specific header #} + +{% block oobs %} + + {% from '_types/root/_n/macros.html' import oob_header with context %} + {{oob_header('auth-header-child', 'orders-header-child', '_types/orders/header/_header.html')}} + + {% from '_types/auth/header/_header.html' import header_row with context %} + {{ header_row(oob=True) }} +{% endblock %} + +{% block aside %} + {% import '_types/browse/desktop/_filter/search.html' as s %} + {{ s.search(current_local_href, search, search_count, hx_select) }} +{% endblock %} + +{% block filter %} +{% include '_types/orders/_summary.html' %} +{% endblock %} + +{% block mobile_menu %} + {% include '_types/orders/_nav.html' %} +{% endblock %} + + +{% block content %} + {% include "_types/orders/_main_panel.html" %} +{% endblock %} + + diff --git a/templates/_types/orders/_rows.html b/templates/_types/orders/_rows.html new file mode 100644 index 0000000..33a459c --- /dev/null +++ b/templates/_types/orders/_rows.html @@ -0,0 +1,164 @@ +{# suma_browser/templates/_types/order/_orders_rows.html #} + +{# --- existing rows, but split into desktop/tablet vs mobile --- #} +{% for order in orders %} + {# Desktop / tablet table row #} + + + #{{ order.id }} + + + {% if order.created_at %} + {{ order.created_at.strftime('%-d %b %Y, %H:%M') }} + {% else %} + — + {% endif %} + + + {{ order.description or '' }} + + + + {{ order.currency or 'GBP' }} + {{ '%.2f'|format(order.total_amount or 0) }} + + + {# status pill, roughly matching existing styling #} + + {{ order.status or 'pending' }} + + + + + View + + + + + {# Mobile card row #} + + +
+
+ + #{{ order.id }} + + + + {{ order.status or 'pending' }} + +
+ +
+ {{ order.created_at or '' }} +
+ +
+
+ {{ order.currency or 'GBP' }} + {{ '%.2f'|format(order.total_amount or 0) }} +
+ + + View + +
+
+ + +{% endfor %} + +{# --- sentinel / end-of-results --- #} +{% if page < total_pages|int %} + + + {# Mobile sentinel content #} +
+ {% include "sentinel/mobile_content.html" %} +
+ + {# Desktop sentinel content #} + + + +{% else %} + + + End of results + + +{% endif %} diff --git a/templates/_types/orders/_summary.html b/templates/_types/orders/_summary.html new file mode 100644 index 0000000..824a235 --- /dev/null +++ b/templates/_types/orders/_summary.html @@ -0,0 +1,11 @@ +
+
+

+ Recent orders placed via the checkout. +

+
+
+ {% import '_types/browse/mobile/_filter/search.html' as s %} + {{ s.search(current_local_href, search, search_count, hx_select) }} +
+
\ No newline at end of file diff --git a/templates/_types/orders/header/_header.html b/templates/_types/orders/header/_header.html new file mode 100644 index 0000000..32c1659 --- /dev/null +++ b/templates/_types/orders/header/_header.html @@ -0,0 +1,14 @@ +{% import 'macros/links.html' as links %} +{% macro header_row(oob=False) %} + {% call links.menu_row(id='orders-row', oob=oob) %} + {% call links.link(url_for('orders.list_orders'), hx_select_search, ) %} + +
+ Orders +
+ {% endcall %} + {% call links.desktop_nav() %} + {% include '_types/orders/_nav.html' %} + {% endcall %} + {% endcall %} +{% endmacro %} \ No newline at end of file diff --git a/templates/_types/orders/index.html b/templates/_types/orders/index.html new file mode 100644 index 0000000..8744c13 --- /dev/null +++ b/templates/_types/orders/index.html @@ -0,0 +1,29 @@ +{% extends '_types/auth/index.html' %} + + +{% block auth_header_child %} + {% from '_types/root/_n/macros.html' import index_row with context %} + {% call index_row('orders-header-child', '_types/orders/header/_header.html') %} + {% block orders_header_child %} + {% endblock %} + {% endcall %} + +{% endblock %} + +{% block _main_mobile_menu %} + {% include '_types/orders/_nav.html' %} +{% endblock %} + +{% block aside %} + {% import '_types/browse/desktop/_filter/search.html' as s %} + {{ s.search(current_local_href, search, search_count, hx_select) }} +{% endblock %} + + +{% block filter %} + {% include '_types/orders/_summary.html' %} +{% endblock %} + +{% block content %} +{% include '_types/orders/_main_panel.html' %} +{% endblock %}