Files
mono/cart/app.py
giles 567888c9e0 Fix cart template cross-app url_for crash and favicon 404
- Cart _cart.html: replace url_for('market.browse.product...') with
  market_product_url() for links and cart_global.update_quantity for
  quantity forms (market endpoints don't exist in cart app)
- Factory favicon route: use STATIC_DIR instead of relative "static"
  (resolves to shared/static/ where favicon.ico actually lives)
- Cart context processor: fetch all 3 fragments (cart-mini, auth-menu,
  nav-tree) concurrently, matching pattern in all other apps

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:48:23 +00:00

251 lines
8.7 KiB
Python

from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from decimal import Decimal
from pathlib import Path
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from bp import (
register_cart_overview,
register_page_cart,
register_cart_global,
register_orders,
register_fragments,
)
from bp.cart.services import (
get_cart,
total,
get_calendar_cart_entries,
calendar_total,
get_ticket_cart_entries,
ticket_total,
)
from bp.cart.services.page_cart import (
get_cart_for_page,
get_calendar_entries_for_page,
get_tickets_for_page,
)
from bp.cart.services.ticket_groups import group_tickets
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)
- nav_tree_html: fetched from blog as fragment
When g.page_post exists, cart and calendar_cart_entries are page-scoped.
Global cart_count / cart_total stay global for cart-mini.
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Pre-fetch cross-app HTML fragments concurrently
user = getattr(g, "user", None)
ident = current_cart_identity()
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "cart", "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
# Cart app owns cart data — use g.cart from _load_cart
all_cart = getattr(g, "cart", None) or []
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)
cart_qty = sum(ci.quantity for ci in all_cart) if all_cart else 0
ctx["cart_count"] = cart_qty + len(all_cal) + len(all_tickets)
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_post = getattr(g, "page_post", None)
if page_post:
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_tickets = await get_tickets_for_page(g.s, page_post.id)
ctx["cart"] = page_cart
ctx["calendar_cart_entries"] = page_cal
ctx["ticket_cart_entries"] = page_tickets
ctx["page_post"] = page_post
ctx["page_config"] = getattr(g, "page_config", None)
else:
ctx["cart"] = all_cart
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["calendar_total"] = calendar_total
ctx["ticket_total"] = ticket_total
return ctx
def create_app() -> "Quart":
from shared.models.page_config import PageConfig
from shared.services.registry import services
from services import register_domain_services
app = create_base_app(
"cart",
context_fn=cart_context,
before_request_fns=[_load_cart],
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
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) ---
@app.url_value_preprocessor
def pull_page_slug(endpoint, values):
if values and "page_slug" in values:
g.page_slug = values.pop("page_slug")
@app.url_defaults
def inject_page_slug(endpoint, values):
slug = g.get("page_slug")
if slug and "page_slug" not in values:
if app.url_map.is_endpoint_expecting(endpoint, "page_slug"):
values["page_slug"] = slug
@app.before_request
async def hydrate_page():
slug = getattr(g, "page_slug", None)
if not slug:
return
post = await services.blog.get_post_by_slug(g.s, slug)
if not post or not post.is_page:
abort(404)
g.page_post = post
g.page_config = (
await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == "page",
PageConfig.container_id == post.id,
)
)
).scalar_one_or_none()
# --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last
# Orders blueprint
app.register_blueprint(register_orders(url_prefix="/orders"))
# Global routes (webhook, return, add — specific paths under /)
app.register_blueprint(
register_cart_global(url_prefix="/"),
url_prefix="/",
)
# Cart overview at GET /
app.register_blueprint(
register_cart_overview(url_prefix="/"),
url_prefix="/",
)
# Page cart at /<page_slug>/ (dynamic, matched last)
app.register_blueprint(
register_page_cart(url_prefix="/"),
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
app = create_app()