from __future__ import annotations import path_setup # noqa: F401 # adds shared/ to sys.path from decimal import Decimal from pathlib import Path from types import SimpleNamespace from quart import g, abort, request from jinja2 import FileSystemLoader, ChoiceLoader from shared.infrastructure.factory import create_base_app from bp import ( register_cart_overview, register_page_cart, register_cart_global, register_orders, register_fragments, register_actions, register_data, ) 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 _make_page_config(raw: dict) -> SimpleNamespace: """Convert a page-config JSON dict to a namespace for SumUp helpers.""" return SimpleNamespace(**raw) def create_app() -> "Quart": 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()) app.register_blueprint(register_actions()) app.register_blueprint(register_data()) # --- 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(): from shared.infrastructure.data_client import fetch_data from shared.contracts.dtos import PostDTO, dto_from_dict slug = getattr(g, "page_slug", None) if not slug: return raw = await fetch_data("blog", "post-by-slug", params={"slug": slug}) if not raw: abort(404) post = dto_from_dict(PostDTO, raw) if not post or not post.is_page: abort(404) g.page_post = post raw_pc = await fetch_data( "blog", "page-config", params={"container_type": "page", "container_id": post.id}, required=False, ) g.page_config = _make_page_config(raw_pc) if raw_pc else 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 // (dynamic, matched last) app.register_blueprint( register_page_cart(url_prefix="/"), url_prefix="/", ) # --- 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 as sel from shared.db.session import get_session from shared.models.order import Order from shared.infrastructure.data_client import fetch_data 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(): cutoff = datetime.now(timezone.utc) - timedelta(minutes=2) result = await sess.execute( sel(Order) .where( Order.status == "pending", Order.sumup_checkout_id.isnot(None), Order.created_at < cutoff, ) .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: # Fetch page_config from blog if order has one pc = None if order.page_config_id: raw_pc = await fetch_data( "blog", "page-config-by-id", params={"id": order.page_config_id}, required=False, ) if raw_pc: pc = _make_page_config(raw_pc) await check_sumup_status(sess, order, page_config=pc) 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()