diff --git a/account/Dockerfile b/account/Dockerfile index 6131e2d..d7c6fbe 100644 --- a/account/Dockerfile +++ b/account/Dockerfile @@ -38,6 +38,12 @@ COPY events/__init__.py ./events/__init__.py COPY events/models/ ./events/models/ COPY federation/__init__.py ./federation/__init__.py COPY federation/models/ ./federation/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ # ---------- Runtime setup ---------- COPY account/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/blog/Dockerfile b/blog/Dockerfile index 585991f..b866953 100644 --- a/blog/Dockerfile +++ b/blog/Dockerfile @@ -46,6 +46,12 @@ COPY federation/__init__.py ./federation/__init__.py COPY federation/models/ ./federation/models/ COPY account/__init__.py ./account/__init__.py COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ # Copy built editor assets from stage 1 COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/ diff --git a/blog/alembic/env.py b/blog/alembic/env.py index 78e0d5d..bcdac6c 100644 --- a/blog/alembic/env.py +++ b/blog/alembic/env.py @@ -6,14 +6,16 @@ MODELS = [ "shared.models.kv", "shared.models.menu_item", "shared.models.menu_node", + "shared.models.page_config", "blog.models.snippet", "blog.models.tag_group", ] TABLES = frozenset({ - "posts", "authors", "post_authors", "tags", "post_tags", "post_likes", + "posts", "authors", "post_authors", "tags", "post_tags", "snippets", "tag_groups", "tag_group_tags", "menu_items", "menu_nodes", "kv", + "page_configs", }) run_alembic(context.config, MODELS, TABLES) diff --git a/blog/bp/actions/routes.py b/blog/bp/actions/routes.py index 2d1c896..2013bca 100644 --- a/blog/bp/actions/routes.py +++ b/blog/bp/actions/routes.py @@ -31,13 +31,65 @@ def register() -> Blueprint: result = await handler() return jsonify(result or {"ok": True}) - # --- update-page-config (proxy to cart, where page_configs table lives) --- + # --- update-page-config --- async def _update_page_config(): - """Create or update a PageConfig — proxies to cart service.""" - from shared.infrastructure.actions import call_action + """Create or update a PageConfig (page_configs now lives in db_blog).""" + from shared.models.page_config import PageConfig + from sqlalchemy import select + from sqlalchemy.orm.attributes import flag_modified data = await request.get_json(force=True) - return await call_action("cart", "update-page-config", payload=data) + container_type = data.get("container_type", "page") + container_id = data.get("container_id") + if container_id is None: + return {"error": "container_id required"}, 400 + + pc = (await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == container_type, + PageConfig.container_id == container_id, + ) + )).scalar_one_or_none() + + if pc is None: + pc = PageConfig( + container_type=container_type, + container_id=container_id, + features=data.get("features", {}), + ) + g.s.add(pc) + await g.s.flush() + + if "features" in data: + features = dict(pc.features or {}) + for key, val in data["features"].items(): + if isinstance(val, bool): + features[key] = val + elif val in ("true", "1", "on"): + features[key] = True + elif val in ("false", "0", "off", None): + features[key] = False + pc.features = features + flag_modified(pc, "features") + + if "sumup_merchant_code" in data: + pc.sumup_merchant_code = data["sumup_merchant_code"] or None + if "sumup_checkout_prefix" in data: + pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None + if "sumup_api_key" in data: + pc.sumup_api_key = data["sumup_api_key"] or None + + await g.s.flush() + + return { + "id": pc.id, + "container_type": pc.container_type, + "container_id": pc.container_id, + "features": pc.features or {}, + "sumup_merchant_code": pc.sumup_merchant_code, + "sumup_checkout_prefix": pc.sumup_checkout_prefix, + "sumup_configured": bool(pc.sumup_api_key), + } _handlers["update-page-config"] = _update_page_config diff --git a/blog/bp/blog/ghost_db.py b/blog/bp/blog/ghost_db.py index e193b85..2081a10 100644 --- a/blog/bp/blog/ghost_db.py +++ b/blog/bp/blog/ghost_db.py @@ -286,17 +286,28 @@ class DBClient: rows: List[Post] = list((await self.sess.execute(q)).scalars()) - # Load PageConfigs from cart service (page_configs lives in db_cart) + # Load PageConfigs directly (page_configs now lives in db_blog) post_ids = [p.id for p in rows] pc_map: Dict[int, dict] = {} if post_ids: - from shared.infrastructure.data_client import fetch_data + from shared.models.page_config import PageConfig try: - raw_pcs = await fetch_data("cart", "page-configs-batch", - params={"container_type": "page", "ids": ",".join(str(i) for i in post_ids)}) - if isinstance(raw_pcs, list): - for pc in raw_pcs: - pc_map[pc["container_id"]] = pc + pc_result = await self.sess.execute( + select(PageConfig).where( + PageConfig.container_type == "page", + PageConfig.container_id.in_(post_ids), + ) + ) + for pc in pc_result.scalars().all(): + pc_map[pc.container_id] = { + "id": pc.id, + "container_type": pc.container_type, + "container_id": pc.container_id, + "features": pc.features or {}, + "sumup_merchant_code": pc.sumup_merchant_code, + "sumup_api_key": pc.sumup_api_key, + "sumup_checkout_prefix": pc.sumup_checkout_prefix, + } except Exception: pass # graceful degradation — pages render without features diff --git a/blog/bp/blog/services/posts_data.py b/blog/bp/blog/services/posts_data.py index 3203aae..1d2c0ad 100644 --- a/blog/bp/blog/services/posts_data.py +++ b/blog/bp/blog/services/posts_data.py @@ -1,8 +1,7 @@ import re from ..ghost_db import DBClient # adjust import path -from sqlalchemy import select -from models.ghost_content import PostLike +from shared.infrastructure.data_client import fetch_data from shared.infrastructure.fragments import fetch_fragment from quart import g @@ -68,22 +67,15 @@ async def posts_data( post_ids = [p["id"] for p in posts] # Add is_liked field to each post for current user - if g.user: - # Fetch all likes for this user and these posts in one query - liked_posts = await session.execute( - select(PostLike.post_id).where( - PostLike.user_id == g.user.id, - PostLike.post_id.in_(post_ids), - PostLike.deleted_at.is_(None), - ) - ) - liked_post_ids = {row[0] for row in liked_posts} + if g.user and post_ids: + liked_ids_list = await fetch_data("likes", "liked-ids", params={ + "user_id": g.user.id, "target_type": "post", + }, required=False) or [] + liked_post_ids = set(liked_ids_list) - # Add is_liked to each post for post in posts: post["is_liked"] = post["id"] in liked_post_ids else: - # Not logged in - no posts are liked for post in posts: post["is_liked"] = False diff --git a/blog/bp/data/routes.py b/blog/bp/data/routes.py index afe6356..4fa5533 100644 --- a/blog/bp/data/routes.py +++ b/blog/bp/data/routes.py @@ -7,7 +7,7 @@ from __future__ import annotations from quart import Blueprint, g, jsonify, request -from shared.infrastructure.data_client import DATA_HEADER, fetch_data +from shared.infrastructure.data_client import DATA_HEADER from shared.contracts.dtos import dto_to_dict from shared.services.registry import services @@ -74,31 +74,112 @@ def register() -> Blueprint: _handlers["search-posts"] = _search_posts - # --- page-config (proxy to cart, where page_configs table lives) --- + # --- page-config-ensure --- + async def _page_config_ensure(): + """Get or create a PageConfig for a container_type + container_id.""" + from sqlalchemy import select + from shared.models.page_config import PageConfig + + container_type = request.args.get("container_type", "page") + container_id = request.args.get("container_id", type=int) + if container_id is None: + return {"error": "container_id required"}, 400 + + row = (await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == container_type, + PageConfig.container_id == container_id, + ) + )).scalar_one_or_none() + + if row is None: + row = PageConfig( + container_type=container_type, + container_id=container_id, + features={}, + ) + g.s.add(row) + await g.s.flush() + + return { + "id": row.id, + "container_type": row.container_type, + "container_id": row.container_id, + } + + _handlers["page-config-ensure"] = _page_config_ensure + + # --- page-config --- async def _page_config(): """Return a single PageConfig by container_type + container_id.""" - return await fetch_data("cart", "page-config", - params={"container_type": request.args.get("container_type", "page"), - "container_id": request.args.get("container_id", "")}, - required=False) + from sqlalchemy import select + from shared.models.page_config import PageConfig + + ct = request.args.get("container_type", "page") + cid = request.args.get("container_id", type=int) + if cid is None: + return None + pc = (await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == ct, + PageConfig.container_id == cid, + ) + )).scalar_one_or_none() + if not pc: + return None + return _page_config_dict(pc) _handlers["page-config"] = _page_config - # --- page-config-by-id (proxy to cart) --- + # --- page-config-by-id --- async def _page_config_by_id(): - return await fetch_data("cart", "page-config-by-id", - params={"id": request.args.get("id", "")}, - required=False) + """Return a single PageConfig by its primary key.""" + from shared.models.page_config import PageConfig + + pc_id = request.args.get("id", type=int) + if pc_id is None: + return None + pc = await g.s.get(PageConfig, pc_id) + if not pc: + return None + return _page_config_dict(pc) _handlers["page-config-by-id"] = _page_config_by_id - # --- page-configs-batch (proxy to cart) --- + # --- page-configs-batch --- async def _page_configs_batch(): - return await fetch_data("cart", "page-configs-batch", - params={"container_type": request.args.get("container_type", "page"), - "ids": request.args.get("ids", "")}, - required=False) or [] + """Return PageConfigs for multiple container_ids (comma-separated).""" + from sqlalchemy import select + from shared.models.page_config import PageConfig + + ct = request.args.get("container_type", "page") + ids_raw = request.args.get("ids", "") + if not ids_raw: + return [] + ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()] + if not ids: + return [] + result = await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == ct, + PageConfig.container_id.in_(ids), + ) + ) + return [_page_config_dict(pc) for pc in result.scalars().all()] _handlers["page-configs-batch"] = _page_configs_batch return bp + + +def _page_config_dict(pc) -> dict: + """Serialize PageConfig to a JSON-safe dict.""" + return { + "id": pc.id, + "container_type": pc.container_type, + "container_id": pc.container_id, + "features": pc.features or {}, + "sumup_merchant_code": pc.sumup_merchant_code, + "sumup_api_key": pc.sumup_api_key, + "sumup_checkout_prefix": pc.sumup_checkout_prefix, + } diff --git a/blog/bp/menu_items/services/menu_items.py b/blog/bp/menu_items/services/menu_items.py index 20a142f..e02b275 100644 --- a/blog/bp/menu_items/services/menu_items.py +++ b/blog/bp/menu_items/services/menu_items.py @@ -80,7 +80,7 @@ async def create_menu_item( ) session.add(menu_node) await session.flush() - await call_action("cart", "attach-child", payload={ + await call_action("relations", "attach-child", payload={ "parent_type": "page", "parent_id": post_id, "child_type": "menu_node", "child_id": menu_node.id, }) @@ -134,11 +134,11 @@ async def update_menu_item( await session.flush() if post_id is not None and post_id != old_post_id: - await call_action("cart", "detach-child", payload={ + await call_action("relations", "detach-child", payload={ "parent_type": "page", "parent_id": old_post_id, "child_type": "menu_node", "child_id": menu_node.id, }) - await call_action("cart", "attach-child", payload={ + await call_action("relations", "attach-child", payload={ "parent_type": "page", "parent_id": post_id, "child_type": "menu_node", "child_id": menu_node.id, }) @@ -154,7 +154,7 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool: menu_node.deleted_at = func.now() await session.flush() - await call_action("cart", "detach-child", payload={ + await call_action("relations", "detach-child", payload={ "parent_type": "page", "parent_id": menu_node.container_id, "child_type": "menu_node", "child_id": menu_node.id, }) diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 1ff3393..ac85830 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -22,23 +22,27 @@ def register(): @require_admin async def admin(slug: str): from shared.browser.app.utils.htmx import is_htmx_request - from shared.infrastructure.data_client import fetch_data + from sqlalchemy import select + from shared.models.page_config import PageConfig - # Load features for page admin (page_configs lives in db_cart) + # Load features for page admin (page_configs now lives in db_blog) post = (g.post_data or {}).get("post", {}) features = {} sumup_configured = False sumup_merchant_code = "" sumup_checkout_prefix = "" if post.get("is_page"): - raw_pc = await fetch_data("cart", "page-config", - params={"container_type": "page", "container_id": post["id"]}, - required=False) - if raw_pc: - features = raw_pc.get("features") or {} - sumup_configured = bool(raw_pc.get("sumup_api_key")) - sumup_merchant_code = raw_pc.get("sumup_merchant_code") or "" - sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or "" + pc = (await g.s.execute( + select(PageConfig).where( + PageConfig.container_type == "page", + PageConfig.container_id == post["id"], + ) + )).scalar_one_or_none() + if pc: + features = pc.features or {} + sumup_configured = bool(pc.sumup_api_key) + sumup_merchant_code = pc.sumup_merchant_code or "" + sumup_checkout_prefix = pc.sumup_checkout_prefix or "" ctx = { "features": features, @@ -84,7 +88,7 @@ def register(): return jsonify({"error": "Expected JSON object with feature flags."}), 400 # Update via cart action (page_configs lives in db_cart) - result = await call_action("cart", "update-page-config", payload={ + result = await call_action("blog", "update-page-config", payload={ "container_type": "page", "container_id": post_id, "features": body, @@ -129,7 +133,7 @@ def register(): if api_key: payload["sumup_api_key"] = api_key - result = await call_action("cart", "update-page-config", payload=payload) + result = await call_action("blog", "update-page-config", payload=payload) features = result.get("features", {}) html = await render_template( diff --git a/blog/bp/post/routes.py b/blog/bp/post/routes.py index ce320a5..ba19ad5 100644 --- a/blog/bp/post/routes.py +++ b/blog/bp/post/routes.py @@ -11,8 +11,8 @@ from quart import ( request, ) from .services.post_data import post_data -from .services.post_operations import toggle_post_like from shared.infrastructure.data_client import fetch_data +from shared.infrastructure.actions import call_action from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.infrastructure.fragments import fetch_fragment, fetch_fragments @@ -144,11 +144,10 @@ def register(): post_id = g.post_data["post"]["id"] user_id = g.user.id - liked, error = await toggle_post_like(g.s, user_id, post_id) - - if error: - resp = make_response(error, 404) - return resp + result = await call_action("likes", "toggle", payload={ + "user_id": user_id, "target_type": "post", "target_id": post_id, + }) + liked = result["liked"] html = await render_template( "_types/browse/like/button.html", diff --git a/blog/bp/post/services/markets.py b/blog/bp/post/services/markets.py index 630c785..b5c6d27 100644 --- a/blog/bp/post/services/markets.py +++ b/blog/bp/post/services/markets.py @@ -41,7 +41,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl if not post.is_page: raise MarketError("Markets can only be created on pages, not posts.") - raw_pc = await fetch_data("cart", "page-config", + raw_pc = await fetch_data("blog", "page-config", params={"container_type": "page", "container_id": post_id}, required=False) if raw_pc is None or not (raw_pc.get("features") or {}).get("market"): diff --git a/blog/bp/post/services/post_data.py b/blog/bp/post/services/post_data.py index 0d0d225..3ac71f4 100644 --- a/blog/bp/post/services/post_data.py +++ b/blog/bp/post/services/post_data.py @@ -1,6 +1,5 @@ from ...blog.ghost_db import DBClient # adjust import path -from sqlalchemy import select -from models.ghost_content import PostLike +from shared.infrastructure.data_client import fetch_data from quart import g async def post_data(slug, session, include_drafts=False): @@ -15,14 +14,10 @@ async def post_data(slug, session, include_drafts=False): # Check if current user has liked this post is_liked = False if g.user: - liked_record = await session.scalar( - select(PostLike).where( - PostLike.user_id == g.user.id, - PostLike.post_id == post["id"], - PostLike.deleted_at.is_(None), - ) - ) - is_liked = liked_record is not None + liked_data = await fetch_data("likes", "is-liked", params={ + "user_id": g.user.id, "target_type": "post", "target_id": post["id"], + }, required=False) + is_liked = (liked_data or {}).get("liked", False) # Add is_liked to post dict post["is_liked"] = is_liked diff --git a/blog/bp/post/services/post_operations.py b/blog/bp/post/services/post_operations.py index e4bb102..e69de29 100644 --- a/blog/bp/post/services/post_operations.py +++ b/blog/bp/post/services/post_operations.py @@ -1,58 +0,0 @@ -from __future__ import annotations - -from typing import Optional - -from sqlalchemy import select, func, update -from sqlalchemy.ext.asyncio import AsyncSession - -from models.ghost_content import Post, PostLike - - -async def toggle_post_like( - session: AsyncSession, - user_id: int, - post_id: int, -) -> tuple[bool, Optional[str]]: - """ - Toggle a post like for a given user using soft deletes. - Returns (liked_state, error_message). - - If error_message is not None, an error occurred. - - liked_state indicates whether post is now liked (True) or unliked (False). - """ - - # Verify post exists - post_exists = await session.scalar( - select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None)) - ) - if not post_exists: - return False, "Post not found" - - # Check if like exists (not deleted) - existing = await session.scalar( - select(PostLike).where( - PostLike.user_id == user_id, - PostLike.post_id == post_id, - PostLike.deleted_at.is_(None), - ) - ) - - if existing: - # Unlike: soft delete the like - await session.execute( - update(PostLike) - .where( - PostLike.user_id == user_id, - PostLike.post_id == post_id, - PostLike.deleted_at.is_(None), - ) - .values(deleted_at=func.now()) - ) - return False, None - else: - # Like: add a new like - new_like = PostLike( - user_id=user_id, - post_id=post_id, - ) - session.add(new_like) - return True, None diff --git a/blog/models/__init__.py b/blog/models/__init__.py index e434f4a..6ec724d 100644 --- a/blog/models/__init__.py +++ b/blog/models/__init__.py @@ -1,4 +1,4 @@ -from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike +from .ghost_content import Post, Author, Tag, PostAuthor, PostTag from .snippet import Snippet from .tag_group import TagGroup, TagGroupTag diff --git a/blog/models/ghost_content.py b/blog/models/ghost_content.py index cd18161..5343814 100644 --- a/blog/models/ghost_content.py +++ b/blog/models/ghost_content.py @@ -1,3 +1,3 @@ from shared.models.ghost_content import ( # noqa: F401 - Tag, Post, Author, PostAuthor, PostTag, PostLike, + Tag, Post, Author, PostAuthor, PostTag, ) diff --git a/blog/services/__init__.py b/blog/services/__init__.py index 89c1023..495a897 100644 --- a/blog/services/__init__.py +++ b/blog/services/__init__.py @@ -5,7 +5,7 @@ from __future__ import annotations def register_domain_services() -> None: """Register services for the blog app. - Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike. + Blog owns: Post, Tag, Author, PostAuthor, PostTag. Cross-app calls go over HTTP via call_action() / fetch_data(). """ from shared.services.registry import services diff --git a/cart/Dockerfile b/cart/Dockerfile index 7fb990e..ee9aac3 100644 --- a/cart/Dockerfile +++ b/cart/Dockerfile @@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py COPY federation/models/ ./federation/models/ COPY account/__init__.py ./account/__init__.py COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ # ---------- Runtime setup ---------- COPY cart/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/cart/alembic/env.py b/cart/alembic/env.py index 7a12099..248c34e 100644 --- a/cart/alembic/env.py +++ b/cart/alembic/env.py @@ -3,13 +3,10 @@ from shared.db.alembic_env import run_alembic MODELS = [ "shared.models.market", # CartItem lives here - "shared.models.order", - "shared.models.page_config", - "shared.models.container_relation", ] TABLES = frozenset({ - "cart_items", "orders", "order_items", "page_configs", "container_relations", + "cart_items", }) run_alembic(context.config, MODELS, TABLES) diff --git a/cart/app.py b/cart/app.py index 8643f7b..0eefe90 100644 --- a/cart/app.py +++ b/cart/app.py @@ -15,7 +15,6 @@ from bp import ( register_cart_overview, register_page_cart, register_cart_global, - register_orders, register_fragments, register_actions, register_data, @@ -121,7 +120,6 @@ def _make_page_config(raw: dict) -> SimpleNamespace: def create_app() -> "Quart": - from shared.services.registry import services from services import register_domain_services app = create_base_app( @@ -184,10 +182,7 @@ def create_app() -> "Quart": # --- 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 /) + # Global routes (add, quantity, delete, checkout — specific paths under /) app.register_blueprint( register_cart_global(url_prefix="/"), url_prefix="/", @@ -205,65 +200,6 @@ def create_app() -> "Quart": 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 diff --git a/cart/bp/__init__.py b/cart/bp/__init__.py index 72d4f84..ee967da 100644 --- a/cart/bp/__init__.py +++ b/cart/bp/__init__.py @@ -1,8 +1,6 @@ from .cart.overview_routes import register as register_cart_overview from .cart.page_routes import register as register_page_cart from .cart.global_routes import register as register_cart_global -from .order.routes import register as register_order -from .orders.routes import register as register_orders from .fragments import register_fragments from .actions import register_actions from .data import register_data diff --git a/cart/bp/actions/routes.py b/cart/bp/actions/routes.py index 4e0adc5..8401a9f 100644 --- a/cart/bp/actions/routes.py +++ b/cart/bp/actions/routes.py @@ -47,109 +47,26 @@ def register() -> Blueprint: _handlers["adopt-cart-for-user"] = _adopt_cart - # --- update-page-config --- - async def _update_page_config(): - """Create or update a PageConfig (page_configs lives in db_cart).""" - from shared.models.page_config import PageConfig - from sqlalchemy import select - from sqlalchemy.orm.attributes import flag_modified + # --- clear-cart-for-order --- + async def _clear_cart_for_order(): + """Soft-delete cart items after an order is paid. Called by orders service.""" + from bp.cart.services.clear_cart_for_order import clear_cart_for_order + from shared.models.order import Order - data = await request.get_json(force=True) - container_type = data.get("container_type", "page") - container_id = data.get("container_id") - if container_id is None: - return {"error": "container_id required"}, 400 + data = await request.get_json() + user_id = data.get("user_id") + session_id = data.get("session_id") + page_post_id = data.get("page_post_id") - pc = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == container_type, - PageConfig.container_id == container_id, - ) - )).scalar_one_or_none() + # Build a minimal order-like object with the fields clear_cart_for_order needs + order = type("_Order", (), { + "user_id": user_id, + "session_id": session_id, + })() - if pc is None: - pc = PageConfig( - container_type=container_type, - container_id=container_id, - features=data.get("features", {}), - ) - g.s.add(pc) - await g.s.flush() + await clear_cart_for_order(g.s, order, page_post_id=page_post_id) + return {"ok": True} - if "features" in data: - features = dict(pc.features or {}) - for key, val in data["features"].items(): - if isinstance(val, bool): - features[key] = val - elif val in ("true", "1", "on"): - features[key] = True - elif val in ("false", "0", "off", None): - features[key] = False - pc.features = features - flag_modified(pc, "features") - - if "sumup_merchant_code" in data: - pc.sumup_merchant_code = data["sumup_merchant_code"] or None - if "sumup_checkout_prefix" in data: - pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None - if "sumup_api_key" in data: - pc.sumup_api_key = data["sumup_api_key"] or None - - await g.s.flush() - - return { - "id": pc.id, - "container_type": pc.container_type, - "container_id": pc.container_id, - "features": pc.features or {}, - "sumup_merchant_code": pc.sumup_merchant_code, - "sumup_checkout_prefix": pc.sumup_checkout_prefix, - "sumup_configured": bool(pc.sumup_api_key), - } - - _handlers["update-page-config"] = _update_page_config - - # --- attach-child --- - async def _attach_child(): - """Create or revive a ContainerRelation.""" - from shared.services.relationships import attach_child - - data = await request.get_json(force=True) - rel = await attach_child( - g.s, - parent_type=data["parent_type"], - parent_id=data["parent_id"], - child_type=data["child_type"], - child_id=data["child_id"], - label=data.get("label"), - sort_order=data.get("sort_order"), - ) - return { - "id": rel.id, - "parent_type": rel.parent_type, - "parent_id": rel.parent_id, - "child_type": rel.child_type, - "child_id": rel.child_id, - "sort_order": rel.sort_order, - } - - _handlers["attach-child"] = _attach_child - - # --- detach-child --- - async def _detach_child(): - """Soft-delete a ContainerRelation.""" - from shared.services.relationships import detach_child - - data = await request.get_json(force=True) - deleted = await detach_child( - g.s, - parent_type=data["parent_type"], - parent_id=data["parent_id"], - child_type=data["child_type"], - child_id=data["child_id"], - ) - return {"deleted": deleted} - - _handlers["detach-child"] = _detach_child + _handlers["clear-cart-for-order"] = _clear_cart_for_order return bp diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index c1151cc..33e8897 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -1,4 +1,4 @@ -# bp/cart/global_routes.py — Global cart routes (webhook, return, add) +# bp/cart/global_routes.py — Global cart routes (add, quantity, delete, checkout) from __future__ import annotations @@ -6,7 +6,6 @@ from quart import Blueprint, g, request, render_template, redirect, url_for, mak from sqlalchemy import select from shared.models.market import CartItem -from shared.models.order import Order from shared.infrastructure.actions import call_action from .services import ( current_cart_identity, @@ -16,20 +15,11 @@ from .services import ( calendar_total, get_ticket_cart_entries, ticket_total, - 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 shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout -from shared.browser.app.csrf import csrf_exempt def register(url_prefix: str) -> Blueprint: @@ -141,7 +131,7 @@ def register(url_prefix: str) -> Blueprint: @bp.post("/checkout/") async def checkout(): - """Legacy global checkout (for orphan items without page scope).""" + """Global checkout — delegates order creation to orders service.""" cart = await get_cart(g.s) calendar_entries = await get_calendar_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s) @@ -168,151 +158,63 @@ def register(url_prefix: str) -> Blueprint: return await make_response(html, 400) 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, - ticket_total=ticket_amount, - ) + # Serialize cart items for the orders service + cart_items_data = [] + for ci in cart: + cart_items_data.append({ + "product_id": ci.product_id, + "product_title": ci.product_title, + "product_slug": ci.product_slug, + "product_image": ci.product_image, + "product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None, + "product_special_price": float(ci.product_special_price) if ci.product_special_price else None, + "product_price_currency": ci.product_price_currency, + "quantity": ci.quantity, + }) + + # Serialize calendar entries and tickets + cal_data = [] + for e in calendar_entries: + cal_data.append({ + "id": e.id, + "calendar_container_id": getattr(e, "calendar_container_id", None), + }) + ticket_data = [] + for t in tickets: + ticket_data.append({ + "id": t.id, + "calendar_container_id": getattr(t, "calendar_container_id", None), + }) + + page_post_id = None if page_config: - order.page_config_id = page_config.id + page_post_id = getattr(page_config, "container_id", None) - 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) - description = build_sumup_description(cart, order.id, ticket_count=len(tickets)) + result = await call_action("orders", "create-order", payload={ + "cart_items": cart_items_data, + "calendar_entries": cal_data, + "tickets": ticket_data, + "user_id": ident.get("user_id"), + "session_id": ident.get("session_id"), + "product_total": float(product_total), + "calendar_total": float(calendar_amount), + "ticket_total": float(ticket_amount), + "page_post_id": page_post_id, + }) - webhook_base_url = url_for("cart_global.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, - ) - 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() + # Update redirect/webhook URLs with real order_id + order_id = result["order_id"] + hosted_url = result.get("sumup_hosted_url") if not hosted_url: html = await render_template( "_types/cart/checkout_error.html", - order=order, + order=None, error="No hosted checkout URL returned from SumUp.", ) return await make_response(html, 500) return redirect(hosted_url) - @csrf_exempt - @bp.post("/checkout/webhook//") - async def checkout_webhook(order_id: int): - """Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events.""" - 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 - - result = await g.s.execute(select(Order).where(Order.id == order_id)) - order = result.scalar_one_or_none() - if not order: - return "", 204 - - if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id: - return "", 204 - - 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) - - # Resolve page/market slugs so product links render correctly - if order.page_config_id: - from shared.infrastructure.data_client import fetch_data - from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict - raw_pc = await fetch_data("cart", "page-config-by-id", - params={"id": order.page_config_id}, - required=False) - post = await fetch_data("blog", "post-by-id", - params={"id": raw_pc["container_id"]}, - required=False) if raw_pc else None - if post: - g.page_slug = post["slug"] - # Fetch marketplace slug from market service - mps = await fetch_data( - "market", "marketplaces-for-container", - params={"type": "page", "id": post["id"]}, - required=False, - ) or [] - if mps: - g.market_slug = mps[0].get("slug") - - if order.sumup_checkout_id: - try: - await check_sumup_status(g.s, order) - except Exception: - pass - - status = (order.status or "pending").lower() - - from shared.infrastructure.data_client import fetch_data - from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict - raw_entries = await fetch_data("events", "entries-for-order", - params={"order_id": order.id}, required=False) or [] - calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries] - raw_tickets = await fetch_data("events", "tickets-for-order", - params={"order_id": order.id}, required=False) or [] - order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets] - await g.s.flush() - - html = await render_template( - "_types/cart/checkout_return.html", - order=order, - status=status, - calendar_entries=calendar_entries, - order_tickets=order_tickets, - ) - return await make_response(html) - return bp diff --git a/cart/bp/cart/page_routes.py b/cart/bp/cart/page_routes.py index 6526093..729fc57 100644 --- a/cart/bp/cart/page_routes.py +++ b/cart/bp/cart/page_routes.py @@ -5,8 +5,7 @@ from __future__ import annotations from quart import Blueprint, g, render_template, redirect, make_response, url_for from shared.browser.app.utils.htmx import is_htmx_request -from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout -from shared.config import config +from shared.infrastructure.actions import call_action from .services import ( total, calendar_total, @@ -14,12 +13,6 @@ from .services import ( ) 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 ( - create_order_from_cart, - build_sumup_description, - build_sumup_reference, - build_webhook_url, -) from .services import current_cart_identity @@ -56,7 +49,6 @@ def register(url_prefix: str) -> Blueprint: @bp.post("/checkout/") async def page_checkout(): post = g.page_post - page_config = getattr(g, "page_config", None) cart = await get_cart_for_page(g.s, post.id) cal_entries = await get_calendar_entries_for_page(g.s, post.id) @@ -65,61 +57,51 @@ def register(url_prefix: str) -> Blueprint: if not cart and not cal_entries and not page_tickets: return redirect(url_for("page_cart.page_view")) - product_total = total(cart) or 0 + product_total_val = total(cart) or 0 calendar_amount = calendar_total(cal_entries) or 0 ticket_amount = ticket_total(page_tickets) or 0 - cart_total = product_total + calendar_amount + ticket_amount + cart_total = product_total_val + calendar_amount + ticket_amount if cart_total <= 0: return redirect(url_for("page_cart.page_view")) - # Create order scoped to this page ident = current_cart_identity() - order = await create_order_from_cart( - g.s, - cart, - cal_entries, - ident.get("user_id"), - ident.get("session_id"), - product_total, - calendar_amount, - ticket_total=ticket_amount, - page_post_id=post.id, - ) - # Set page_config on order - if page_config: - order.page_config_id = page_config.id + # Serialize cart items for the orders service + cart_items_data = [] + for ci in cart: + cart_items_data.append({ + "product_id": ci.product_id, + "product_title": ci.product_title, + "product_slug": ci.product_slug, + "product_image": ci.product_image, + "product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None, + "product_special_price": float(ci.product_special_price) if ci.product_special_price else None, + "product_price_currency": ci.product_price_currency, + "quantity": ci.quantity, + }) - # Build SumUp checkout details — webhook/return use global routes - 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) - description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets)) + cal_data = [{"id": e.id, "calendar_container_id": getattr(e, "calendar_container_id", None)} for e in cal_entries] + ticket_data = [{"id": t.id, "calendar_container_id": getattr(t, "calendar_container_id", None)} for t in page_tickets] - webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True) - webhook_url = build_webhook_url(webhook_base_url) + result = await call_action("orders", "create-order", payload={ + "cart_items": cart_items_data, + "calendar_entries": cal_data, + "tickets": ticket_data, + "user_id": ident.get("user_id"), + "session_id": ident.get("session_id"), + "product_total": float(product_total_val), + "calendar_total": float(calendar_amount), + "ticket_total": float(ticket_amount), + "page_post_id": post.id, + }) - checkout_data = await sumup_create_checkout( - order, - redirect_url=redirect_url, - webhook_url=webhook_url, - description=description, - page_config=page_config, - ) - 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() + hosted_url = result.get("sumup_hosted_url") if not hosted_url: html = await render_template( "_types/cart/checkout_error.html", - order=order, + order=None, error="No hosted checkout URL returned from SumUp.", ) return await make_response(html, 500) diff --git a/cart/bp/cart/services/__init__.py b/cart/bp/cart/services/__init__.py index 8ba68b4..93b0215 100644 --- a/cart/bp/cart/services/__init__.py +++ b/cart/bp/cart/services/__init__.py @@ -1,9 +1,7 @@ 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 .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total -from .check_sumup_status import check_sumup_status from .page_cart import ( get_cart_for_page, get_calendar_entries_for_page, diff --git a/cart/bp/cart/services/page_cart.py b/cart/bp/cart/services/page_cart.py index efb9232..cf20f8a 100644 --- a/cart/bp/cart/services/page_cart.py +++ b/cart/bp/cart/services/page_cart.py @@ -178,7 +178,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]: p = dto_from_dict(PostDTO, raw_p) posts_by_id[p.id] = p - raw_pcs = await fetch_data("cart", "page-configs-batch", + raw_pcs = await fetch_data("blog", "page-configs-batch", params={"container_type": "page", "ids": ",".join(str(i) for i in post_ids)}, required=False) or [] diff --git a/cart/bp/data/routes.py b/cart/bp/data/routes.py index b7e42df..dbaa45d 100644 --- a/cart/bp/data/routes.py +++ b/cart/bp/data/routes.py @@ -45,41 +45,6 @@ def register() -> Blueprint: _handlers["cart-summary"] = _cart_summary - # --- page-config-ensure --- - async def _page_config_ensure(): - """Get or create a PageConfig for a container_type + container_id.""" - from sqlalchemy import select - from shared.models.page_config import PageConfig - - container_type = request.args.get("container_type", "page") - container_id = request.args.get("container_id", type=int) - if container_id is None: - return {"error": "container_id required"}, 400 - - row = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == container_type, - PageConfig.container_id == container_id, - ) - )).scalar_one_or_none() - - if row is None: - row = PageConfig( - container_type=container_type, - container_id=container_id, - features={}, - ) - g.s.add(row) - await g.s.flush() - - return { - "id": row.id, - "container_type": row.container_type, - "container_id": row.container_id, - } - - _handlers["page-config-ensure"] = _page_config_ensure - # --- cart-items (product slugs + quantities for template rendering) --- async def _cart_items(): from sqlalchemy import select @@ -111,103 +76,4 @@ def register() -> Blueprint: _handlers["cart-items"] = _cart_items - # --- page-config --- - async def _page_config(): - """Return a single PageConfig by container_type + container_id.""" - from sqlalchemy import select - from shared.models.page_config import PageConfig - - ct = request.args.get("container_type", "page") - cid = request.args.get("container_id", type=int) - if cid is None: - return None - pc = (await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == ct, - PageConfig.container_id == cid, - ) - )).scalar_one_or_none() - if not pc: - return None - return _page_config_dict(pc) - - _handlers["page-config"] = _page_config - - # --- page-config-by-id --- - async def _page_config_by_id(): - """Return a single PageConfig by its primary key.""" - from shared.models.page_config import PageConfig - - pc_id = request.args.get("id", type=int) - if pc_id is None: - return None - pc = await g.s.get(PageConfig, pc_id) - if not pc: - return None - return _page_config_dict(pc) - - _handlers["page-config-by-id"] = _page_config_by_id - - # --- page-configs-batch --- - async def _page_configs_batch(): - """Return PageConfigs for multiple container_ids (comma-separated).""" - from sqlalchemy import select - from shared.models.page_config import PageConfig - - ct = request.args.get("container_type", "page") - ids_raw = request.args.get("ids", "") - if not ids_raw: - return [] - ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()] - if not ids: - return [] - result = await g.s.execute( - select(PageConfig).where( - PageConfig.container_type == ct, - PageConfig.container_id.in_(ids), - ) - ) - return [_page_config_dict(pc) for pc in result.scalars().all()] - - _handlers["page-configs-batch"] = _page_configs_batch - - # --- get-children --- - async def _get_children(): - """Return ContainerRelation children for a parent.""" - from shared.services.relationships import get_children - - parent_type = request.args.get("parent_type", "") - parent_id = request.args.get("parent_id", type=int) - child_type = request.args.get("child_type") - if not parent_type or parent_id is None: - return [] - rels = await get_children(g.s, parent_type, parent_id, child_type) - return [ - { - "id": r.id, - "parent_type": r.parent_type, - "parent_id": r.parent_id, - "child_type": r.child_type, - "child_id": r.child_id, - "sort_order": r.sort_order, - "label": r.label, - } - for r in rels - ] - - _handlers["get-children"] = _get_children - return bp - - -def _page_config_dict(pc) -> dict: - """Serialize PageConfig to a JSON-safe dict.""" - return { - "id": pc.id, - "container_type": pc.container_type, - "container_id": pc.container_id, - "features": pc.features or {}, - "sumup_merchant_code": pc.sumup_merchant_code, - "sumup_api_key": pc.sumup_api_key, - "sumup_checkout_prefix": pc.sumup_checkout_prefix, - } diff --git a/cart/models/__init__.py b/cart/models/__init__.py index 508c4b0..e69de29 100644 --- a/cart/models/__init__.py +++ b/cart/models/__init__.py @@ -1,2 +0,0 @@ -from .order import Order, OrderItem -from .page_config import PageConfig diff --git a/deploy.sh b/deploy.sh index c671696..bdbebcc 100755 --- a/deploy.sh +++ b/deploy.sh @@ -2,7 +2,7 @@ set -euo pipefail REGISTRY="registry.rose-ash.com:5000" -APPS="blog market cart events federation account" +APPS="blog market cart events federation account relations likes orders" usage() { echo "Usage: deploy.sh [app ...]" diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 70ea989..7d894d3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,6 +25,12 @@ x-sibling-models: &sibling-models - ./federation/models:/app/federation/models:ro - ./account/__init__.py:/app/account/__init__.py:ro - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro services: blog: @@ -55,6 +61,12 @@ services: - ./federation/models:/app/federation/models:ro - ./account/__init__.py:/app/account/__init__.py:ro - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro market: ports: @@ -85,6 +97,12 @@ services: - ./federation/models:/app/federation/models:ro - ./account/__init__.py:/app/account/__init__.py:ro - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro cart: ports: @@ -114,6 +132,12 @@ services: - ./federation/models:/app/federation/models:ro - ./account/__init__.py:/app/account/__init__.py:ro - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro events: ports: @@ -143,6 +167,12 @@ services: - ./federation/models:/app/federation/models:ro - ./account/__init__.py:/app/account/__init__.py:ro - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro federation: ports: @@ -172,6 +202,12 @@ services: - ./events/models:/app/events/models:ro - ./account/__init__.py:/app/account/__init__.py:ro - ./account/models:/app/account/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro account: ports: @@ -201,6 +237,103 @@ services: - ./events/models:/app/events/models:ro - ./federation/__init__.py:/app/federation/__init__.py:ro - ./federation/models:/app/federation/models:ro + - ./relations/__init__.py:/app/relations/__init__.py:ro + - ./relations/models:/app/relations/models:ro + - ./likes/__init__.py:/app/likes/__init__.py:ro + - ./likes/models:/app/likes/models:ro + - ./orders/__init__.py:/app/orders/__init__.py:ro + - ./orders/models:/app/orders/models:ro + + relations: + ports: + - "8008:8000" + environment: + <<: *dev-env + volumes: + - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro + - ./shared:/app/shared + - ./relations/alembic.ini:/app/relations/alembic.ini:ro + - ./relations/alembic:/app/relations/alembic:ro + - ./relations/app.py:/app/app.py + - ./relations/bp:/app/bp + - ./relations/services:/app/services + - ./relations/models:/app/models + - ./relations/path_setup.py:/app/path_setup.py + - ./relations/entrypoint.sh:/usr/local/bin/entrypoint.sh + # sibling models + - ./blog/__init__.py:/app/blog/__init__.py:ro + - ./blog/models:/app/blog/models:ro + - ./market/__init__.py:/app/market/__init__.py:ro + - ./market/models:/app/market/models:ro + - ./cart/__init__.py:/app/cart/__init__.py:ro + - ./cart/models:/app/cart/models:ro + - ./events/__init__.py:/app/events/__init__.py:ro + - ./events/models:/app/events/models:ro + - ./federation/__init__.py:/app/federation/__init__.py:ro + - ./federation/models:/app/federation/models:ro + - ./account/__init__.py:/app/account/__init__.py:ro + - ./account/models:/app/account/models:ro + + likes: + ports: + - "8009:8000" + environment: + <<: *dev-env + volumes: + - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro + - ./shared:/app/shared + - ./likes/alembic.ini:/app/likes/alembic.ini:ro + - ./likes/alembic:/app/likes/alembic:ro + - ./likes/app.py:/app/app.py + - ./likes/bp:/app/bp + - ./likes/services:/app/services + - ./likes/models:/app/models + - ./likes/path_setup.py:/app/path_setup.py + - ./likes/entrypoint.sh:/usr/local/bin/entrypoint.sh + # sibling models + - ./blog/__init__.py:/app/blog/__init__.py:ro + - ./blog/models:/app/blog/models:ro + - ./market/__init__.py:/app/market/__init__.py:ro + - ./market/models:/app/market/models:ro + - ./cart/__init__.py:/app/cart/__init__.py:ro + - ./cart/models:/app/cart/models:ro + - ./events/__init__.py:/app/events/__init__.py:ro + - ./events/models:/app/events/models:ro + - ./federation/__init__.py:/app/federation/__init__.py:ro + - ./federation/models:/app/federation/models:ro + - ./account/__init__.py:/app/account/__init__.py:ro + - ./account/models:/app/account/models:ro + + orders: + ports: + - "8010:8000" + environment: + <<: *dev-env + volumes: + - /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro + - ./shared:/app/shared + - ./orders/alembic.ini:/app/orders/alembic.ini:ro + - ./orders/alembic:/app/orders/alembic:ro + - ./orders/app.py:/app/app.py + - ./orders/bp:/app/bp + - ./orders/services:/app/services + - ./orders/templates:/app/templates + - ./orders/models:/app/models + - ./orders/path_setup.py:/app/path_setup.py + - ./orders/entrypoint.sh:/usr/local/bin/entrypoint.sh + # sibling models + - ./blog/__init__.py:/app/blog/__init__.py:ro + - ./blog/models:/app/blog/models:ro + - ./market/__init__.py:/app/market/__init__.py:ro + - ./market/models:/app/market/models:ro + - ./cart/__init__.py:/app/cart/__init__.py:ro + - ./cart/models:/app/cart/models:ro + - ./events/__init__.py:/app/events/__init__.py:ro + - ./events/models:/app/events/models:ro + - ./federation/__init__.py:/app/federation/__init__.py:ro + - ./federation/models:/app/federation/models:ro + - ./account/__init__.py:/app/account/__init__.py:ro + - ./account/models:/app/account/models:ro networks: appnet: diff --git a/docker-compose.yml b/docker-compose.yml index f7ecce1..6e11b1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,9 @@ x-app-env: &app-env APP_URL_EVENTS: https://events.rose-ash.com APP_URL_FEDERATION: https://federation.rose-ash.com APP_URL_ACCOUNT: https://account.rose-ash.com + APP_URL_ORDERS: https://orders.rose-ash.com + APP_URL_RELATIONS: http://relations:8000 + APP_URL_LIKES: http://likes:8000 APP_URL_ARTDAG: https://celery-artdag.rose-ash.com APP_URL_ARTDAG_L2: https://artdag.rose-ash.com INTERNAL_URL_BLOG: http://blog:8000 @@ -40,6 +43,9 @@ x-app-env: &app-env INTERNAL_URL_EVENTS: http://events:8000 INTERNAL_URL_FEDERATION: http://federation:8000 INTERNAL_URL_ACCOUNT: http://account:8000 + INTERNAL_URL_ORDERS: http://orders:8000 + INTERNAL_URL_RELATIONS: http://relations:8000 + INTERNAL_URL_LIKES: http://likes:8000 INTERNAL_URL_ARTDAG: http://l1-server:8100 AP_DOMAIN: federation.rose-ash.com AP_DOMAIN_BLOG: blog.rose-ash.com @@ -147,6 +153,54 @@ services: RUN_MIGRATIONS: "true" WORKERS: "1" + relations: + <<: *app-common + image: registry.rose-ash.com:5000/relations:latest + build: + context: . + dockerfile: relations/Dockerfile + environment: + <<: *app-env + DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_relations + ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_relations + REDIS_URL: redis://redis:6379/6 + DATABASE_HOST: db + DATABASE_PORT: "5432" + RUN_MIGRATIONS: "true" + WORKERS: "1" + + likes: + <<: *app-common + image: registry.rose-ash.com:5000/likes:latest + build: + context: . + dockerfile: likes/Dockerfile + environment: + <<: *app-env + DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_likes + ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_likes + REDIS_URL: redis://redis:6379/7 + DATABASE_HOST: db + DATABASE_PORT: "5432" + RUN_MIGRATIONS: "true" + WORKERS: "1" + + orders: + <<: *app-common + image: registry.rose-ash.com:5000/orders:latest + build: + context: . + dockerfile: orders/Dockerfile + environment: + <<: *app-env + DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_orders + ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_orders + REDIS_URL: redis://redis:6379/8 + DATABASE_HOST: db + DATABASE_PORT: "5432" + RUN_MIGRATIONS: "true" + WORKERS: "1" + db: image: postgres:16 environment: diff --git a/events/Dockerfile b/events/Dockerfile index 90c5ad9..034c3a2 100644 --- a/events/Dockerfile +++ b/events/Dockerfile @@ -37,6 +37,12 @@ COPY federation/__init__.py ./federation/__init__.py COPY federation/models/ ./federation/models/ COPY account/__init__.py ./account/__init__.py COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ # ---------- Runtime setup ---------- COPY events/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/events/bp/calendars/services/calendars.py b/events/bp/calendars/services/calendars.py index 065f4f1..cd7c54c 100644 --- a/events/bp/calendars/services/calendars.py +++ b/events/bp/calendars/services/calendars.py @@ -71,7 +71,7 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> cal.deleted_at = utcnow() await sess.flush() - await call_action("cart", "detach-child", payload={ + await call_action("relations", "detach-child", payload={ "parent_type": "page", "parent_id": cal.container_id, "child_type": "calendar", "child_id": cal.id, }) @@ -108,7 +108,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend if existing.deleted_at is not None: existing.deleted_at = None # revive await sess.flush() - await call_action("cart", "attach-child", payload={ + await call_action("relations", "attach-child", payload={ "parent_type": "page", "parent_id": post_id, "child_type": "calendar", "child_id": existing.id, }) @@ -118,7 +118,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug) sess.add(cal) await sess.flush() - await call_action("cart", "attach-child", payload={ + await call_action("relations", "attach-child", payload={ "parent_type": "page", "parent_id": post_id, "child_type": "calendar", "child_id": cal.id, }) diff --git a/federation/Dockerfile b/federation/Dockerfile index e961f11..e6f5d7d 100644 --- a/federation/Dockerfile +++ b/federation/Dockerfile @@ -38,6 +38,12 @@ COPY events/__init__.py ./events/__init__.py COPY events/models/ ./events/models/ COPY account/__init__.py ./account/__init__.py COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ # ---------- Runtime setup ---------- COPY federation/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/likes/Dockerfile b/likes/Dockerfile new file mode 100644 index 0000000..a7d40be --- /dev/null +++ b/likes/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PIP_NO_CACHE_DIR=1 \ + APP_PORT=8000 \ + APP_MODULE=app:app + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY shared/requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +# Shared code +COPY shared/ ./shared/ + +# App code +COPY likes/ ./ + +# Sibling models for cross-domain SQLAlchemy imports +COPY blog/__init__.py ./blog/__init__.py +COPY blog/models/ ./blog/models/ +COPY market/__init__.py ./market/__init__.py +COPY market/models/ ./market/models/ +COPY cart/__init__.py ./cart/__init__.py +COPY cart/models/ ./cart/models/ +COPY events/__init__.py ./events/__init__.py +COPY events/models/ ./events/models/ +COPY federation/__init__.py ./federation/__init__.py +COPY federation/models/ ./federation/models/ +COPY account/__init__.py ./account/__init__.py +COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ + +COPY likes/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/likes/__init__.py b/likes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/likes/alembic.ini b/likes/alembic.ini new file mode 100644 index 0000000..a04e071 --- /dev/null +++ b/likes/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/likes/alembic/env.py b/likes/alembic/env.py new file mode 100644 index 0000000..e27bd11 --- /dev/null +++ b/likes/alembic/env.py @@ -0,0 +1,12 @@ +from alembic import context +from shared.db.alembic_env import run_alembic + +MODELS = [ + "likes.models.like", +] + +TABLES = frozenset({ + "likes", +}) + +run_alembic(context.config, MODELS, TABLES) diff --git a/likes/alembic/versions/0001_initial.py b/likes/alembic/versions/0001_initial.py new file mode 100644 index 0000000..ba179c6 --- /dev/null +++ b/likes/alembic/versions/0001_initial.py @@ -0,0 +1,47 @@ +"""Initial likes tables + +Revision ID: likes_0001 +Revises: None +Create Date: 2026-02-27 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "likes_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def _table_exists(conn, name): + result = conn.execute(sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t" + ), {"t": name}) + return result.scalar() is not None + + +def upgrade(): + if _table_exists(op.get_bind(), "likes"): + return + + op.create_table( + "likes", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer, nullable=False, index=True), + sa.Column("target_type", sa.String(32), nullable=False), + sa.Column("target_slug", sa.String(255), nullable=True), + sa.Column("target_id", sa.Integer, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint("user_id", "target_type", "target_slug", + name="uq_likes_user_type_slug"), + sa.UniqueConstraint("user_id", "target_type", "target_id", + name="uq_likes_user_type_id"), + ) + op.create_index("ix_likes_target", "likes", ["target_type", "target_slug"]) + + +def downgrade(): + op.drop_table("likes") diff --git a/likes/app.py b/likes/app.py new file mode 100644 index 0000000..6e74bf7 --- /dev/null +++ b/likes/app.py @@ -0,0 +1,22 @@ +from __future__ import annotations +import path_setup # noqa: F401 + +from shared.infrastructure.factory import create_base_app + +from bp import register_actions, register_data +from services import register_domain_services + + +def create_app() -> "Quart": + app = create_base_app( + "likes", + domain_services_fn=register_domain_services, + ) + + app.register_blueprint(register_actions()) + app.register_blueprint(register_data()) + + return app + + +app = create_app() diff --git a/likes/bp/__init__.py b/likes/bp/__init__.py new file mode 100644 index 0000000..7122ccd --- /dev/null +++ b/likes/bp/__init__.py @@ -0,0 +1,2 @@ +from .data.routes import register as register_data +from .actions.routes import register as register_actions diff --git a/likes/bp/actions/__init__.py b/likes/bp/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/likes/bp/actions/routes.py b/likes/bp/actions/routes.py new file mode 100644 index 0000000..ab05051 --- /dev/null +++ b/likes/bp/actions/routes.py @@ -0,0 +1,81 @@ +"""Likes app action endpoints.""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.actions import ACTION_HEADER + + +def register() -> Blueprint: + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.post("/") + async def handle_action(action_name: str): + handler = _handlers.get(action_name) + if handler is None: + return jsonify({"error": "unknown action"}), 404 + try: + result = await handler() + return jsonify(result) + except Exception as exc: + import logging + logging.getLogger(__name__).exception("Action %s failed", action_name) + return jsonify({"error": str(exc)}), 500 + + # --- toggle --- + async def _toggle(): + """Toggle a like. Returns {"liked": bool}.""" + from sqlalchemy import select, update, func + from likes.models.like import Like + + data = await request.get_json(force=True) + user_id = data["user_id"] + target_type = data["target_type"] + target_slug = data.get("target_slug") + target_id = data.get("target_id") + + filters = [ + Like.user_id == user_id, + Like.target_type == target_type, + Like.deleted_at.is_(None), + ] + if target_slug is not None: + filters.append(Like.target_slug == target_slug) + elif target_id is not None: + filters.append(Like.target_id == target_id) + else: + return {"error": "target_slug or target_id required"}, 400 + + existing = await g.s.scalar(select(Like).where(*filters)) + + if existing: + # Unlike: soft delete + await g.s.execute( + update(Like).where(Like.id == existing.id).values(deleted_at=func.now()) + ) + return {"liked": False} + else: + # Like: insert new + new_like = Like( + user_id=user_id, + target_type=target_type, + target_slug=target_slug, + target_id=target_id, + ) + g.s.add(new_like) + await g.s.flush() + return {"liked": True} + + _handlers["toggle"] = _toggle + + return bp diff --git a/likes/bp/data/__init__.py b/likes/bp/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/likes/bp/data/routes.py b/likes/bp/data/routes.py new file mode 100644 index 0000000..196ae66 --- /dev/null +++ b/likes/bp/data/routes.py @@ -0,0 +1,109 @@ +"""Likes app data endpoints.""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + # --- is-liked --- + async def _is_liked(): + """Check if a user has liked a specific target.""" + from sqlalchemy import select + from likes.models.like import Like + + user_id = request.args.get("user_id", type=int) + target_type = request.args.get("target_type", "") + target_slug = request.args.get("target_slug") + target_id = request.args.get("target_id", type=int) + + if not user_id or not target_type: + return {"liked": False} + + filters = [ + Like.user_id == user_id, + Like.target_type == target_type, + Like.deleted_at.is_(None), + ] + if target_slug is not None: + filters.append(Like.target_slug == target_slug) + elif target_id is not None: + filters.append(Like.target_id == target_id) + else: + return {"liked": False} + + row = await g.s.scalar(select(Like.id).where(*filters)) + return {"liked": row is not None} + + _handlers["is-liked"] = _is_liked + + # --- liked-slugs --- + async def _liked_slugs(): + """Return all liked target_slugs for a user + target_type.""" + from sqlalchemy import select + from likes.models.like import Like + + user_id = request.args.get("user_id", type=int) + target_type = request.args.get("target_type", "") + + if not user_id or not target_type: + return [] + + result = await g.s.execute( + select(Like.target_slug).where( + Like.user_id == user_id, + Like.target_type == target_type, + Like.target_slug.isnot(None), + Like.deleted_at.is_(None), + ) + ) + return list(result.scalars().all()) + + _handlers["liked-slugs"] = _liked_slugs + + # --- liked-ids --- + async def _liked_ids(): + """Return all liked target_ids for a user + target_type.""" + from sqlalchemy import select + from likes.models.like import Like + + user_id = request.args.get("user_id", type=int) + target_type = request.args.get("target_type", "") + + if not user_id or not target_type: + return [] + + result = await g.s.execute( + select(Like.target_id).where( + Like.user_id == user_id, + Like.target_type == target_type, + Like.target_id.isnot(None), + Like.deleted_at.is_(None), + ) + ) + return list(result.scalars().all()) + + _handlers["liked-ids"] = _liked_ids + + return bp diff --git a/likes/entrypoint.sh b/likes/entrypoint.sh new file mode 100644 index 0000000..8ee3e61 --- /dev/null +++ b/likes/entrypoint.sh @@ -0,0 +1,61 @@ +#!/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 + +# Create own database + run own migrations +if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then + python3 -c " +import os, re +url = os.environ['ALEMBIC_DATABASE_URL'] +m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url) +if not m: + print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation') + exit(0) +user, password, host, port, dbname = m.groups() + +import psycopg +conn = psycopg.connect( + f'postgresql://{user}:{password}@{host}:{port}/postgres', + autocommit=True, +) +cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,)) +if not cur.fetchone(): + conn.execute(f'CREATE DATABASE {dbname}') + print(f'Created database {dbname}') +else: + print(f'Database {dbname} already exists') +conn.close() +" || echo "DB creation failed (non-fatal), continuing..." + + echo "Running likes Alembic migrations..." + if [ -d likes ]; then (cd likes && alembic upgrade head); else alembic upgrade head; fi +fi + +# 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.flushdb() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +RELOAD_FLAG="" +if [[ "${RELOAD:-}" == "true" ]]; then + RELOAD_FLAG="--reload" + echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." +else + echo "Starting Hypercorn (${APP_MODULE:-app:app})..." +fi +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG} diff --git a/likes/models/__init__.py b/likes/models/__init__.py new file mode 100644 index 0000000..3cf9109 --- /dev/null +++ b/likes/models/__init__.py @@ -0,0 +1 @@ +from .like import Like diff --git a/likes/models/like.py b/likes/models/like.py new file mode 100644 index 0000000..13f7872 --- /dev/null +++ b/likes/models/like.py @@ -0,0 +1,36 @@ +from datetime import datetime +from typing import Optional + +from sqlalchemy import Integer, String, DateTime, Index, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from shared.db.base import Base + + +class Like(Base): + __tablename__ = "likes" + + __table_args__ = ( + UniqueConstraint("user_id", "target_type", "target_slug", + name="uq_likes_user_type_slug"), + UniqueConstraint("user_id", "target_type", "target_id", + name="uq_likes_user_type_id"), + Index("ix_likes_target", "target_type", "target_slug"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True) + target_type: Mapped[str] = mapped_column(String(32), nullable=False) + target_slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) + target_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), nullable=False, + ) + deleted_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, + ) diff --git a/likes/path_setup.py b/likes/path_setup.py new file mode 100644 index 0000000..c7166f7 --- /dev/null +++ b/likes/path_setup.py @@ -0,0 +1,9 @@ +import sys +import os + +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p) diff --git a/likes/services/__init__.py b/likes/services/__init__.py new file mode 100644 index 0000000..be93bf2 --- /dev/null +++ b/likes/services/__init__.py @@ -0,0 +1,6 @@ +"""Likes app service registration.""" +from __future__ import annotations + + +def register_domain_services() -> None: + """Register services for the likes app.""" diff --git a/market/Dockerfile b/market/Dockerfile index 836fa1c..50ade79 100644 --- a/market/Dockerfile +++ b/market/Dockerfile @@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py COPY federation/models/ ./federation/models/ COPY account/__init__.py ./account/__init__.py COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ # ---------- Runtime setup ---------- COPY market/entrypoint.sh /usr/local/bin/entrypoint.sh diff --git a/market/alembic/env.py b/market/alembic/env.py index 72c13b8..b6b2ec9 100644 --- a/market/alembic/env.py +++ b/market/alembic/env.py @@ -9,7 +9,7 @@ MODELS = [ TABLES = frozenset({ "products", "product_images", "product_sections", "product_labels", "product_stickers", "product_attributes", "product_nutrition", - "product_allergens", "product_likes", + "product_allergens", "market_places", "nav_tops", "nav_subs", "listings", "listing_items", "link_errors", "link_externals", "subcategory_redirects", "product_logs", diff --git a/market/bp/browse/services/db_backend.py b/market/bp/browse/services/db_backend.py index dab83b2..b8dd7ea 100644 --- a/market/bp/browse/services/db_backend.py +++ b/market/bp/browse/services/db_backend.py @@ -12,9 +12,9 @@ from models.market import ( Listing, ListingItem, NavTop, NavSub, ProductSticker, ProductLabel, - ProductAttribute, ProductNutrition, ProductAllergen, ProductLike - + ProductAttribute, ProductNutrition, ProductAllergen, ) +from shared.infrastructure.data_client import fetch_data from sqlalchemy import func, case @@ -72,26 +72,8 @@ async def db_nav(session, market_id=None) -> Dict: async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]: - liked_product_ids_subq = ( - select(ProductLike.product_slug) - .where( - and_( - ProductLike.user_id == user_id, - ProductLike.deleted_at.is_(None) - ) - ) - ) - - is_liked_case = case( - (and_( - (Product.slug.in_(liked_product_ids_subq)), - Product.deleted_at.is_(None) - ), True), - else_=False - ).label("is_liked") - q = ( - select(Product, is_liked_case) + select(Product) .where(Product.slug == slug, Product.deleted_at.is_(None)) .options( selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))), @@ -105,11 +87,17 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]: ) result = await session.execute(q) - row = result.first() if result is not None else None - p, is_liked = row if row else (None, None) + p = result.scalars().first() if not p: return None + is_liked = False + if user_id: + liked_data = await fetch_data("likes", "is-liked", params={ + "user_id": user_id, "target_type": "product", "target_slug": slug, + }, required=False) + is_liked = (liked_data or {}).get("liked", False) + gallery = [ img.url for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0)) @@ -170,26 +158,9 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]: async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]: - liked_product_ids_subq = ( - select(ProductLike.product_slug) - .where( - and_( - ProductLike.user_id == user_id, - ProductLike.deleted_at.is_(None) - ) - ) - ) - - is_liked_case = case( - ( - (Product.slug.in_(liked_product_ids_subq)), - True - ), - else_=False - ).label("is_liked") q = ( - select(Product, is_liked_case) + select(Product) .where(Product.id == id) .options( selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))), @@ -203,11 +174,17 @@ async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]: ) result = await session.execute(q) - row = result.first() if result is not None else None - p, is_liked = row if row else (None, None) + p = result.scalars().first() if not p: return None + is_liked = False + if user_id: + liked_data = await fetch_data("likes", "is-liked", params={ + "user_id": user_id, "target_type": "product", "target_slug": p.slug, + }, required=False) + is_liked = (liked_data or {}).get("liked", False) + gallery = [ img.url for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0)) @@ -367,40 +344,25 @@ async def db_products_nocounts( ) if search_q: filter_conditions.append(func.lower(Product.description_short).contains(search_q)) + # Fetch liked slugs from likes service (once) + liked_slugs_set: set[str] = set() + if user_id and (liked or True): + liked_slugs_list = await fetch_data("likes", "liked-slugs", params={ + "user_id": user_id, "target_type": "product", + }, required=False) or [] + liked_slugs_set = set(liked_slugs_list) + if liked: - liked_subq = liked_subq = ( - select(ProductLike.product_slug) - .where( - and_( - ProductLike.user_id == user_id, - ProductLike.deleted_at.is_(None) - ) - ) - .subquery() - ) - filter_conditions.append(Product.slug.in_(liked_subq)) - + if not liked_slugs_set: + return {"total_pages": 1, "items": []} + filter_conditions.append(Product.slug.in_(liked_slugs_set)) + filtered_count_query = select(func.count(Product.id)).where(Product.id.in_(base_ids), *filter_conditions) total_filtered = (await session.execute(filtered_count_query)).scalars().one() total_pages = max(1, (total_filtered + page_size - 1) // page_size) page = max(1, page) - - liked_product_slugs_subq = ( - select(ProductLike.product_slug) - .where( - and_( - ProductLike.user_id == user_id, - ProductLike.deleted_at.is_(None) - ) - ) - ) - is_liked_case = case( - (Product.slug.in_(liked_product_slugs_subq), True), - else_=False - ).label("is_liked") - - q_filtered = select(Product, is_liked_case).where(Product.id.in_(base_ids), *filter_conditions).options( + q_filtered = select(Product).where(Product.id.in_(base_ids), *filter_conditions).options( selectinload(Product.images), selectinload(Product.sections), selectinload(Product.labels), @@ -434,10 +396,11 @@ async def db_products_nocounts( offset_val = (page - 1) * page_size q_filtered = q_filtered.offset(offset_val).limit(page_size) - products_page = (await session.execute(q_filtered)).all() + products_page = (await session.execute(q_filtered)).scalars().all() items: List[Dict] = [] - for p, is_liked in products_page: + for p in products_page: + is_liked = p.slug in liked_slugs_set gallery_imgs = sorted((img for img in p.images), key=lambda i: (i.kind or "gallery", i.position or 0)) gallery = [img.url for img in gallery_imgs if (img.kind or "gallery") == "gallery"] embedded = [img.url for img in sorted(p.images, key=lambda i: i.position or 0) if (img.kind or "") == "embedded"] @@ -580,30 +543,14 @@ async def db_products_counts( labels_list: List[Dict] = [] liked_count = 0 search_count = 0 - liked_product_slugs_subq = ( - select(ProductLike.product_slug) - .where(ProductLike.user_id == user_id, ProductLike.deleted_at.is_(None)) - ) - liked_count = await session.scalar( - select(func.count(Product.id)) - .where( - Product.id.in_(base_ids), - Product.slug.in_(liked_product_slugs_subq), - Product.deleted_at.is_(None) - ) - ) - - liked_count = (await session.execute( - select(func.count()) - .select_from(ProductLike) - .where( - ProductLike.user_id == user_id, - ProductLike.product_slug.in_( - select(Product.slug).where(Product.id.in_(base_ids)) - ), - ProductLike.deleted_at.is_(None) - ) - )).scalar_one() if user_id else 0 + if user_id: + liked_slugs_list = await fetch_data("likes", "liked-slugs", params={ + "user_id": user_id, "target_type": "product", + }, required=False) or [] + liked_slugs_in_base = set(liked_slugs_list) & set(base_products_slugs) + liked_count = len(liked_slugs_in_base) + else: + liked_count = 0 # Brand counts brand_count_rows = await session.execute( diff --git a/market/bp/browse/services/services.py b/market/bp/browse/services/services.py index dbdcaad..cadc90c 100644 --- a/market/bp/browse/services/services.py +++ b/market/bp/browse/services/services.py @@ -13,8 +13,7 @@ from .blacklist.product_details import is_blacklisted_heading from shared.utils import host_url -from sqlalchemy import select -from models import ProductLike +from shared.infrastructure.data_client import fetch_data from ...market.filters.qs import decode @@ -171,15 +170,9 @@ async def _is_liked(user_id: int | None, slug: str) -> bool: """ if not user_id: return False - # because ProductLike has composite PK (user_id, product_slug), - # we can fetch it by primary key dict: - row = await g.s.execute( - select(ProductLike).where( - ProductLike.user_id == user_id, - ProductLike.product_slug == slug, - ) - ) - row.scalar_one_or_none() - return row is not None + liked_data = await fetch_data("likes", "is-liked", params={ + "user_id": user_id, "target_type": "product", "target_slug": slug, + }, required=False) + return (liked_data or {}).get("liked", False) diff --git a/market/bp/product/routes.py b/market/bp/product/routes.py index 56d4c72..bd92a56 100644 --- a/market/bp/product/routes.py +++ b/market/bp/product/routes.py @@ -10,7 +10,7 @@ from quart import ( ) from sqlalchemy import select, func, update -from models.market import Product, ProductLike +from models.market import Product from ..browse.services.slugs import canonical_html_slug from ..browse.services.blacklist.product import is_product_blocked from ..browse.services import db_backend as cb @@ -18,7 +18,8 @@ from ..browse.services import _massage_product from shared.utils import host_url from shared.browser.app.redis_cacher import cache_page, clear_cache from ..cart.services import total -from .services.product_operations import toggle_product_like, massage_full_product +from shared.infrastructure.actions import call_action +from .services.product_operations import massage_full_product def register(): @@ -132,11 +133,10 @@ def register(): user_id = g.user.id - liked, error = await toggle_product_like(g.s, user_id, product_slug) - - if error: - resp = make_response(error, 404) - return resp + result = await call_action("likes", "toggle", payload={ + "user_id": user_id, "target_type": "product", "target_slug": product_slug, + }) + liked = result["liked"] html = await render_template( "_types/browse/like/button.html", diff --git a/market/bp/product/services/product_operations.py b/market/bp/product/services/product_operations.py index 343be8e..c40be53 100644 --- a/market/bp/product/services/product_operations.py +++ b/market/bp/product/services/product_operations.py @@ -1,11 +1,6 @@ from __future__ import annotations -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from models.market import Product, ProductLike +from models.market import Product def massage_full_product(product: Product) -> dict: @@ -44,52 +39,3 @@ def massage_full_product(product: Product) -> dict: return _massage_product(d) -async def toggle_product_like( - session: AsyncSession, - user_id: int, - product_slug: str, -) -> tuple[bool, Optional[str]]: - """ - Toggle a product like for a given user using soft deletes. - Returns (liked_state, error_message). - - If error_message is not None, an error occurred. - - liked_state indicates whether product is now liked (True) or unliked (False). - """ - from sqlalchemy import func, update - - # Get product_id from slug - product_id = await session.scalar( - select(Product.id).where(Product.slug == product_slug, Product.deleted_at.is_(None)) - ) - if not product_id: - return False, "Product not found" - - # Check if like exists (not deleted) - existing = await session.scalar( - select(ProductLike).where( - ProductLike.user_id == user_id, - ProductLike.product_slug == product_slug, - ProductLike.deleted_at.is_(None), - ) - ) - - if existing: - # Unlike: soft delete the like - await session.execute( - update(ProductLike) - .where( - ProductLike.user_id == user_id, - ProductLike.product_slug == product_slug, - ProductLike.deleted_at.is_(None), - ) - .values(deleted_at=func.now()) - ) - return False, None - else: - # Like: add a new like - new_like = ProductLike( - user_id=user_id, - product_slug=product_slug, - ) - session.add(new_like) - return True, None diff --git a/market/models/__init__.py b/market/models/__init__.py index 9ca9e79..9033bba 100644 --- a/market/models/__init__.py +++ b/market/models/__init__.py @@ -1,5 +1,5 @@ from .market import ( - Product, ProductLike, ProductImage, ProductSection, + Product, ProductImage, ProductSection, NavTop, NavSub, Listing, ListingItem, LinkError, LinkExternal, SubcategoryRedirect, ProductLog, ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, diff --git a/market/models/market.py b/market/models/market.py index 65511e1..4c46b14 100644 --- a/market/models/market.py +++ b/market/models/market.py @@ -1,5 +1,5 @@ from shared.models.market import ( # noqa: F401 - Product, ProductLike, ProductImage, ProductSection, + Product, ProductImage, ProductSection, NavTop, NavSub, Listing, ListingItem, LinkError, LinkExternal, SubcategoryRedirect, ProductLog, ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, diff --git a/orders/Dockerfile b/orders/Dockerfile new file mode 100644 index 0000000..8b784f5 --- /dev/null +++ b/orders/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1 + +# ---------- Python application ---------- +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + 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 shared/requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +# Shared code (replaces submodule) +COPY shared/ ./shared/ + +# App code +COPY orders/ ./ + +# Sibling models for cross-domain SQLAlchemy imports +COPY blog/__init__.py ./blog/__init__.py +COPY blog/models/ ./blog/models/ +COPY market/__init__.py ./market/__init__.py +COPY market/models/ ./market/models/ +COPY cart/__init__.py ./cart/__init__.py +COPY cart/models/ ./cart/models/ +COPY events/__init__.py ./events/__init__.py +COPY events/models/ ./events/models/ +COPY federation/__init__.py ./federation/__init__.py +COPY federation/models/ ./federation/models/ +COPY account/__init__.py ./account/__init__.py +COPY account/models/ ./account/models/ +COPY relations/__init__.py ./relations/__init__.py +COPY relations/models/ ./relations/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ + +# ---------- Runtime setup ---------- +COPY orders/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/orders/__init__.py b/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/alembic.ini b/orders/alembic.ini new file mode 100644 index 0000000..a04e071 --- /dev/null +++ b/orders/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/orders/alembic/env.py b/orders/alembic/env.py new file mode 100644 index 0000000..bd3008c --- /dev/null +++ b/orders/alembic/env.py @@ -0,0 +1,12 @@ +from alembic import context +from shared.db.alembic_env import run_alembic + +MODELS = [ + "shared.models.order", +] + +TABLES = frozenset({ + "orders", "order_items", +}) + +run_alembic(context.config, MODELS, TABLES) diff --git a/orders/alembic/versions/0001_initial.py b/orders/alembic/versions/0001_initial.py new file mode 100644 index 0000000..a43009a --- /dev/null +++ b/orders/alembic/versions/0001_initial.py @@ -0,0 +1,67 @@ +"""Initial orders tables + +Revision ID: orders_0001 +Revises: None +Create Date: 2026-02-27 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "orders_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def _table_exists(conn, name): + result = conn.execute(sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t" + ), {"t": name}) + return result.scalar() is not None + + +def upgrade(): + if not _table_exists(op.get_bind(), "orders"): + op.create_table( + "orders", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer, nullable=True), + sa.Column("session_id", sa.String(64), nullable=True), + sa.Column("page_config_id", sa.Integer, nullable=True), + sa.Column("status", sa.String(32), nullable=False, server_default="pending"), + sa.Column("currency", sa.String(16), nullable=False, server_default="GBP"), + sa.Column("total_amount", sa.Numeric(12, 2), nullable=False), + sa.Column("description", sa.Text, nullable=True), + sa.Column("sumup_reference", sa.String(255), nullable=True), + sa.Column("sumup_checkout_id", sa.String(128), nullable=True), + sa.Column("sumup_status", sa.String(32), nullable=True), + sa.Column("sumup_hosted_url", sa.Text, nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_index("ix_orders_session_id", "orders", ["session_id"]) + op.create_index("ix_orders_page_config_id", "orders", ["page_config_id"]) + op.create_index("ix_orders_description", "orders", ["description"], postgresql_using="hash") + op.create_index("ix_orders_sumup_reference", "orders", ["sumup_reference"]) + op.create_index("ix_orders_sumup_checkout_id", "orders", ["sumup_checkout_id"]) + + if not _table_exists(op.get_bind(), "order_items"): + op.create_table( + "order_items", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("order_id", sa.Integer, sa.ForeignKey("orders.id", ondelete="CASCADE"), nullable=False), + sa.Column("product_id", sa.Integer, nullable=False), + sa.Column("product_title", sa.String(512), nullable=True), + sa.Column("product_slug", sa.String(512), nullable=True), + sa.Column("product_image", sa.Text, nullable=True), + sa.Column("quantity", sa.Integer, nullable=False, server_default="1"), + sa.Column("unit_price", sa.Numeric(12, 2), nullable=False), + sa.Column("currency", sa.String(16), nullable=False, server_default="GBP"), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + + +def downgrade(): + op.drop_table("order_items") + op.drop_table("orders") diff --git a/orders/app.py b/orders/app.py new file mode 100644 index 0000000..875255c --- /dev/null +++ b/orders/app.py @@ -0,0 +1,129 @@ +from __future__ import annotations +import path_setup # noqa: F401 # adds shared/ to sys.path + +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_orders, + register_order, + register_checkout, + register_fragments, + register_actions, + register_data, +) + + +async def orders_context() -> dict: + """Orders app context processor.""" + from shared.infrastructure.context import base_context + from shared.infrastructure.cart_identity import current_cart_identity + from shared.infrastructure.fragments import fetch_fragments + + ctx = await base_context() + ctx["menu_items"] = [] + + 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": "orders", "path": request.path}), + ]) + ctx["cart_mini_html"] = cart_mini_html + ctx["auth_menu_html"] = auth_menu_html + ctx["nav_tree_html"] = nav_tree_html + + 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 services import register_domain_services + + app = create_base_app( + "orders", + context_fn=orders_context, + 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.register_blueprint(register_fragments()) + app.register_blueprint(register_actions()) + app.register_blueprint(register_data()) + + # Orders list at / + app.register_blueprint(register_orders(url_prefix="/")) + + # Checkout webhook + return + app.register_blueprint(register_checkout()) + + # --- 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.""" + 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 services.check_sumup_status import check_sumup_status + + log = logging.getLogger("orders.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: + 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() diff --git a/orders/bp/__init__.py b/orders/bp/__init__.py new file mode 100644 index 0000000..590fe33 --- /dev/null +++ b/orders/bp/__init__.py @@ -0,0 +1,6 @@ +from .order.routes import register as register_order +from .orders.routes import register as register_orders +from .checkout.routes import register as register_checkout +from .data.routes import register as register_data +from .actions.routes import register as register_actions +from .fragments.routes import register as register_fragments diff --git a/orders/bp/actions/__init__.py b/orders/bp/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/actions/routes.py b/orders/bp/actions/routes.py new file mode 100644 index 0000000..dd4e4d5 --- /dev/null +++ b/orders/bp/actions/routes.py @@ -0,0 +1,108 @@ +"""Orders app action endpoints.""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.actions import ACTION_HEADER + + +def register() -> Blueprint: + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.post("/") + async def handle_action(action_name: str): + handler = _handlers.get(action_name) + if handler is None: + return jsonify({"error": "unknown action"}), 404 + try: + result = await handler() + return jsonify(result) + except Exception as exc: + import logging + logging.getLogger(__name__).exception("Action %s failed", action_name) + return jsonify({"error": str(exc)}), 500 + + # --- create-order --- + async def _create_order(): + """Create an order from cart data. Called by cart during checkout.""" + from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout + from shared.infrastructure.urls import orders_url + from services.checkout import ( + create_order, + resolve_page_config_from_post_id, + build_sumup_description, + build_sumup_reference, + build_webhook_url, + ) + + data = await request.get_json(force=True) + + cart_items = data.get("cart_items", []) + calendar_entries = data.get("calendar_entries", []) + tickets = data.get("tickets", []) + user_id = data.get("user_id") + session_id = data.get("session_id") + product_total = data.get("product_total", 0) + calendar_total = data.get("calendar_total", 0) + ticket_total = data.get("ticket_total", 0) + page_post_id = data.get("page_post_id") + + order = await create_order( + g.s, cart_items, calendar_entries, + user_id, session_id, + product_total, calendar_total, + ticket_total=ticket_total, + page_post_id=page_post_id, + ) + + page_config = None + if page_post_id: + page_config = await resolve_page_config_from_post_id(page_post_id) + + if page_config: + order.page_config_id = page_config.id + + order.sumup_reference = build_sumup_reference(order.id, page_config=page_config) + description = build_sumup_description(cart_items, order.id, ticket_count=len(tickets)) + + # Build URLs using orders service's own domain + redirect_url = orders_url(f"/checkout/return/{order.id}/") + webhook_base_url = orders_url(f"/checkout/webhook/{order.id}/") + 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, + ) + 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() + + return { + "order_id": order.id, + "sumup_hosted_url": hosted_url, + "page_config_id": order.page_config_id, + "sumup_reference": order.sumup_reference, + "description": order.description, + } + + _handlers["create-order"] = _create_order + + return bp diff --git a/orders/bp/checkout/__init__.py b/orders/bp/checkout/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/checkout/routes.py b/orders/bp/checkout/routes.py new file mode 100644 index 0000000..d4a8cc7 --- /dev/null +++ b/orders/bp/checkout/routes.py @@ -0,0 +1,99 @@ +"""Checkout webhook + return routes (moved from cart/bp/cart/global_routes.py).""" +from __future__ import annotations + +from quart import Blueprint, g, request, render_template, make_response +from sqlalchemy import select + +from shared.models.order import Order +from shared.browser.app.csrf import csrf_exempt + +from services.checkout import validate_webhook_secret, get_order_with_details +from services.check_sumup_status import check_sumup_status + + +def register() -> Blueprint: + bp = Blueprint("checkout", __name__, url_prefix="/checkout") + + @csrf_exempt + @bp.post("/webhook//") + async def checkout_webhook(order_id: int): + """Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events.""" + 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 + result = await g.s.execute(select(Order).where(Order.id == order_id)) + order = result.scalar_one_or_none() + if not order: + return "", 204 + if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id: + return "", 204 + try: + await check_sumup_status(g.s, order) + except Exception: + pass + return "", 204 + + @bp.get("/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) + + if order.page_config_id: + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict + raw_pc = await fetch_data("blog", "page-config-by-id", + params={"id": order.page_config_id}, required=False) + post = await fetch_data("blog", "post-by-id", + params={"id": raw_pc["container_id"]}, required=False) if raw_pc else None + if post: + g.page_slug = post["slug"] + mps = await fetch_data( + "market", "marketplaces-for-container", + params={"type": "page", "id": post["id"]}, required=False, + ) or [] + if mps: + g.market_slug = mps[0].get("slug") + + if order.sumup_checkout_id: + try: + await check_sumup_status(g.s, order) + except Exception: + pass + + status = (order.status or "pending").lower() + + from shared.infrastructure.data_client import fetch_data + from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict + raw_entries = await fetch_data("events", "entries-for-order", + params={"order_id": order.id}, required=False) or [] + calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries] + raw_tickets = await fetch_data("events", "tickets-for-order", + params={"order_id": order.id}, required=False) or [] + order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets] + await g.s.flush() + + html = await render_template( + "_types/cart/checkout_return.html", + order=order, status=status, + calendar_entries=calendar_entries, + order_tickets=order_tickets, + ) + return await make_response(html) + + return bp diff --git a/orders/bp/data/__init__.py b/orders/bp/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/data/routes.py b/orders/bp/data/routes.py new file mode 100644 index 0000000..5f6d846 --- /dev/null +++ b/orders/bp/data/routes.py @@ -0,0 +1,30 @@ +"""Orders app data endpoints.""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + return bp diff --git a/orders/bp/fragments/__init__.py b/orders/bp/fragments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/fragments/routes.py b/orders/bp/fragments/routes.py new file mode 100644 index 0000000..b4cbbc4 --- /dev/null +++ b/orders/bp/fragments/routes.py @@ -0,0 +1,42 @@ +"""Orders app fragment endpoints. + +Fragments: + account-nav-item "orders" link for account dashboard +""" +from __future__ import annotations + +from quart import Blueprint, Response, request +from shared.infrastructure.fragments import FRAGMENT_HEADER + + +def register(): + bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments") + + async def _account_nav_item(): + from shared.infrastructure.urls import orders_url + href = orders_url("/") + return ( + '' + ) + + _handlers = { + "account-nav-item": _account_nav_item, + } + + @bp.before_request + async def _require_fragment_header(): + if not request.headers.get(FRAGMENT_HEADER): + return Response("", status=403) + + @bp.get("/") + 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 diff --git a/orders/bp/order/__init__.py b/orders/bp/order/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/order/filters/__init__.py b/orders/bp/order/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/order/filters/qs.py b/orders/bp/order/filters/qs.py new file mode 100644 index 0000000..03707e8 --- /dev/null +++ b/orders/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 shared.browser.app.filters.qs_base import KEEP, build_qs +from shared.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/orders/bp/order/routes.py b/orders/bp/order/routes.py new file mode 100644 index 0000000..c84f85f --- /dev/null +++ b/orders/bp/order/routes.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from quart import Blueprint, g, render_template, redirect, url_for, make_response +from sqlalchemy import select +from sqlalchemy.orm import selectinload + +from shared.models.order import Order +from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout +from shared.config import config + +from shared.infrastructure.cart_identity import current_cart_identity +from services.check_sumup_status import check_sumup_status +from shared.browser.app.utils.htmx import is_htmx_request + +from .filters.qs import makeqs_factory, decode + + +def _owner_filter(): + """Return SQLAlchemy clause restricting orders to current user/session.""" + ident = current_cart_identity() + if ident["user_id"]: + return Order.user_id == ident["user_id"] + if ident["session_id"]: + return Order.session_id == ident["session_id"] + return None + + +def register() -> Blueprint: + bp = Blueprint("order", __name__, url_prefix='/') + + @bp.before_request + def route(): + g.makeqs_factory = makeqs_factory + + @bp.get("/") + async def order_detail(order_id: int): + """Show a single order + items.""" + owner = _owner_filter() + if owner is None: + return await make_response("Order not found", 404) + result = await g.s.execute( + select(Order) + .options(selectinload(Order.items)) + .where(Order.id == order_id, owner) + ) + order = result.scalar_one_or_none() + if not order: + return await make_response("Order not found", 404) + if not is_htmx_request(): + html = await render_template("_types/order/index.html", order=order) + else: + 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.""" + owner = _owner_filter() + if owner is None: + return await make_response("Order not found", 404) + result = await g.s.execute(select(Order).where(Order.id == order_id, owner)) + order = result.scalar_one_or_none() + if not order: + return await make_response("Order not found", 404) + + if order.status == "paid": + return redirect(url_for("orders.order.order_detail", order_id=order.id)) + + if order.sumup_hosted_url: + return redirect(order.sumup_hosted_url) + + redirect_url = url_for("checkout.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("checkout.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.""" + owner = _owner_filter() + if owner is None: + return await make_response("Order not found", 404) + result = await g.s.execute(select(Order).where(Order.id == order_id, owner)) + order = result.scalar_one_or_none() + if not order: + return await make_response("Order not found", 404) + + 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: + pass + + return redirect(url_for("orders.order.order_detail", order_id=order.id)) + + return bp diff --git a/orders/bp/orders/__init__.py b/orders/bp/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/orders/filters/__init__.py b/orders/bp/orders/filters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/orders/bp/orders/filters/qs.py b/orders/bp/orders/filters/qs.py new file mode 100644 index 0000000..984e2c3 --- /dev/null +++ b/orders/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 shared.browser.app.filters.qs_base import KEEP, build_qs +from shared.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/orders/bp/orders/routes.py b/orders/bp/orders/routes.py new file mode 100644 index 0000000..5cc33e0 --- /dev/null +++ b/orders/bp/orders/routes.py @@ -0,0 +1,138 @@ +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 shared.models.order import Order, OrderItem + +from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page +from shared.infrastructure.cart_identity import current_cart_identity +from shared.browser.app.utils.htmx import is_htmx_request +from bp.order.routes import register as 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 + + 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 + def route(): + g.makeqs_factory = makeqs_factory + + @bp.before_request + async def _require_identity(): + """Orders require a logged-in user or at least a cart session.""" + ident = current_cart_identity() + if not ident["user_id"] and not ident["session_id"]: + return redirect(url_for("auth.login_form")) + + @bp.get("/") + async def list_orders(): + ident = current_cart_identity() + if ident["user_id"]: + owner_clause = Order.user_id == ident["user_id"] + elif ident["session_id"]: + owner_clause = Order.session_id == ident["session_id"] + else: + return redirect(url_for("auth.login_form")) + + q = decode() + page, search = q.page, q.search + if page < 1: + page = 1 + + 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) + .where( + OrderItem.order_id == Order.id, + or_( + OrderItem.product_title.ilike(term), + OrderItem.product_slug.ilike(term), + ), + ) + ) + ) + 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) + + count_stmt = select(func.count()).select_from(Order).where(owner_clause) + 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) + + if page > total_pages: + page = total_pages + + offset = (page - 1) * ORDERS_PER_PAGE + stmt = ( + select(Order) + .where(owner_clause) + .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, + } + + if not is_htmx_request(): + html = await render_template("_types/orders/index.html", **context) + elif page > 1: + html = await render_template("_types/orders/_rows.html", **context) + else: + 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/orders/entrypoint.sh b/orders/entrypoint.sh new file mode 100644 index 0000000..c55d614 --- /dev/null +++ b/orders/entrypoint.sh @@ -0,0 +1,61 @@ +#!/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 + +# Create own database + run own migrations +if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then + python3 -c " +import os, re +url = os.environ['ALEMBIC_DATABASE_URL'] +m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url) +if not m: + print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation') + exit(0) +user, password, host, port, dbname = m.groups() + +import psycopg +conn = psycopg.connect( + f'postgresql://{user}:{password}@{host}:{port}/postgres', + autocommit=True, +) +cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,)) +if not cur.fetchone(): + conn.execute(f'CREATE DATABASE {dbname}') + print(f'Created database {dbname}') +else: + print(f'Database {dbname} already exists') +conn.close() +" || echo "DB creation failed (non-fatal), continuing..." + + echo "Running orders Alembic migrations..." + if [ -d orders ]; then (cd orders && alembic upgrade head); else alembic upgrade head; fi +fi + +# 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.flushdb() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +RELOAD_FLAG="" +if [[ "${RELOAD:-}" == "true" ]]; then + RELOAD_FLAG="--reload" + echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." +else + echo "Starting Hypercorn (${APP_MODULE:-app:app})..." +fi +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG} diff --git a/orders/models/__init__.py b/orders/models/__init__.py new file mode 100644 index 0000000..80f400c --- /dev/null +++ b/orders/models/__init__.py @@ -0,0 +1 @@ +from shared.models.order import Order, OrderItem diff --git a/orders/path_setup.py b/orders/path_setup.py new file mode 100644 index 0000000..c7166f7 --- /dev/null +++ b/orders/path_setup.py @@ -0,0 +1,9 @@ +import sys +import os + +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p) diff --git a/orders/services/__init__.py b/orders/services/__init__.py new file mode 100644 index 0000000..97c0882 --- /dev/null +++ b/orders/services/__init__.py @@ -0,0 +1,6 @@ +"""Orders app service registration.""" +from __future__ import annotations + + +def register_domain_services() -> None: + """Register services for the orders app.""" diff --git a/orders/services/check_sumup_status.py b/orders/services/check_sumup_status.py new file mode 100644 index 0000000..4145d64 --- /dev/null +++ b/orders/services/check_sumup_status.py @@ -0,0 +1,63 @@ +"""Check SumUp checkout status and update order accordingly. + +Moved from cart/bp/cart/services/check_sumup_status.py. +""" +from types import SimpleNamespace + +from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout +from shared.events import emit_activity +from shared.infrastructure.actions import call_action +from shared.infrastructure.data_client import fetch_data + + +async def check_sumup_status(session, order, *, page_config=None): + # Auto-fetch page_config from blog if order has one and caller didn't provide it + if page_config is None and 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: + page_config = SimpleNamespace(**raw_pc) + + checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config) + 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" + await call_action("events", "confirm-entries-for-order", payload={ + "order_id": order.id, "user_id": order.user_id, + "session_id": order.session_id, + }) + await call_action("events", "confirm-tickets-for-order", payload={ + "order_id": order.id, + }) + + page_post_id = page_config.container_id if page_config else None + await call_action("cart", "clear-cart-for-order", payload={ + "user_id": order.user_id, + "session_id": order.session_id, + "page_post_id": page_post_id, + }) + + await emit_activity( + session, + activity_type="rose:OrderPaid", + actor_uri="internal:orders", + 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": + order.status = "failed" + else: + order.status = sumup_status.lower() or order.status + + await session.flush() diff --git a/orders/services/checkout.py b/orders/services/checkout.py new file mode 100644 index 0000000..912247c --- /dev/null +++ b/orders/services/checkout.py @@ -0,0 +1,147 @@ +"""Order creation and SumUp checkout helpers. + +Moved from cart/bp/cart/services/checkout.py. +Only the order-side logic lives here; find_or_create_cart_item stays in cart. +""" +from __future__ import annotations + +from typing import Optional +from urllib.parse import urlencode +from types import SimpleNamespace + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from shared.models.order import Order, OrderItem +from shared.config import config +from shared.events import emit_activity +from shared.infrastructure.actions import call_action +from shared.infrastructure.data_client import fetch_data + + +async def resolve_page_config_from_post_id(post_id: int) -> Optional[SimpleNamespace]: + """Fetch the PageConfig for *post_id* from the blog service.""" + raw_pc = await fetch_data( + "blog", "page-config", + params={"container_type": "page", "container_id": post_id}, + required=False, + ) + return SimpleNamespace(**raw_pc) if raw_pc else None + + +async def create_order( + session: AsyncSession, + cart_items: list[dict], + calendar_entries: list, + user_id: Optional[int], + session_id: Optional[str], + product_total: float, + calendar_total: float, + *, + ticket_total: float = 0, + page_post_id: int | None = None, +) -> Order: + """Create an Order + OrderItems from serialized cart data.""" + cart_total = product_total + calendar_total + ticket_total + currency = (cart_items[0].get("product_price_currency") if cart_items else None) or "GBP" + + order = Order( + user_id=user_id, + session_id=session_id, + status="pending", + currency=currency, + total_amount=cart_total, + ) + session.add(order) + await session.flush() + + for ci in cart_items: + price = ci.get("product_special_price") or ci.get("product_regular_price") or 0 + oi = OrderItem( + order=order, + product_id=ci["product_id"], + product_title=ci.get("product_title"), + product_slug=ci.get("product_slug"), + product_image=ci.get("product_image"), + quantity=ci.get("quantity", 1), + unit_price=price, + currency=currency, + ) + session.add(oi) + + await call_action("events", "claim-entries-for-order", payload={ + "order_id": order.id, "user_id": user_id, + "session_id": session_id, "page_post_id": page_post_id, + }) + await call_action("events", "claim-tickets-for-order", payload={ + "order_id": order.id, "user_id": user_id, + "session_id": session_id, "page_post_id": page_post_id, + }) + + await emit_activity( + session, + activity_type="Create", + actor_uri="internal:orders", + 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 + + +def build_sumup_description(cart_items: list[dict], order_id: int, *, ticket_count: int = 0) -> str: + titles = [ci.get("product_title") for ci in cart_items if ci.get("product_title")] + item_count = sum(ci.get("quantity", 1) for ci in cart_items) + parts = [] + if titles: + if len(titles) <= 3: + parts.append(", ".join(titles)) + else: + parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more") + if ticket_count: + parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}") + 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: + if page_config and page_config.sumup_checkout_prefix: + prefix = page_config.sumup_checkout_prefix + else: + 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: + 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: + sumup_cfg = config().get("sumup", {}) or {} + webhook_secret = sumup_cfg.get("webhook_secret") + if not webhook_secret: + return True + return token is not None and token == webhook_secret + + +async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]: + result = await session.execute( + select(Order) + .options(selectinload(Order.items)) + .where(Order.id == order_id) + ) + return result.scalar_one_or_none() diff --git a/orders/templates/_types/cart/checkout_error.html b/orders/templates/_types/cart/checkout_error.html new file mode 100644 index 0000000..a15b1e9 --- /dev/null +++ b/orders/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/orders/templates/_types/cart/checkout_return.html b/orders/templates/_types/cart/checkout_return.html new file mode 100644 index 0000000..b08a09d --- /dev/null +++ b/orders/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' %} + {% include '_types/order/_ticket_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/orders/templates/_types/order/_calendar_items.html b/orders/templates/_types/order/_calendar_items.html new file mode 100644 index 0000000..019f048 --- /dev/null +++ b/orders/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/orders/templates/_types/order/_items.html b/orders/templates/_types/order/_items.html new file mode 100644 index 0000000..92d674d --- /dev/null +++ b/orders/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/orders/templates/_types/order/_main_panel.html b/orders/templates/_types/order/_main_panel.html new file mode 100644 index 0000000..679b846 --- /dev/null +++ b/orders/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/orders/templates/_types/order/_nav.html b/orders/templates/_types/order/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/orders/templates/_types/order/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/orders/templates/_types/order/_oob_elements.html b/orders/templates/_types/order/_oob_elements.html new file mode 100644 index 0000000..31d1e17 --- /dev/null +++ b/orders/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/orders/templates/_types/order/_summary.html b/orders/templates/_types/order/_summary.html new file mode 100644 index 0000000..ffe560b --- /dev/null +++ b/orders/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/orders/templates/_types/order/_ticket_items.html b/orders/templates/_types/order/_ticket_items.html new file mode 100644 index 0000000..ef06c0b --- /dev/null +++ b/orders/templates/_types/order/_ticket_items.html @@ -0,0 +1,49 @@ +{# --- Tickets in this order --- #} + {% if order and order_tickets %} +
+

+ Event tickets in this order +

+ +
    + {% for tk in order_tickets %} +
  • +
    +
    + {{ tk.entry_name }} + {# Small status pill #} + + {{ tk.state|replace('_', ' ')|capitalize }} + +
    + {% if tk.ticket_type_name %} +
    {{ tk.ticket_type_name }}
    + {% endif %} +
    + {{ 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 %} +
    +
    + {{ tk.code }} +
    +
    +
    + £{{ "%.2f"|format(tk.price or 0) }} +
    +
  • + {% endfor %} +
+
+ {% endif %} \ No newline at end of file diff --git a/orders/templates/_types/order/header/_header.html b/orders/templates/_types/order/header/_header.html new file mode 100644 index 0000000..4d7f74b --- /dev/null +++ b/orders/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/orders/templates/_types/order/index.html b/orders/templates/_types/order/index.html new file mode 100644 index 0000000..c3d301e --- /dev/null +++ b/orders/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/orders/templates/_types/orders/_main_panel.html b/orders/templates/_types/orders/_main_panel.html new file mode 100644 index 0000000..01ad410 --- /dev/null +++ b/orders/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/orders/templates/_types/orders/_nav.html b/orders/templates/_types/orders/_nav.html new file mode 100644 index 0000000..f5c504d --- /dev/null +++ b/orders/templates/_types/orders/_nav.html @@ -0,0 +1,2 @@ +{% from 'macros/admin_nav.html' import placeholder_nav %} +{{ placeholder_nav() }} diff --git a/orders/templates/_types/orders/_oob_elements.html b/orders/templates/_types/orders/_oob_elements.html new file mode 100644 index 0000000..741e8fa --- /dev/null +++ b/orders/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 %} + {% from 'macros/search.html' import search_desktop %} + {{ search_desktop(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/orders/templates/_types/orders/_rows.html b/orders/templates/_types/orders/_rows.html new file mode 100644 index 0000000..33a459c --- /dev/null +++ b/orders/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/orders/templates/_types/orders/_summary.html b/orders/templates/_types/orders/_summary.html new file mode 100644 index 0000000..f812413 --- /dev/null +++ b/orders/templates/_types/orders/_summary.html @@ -0,0 +1,11 @@ +
+
+

+ Recent orders placed via the checkout. +

+
+
+ {% from 'macros/search.html' import search_mobile %} + {{ search_mobile(current_local_href, search, search_count, hx_select) }} +
+
\ No newline at end of file diff --git a/orders/templates/_types/orders/header/_header.html b/orders/templates/_types/orders/header/_header.html new file mode 100644 index 0000000..32c1659 --- /dev/null +++ b/orders/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/orders/templates/_types/orders/index.html b/orders/templates/_types/orders/index.html new file mode 100644 index 0000000..7ee80a0 --- /dev/null +++ b/orders/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 %} + {% from 'macros/search.html' import search_desktop %} + {{ search_desktop(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 %} diff --git a/relations/Dockerfile b/relations/Dockerfile new file mode 100644 index 0000000..211df29 --- /dev/null +++ b/relations/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 + +FROM python:3.11-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app \ + PIP_NO_CACHE_DIR=1 \ + APP_PORT=8000 \ + APP_MODULE=app:app + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY shared/requirements.txt ./requirements.txt +RUN pip install -r requirements.txt + +# Shared code +COPY shared/ ./shared/ + +# App code +COPY relations/ ./ + +# Sibling models for cross-domain SQLAlchemy imports +COPY blog/__init__.py ./blog/__init__.py +COPY blog/models/ ./blog/models/ +COPY market/__init__.py ./market/__init__.py +COPY market/models/ ./market/models/ +COPY cart/__init__.py ./cart/__init__.py +COPY cart/models/ ./cart/models/ +COPY events/__init__.py ./events/__init__.py +COPY events/models/ ./events/models/ +COPY federation/__init__.py ./federation/__init__.py +COPY federation/models/ ./federation/models/ +COPY account/__init__.py ./account/__init__.py +COPY account/models/ ./account/models/ +COPY likes/__init__.py ./likes/__init__.py +COPY likes/models/ ./likes/models/ +COPY orders/__init__.py ./orders/__init__.py +COPY orders/models/ ./orders/models/ + +COPY relations/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/relations/__init__.py b/relations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/relations/alembic.ini b/relations/alembic.ini new file mode 100644 index 0000000..a04e071 --- /dev/null +++ b/relations/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +script_location = alembic +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/relations/alembic/env.py b/relations/alembic/env.py new file mode 100644 index 0000000..ba46376 --- /dev/null +++ b/relations/alembic/env.py @@ -0,0 +1,12 @@ +from alembic import context +from shared.db.alembic_env import run_alembic + +MODELS = [ + "shared.models.container_relation", +] + +TABLES = frozenset({ + "container_relations", +}) + +run_alembic(context.config, MODELS, TABLES) diff --git a/relations/alembic/versions/0001_initial.py b/relations/alembic/versions/0001_initial.py new file mode 100644 index 0000000..ebea1e4 --- /dev/null +++ b/relations/alembic/versions/0001_initial.py @@ -0,0 +1,49 @@ +"""Initial relations tables + +Revision ID: relations_0001 +Revises: None +Create Date: 2026-02-27 +""" + +import sqlalchemy as sa +from alembic import op + +revision = "relations_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def _table_exists(conn, name): + result = conn.execute(sa.text( + "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t" + ), {"t": name}) + return result.scalar() is not None + + +def upgrade(): + if _table_exists(op.get_bind(), "container_relations"): + return + + op.create_table( + "container_relations", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("parent_type", sa.String(32), nullable=False), + sa.Column("parent_id", sa.Integer, nullable=False), + sa.Column("child_type", sa.String(32), nullable=False), + sa.Column("child_id", sa.Integer, nullable=False), + sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"), + sa.Column("label", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.UniqueConstraint( + "parent_type", "parent_id", "child_type", "child_id", + name="uq_container_relations_parent_child", + ), + ) + op.create_index("ix_container_relations_parent", "container_relations", ["parent_type", "parent_id"]) + op.create_index("ix_container_relations_child", "container_relations", ["child_type", "child_id"]) + + +def downgrade(): + op.drop_table("container_relations") diff --git a/relations/app.py b/relations/app.py new file mode 100644 index 0000000..a915eaa --- /dev/null +++ b/relations/app.py @@ -0,0 +1,22 @@ +from __future__ import annotations +import path_setup # noqa: F401 + +from shared.infrastructure.factory import create_base_app + +from bp import register_actions, register_data +from services import register_domain_services + + +def create_app() -> "Quart": + app = create_base_app( + "relations", + domain_services_fn=register_domain_services, + ) + + app.register_blueprint(register_actions()) + app.register_blueprint(register_data()) + + return app + + +app = create_app() diff --git a/relations/bp/__init__.py b/relations/bp/__init__.py new file mode 100644 index 0000000..7122ccd --- /dev/null +++ b/relations/bp/__init__.py @@ -0,0 +1,2 @@ +from .data.routes import register as register_data +from .actions.routes import register as register_actions diff --git a/relations/bp/actions/__init__.py b/relations/bp/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/relations/bp/actions/routes.py b/relations/bp/actions/routes.py new file mode 100644 index 0000000..a80928c --- /dev/null +++ b/relations/bp/actions/routes.py @@ -0,0 +1,78 @@ +"""Relations app action endpoints.""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.actions import ACTION_HEADER + + +def register() -> Blueprint: + bp = Blueprint("actions", __name__, url_prefix="/internal/actions") + + @bp.before_request + async def _require_action_header(): + if not request.headers.get(ACTION_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.post("/") + async def handle_action(action_name: str): + handler = _handlers.get(action_name) + if handler is None: + return jsonify({"error": "unknown action"}), 404 + try: + result = await handler() + return jsonify(result) + except Exception as exc: + import logging + logging.getLogger(__name__).exception("Action %s failed", action_name) + return jsonify({"error": str(exc)}), 500 + + # --- attach-child --- + async def _attach_child(): + """Create or revive a ContainerRelation.""" + from shared.services.relationships import attach_child + + data = await request.get_json(force=True) + rel = await attach_child( + g.s, + parent_type=data["parent_type"], + parent_id=data["parent_id"], + child_type=data["child_type"], + child_id=data["child_id"], + label=data.get("label"), + sort_order=data.get("sort_order"), + ) + return { + "id": rel.id, + "parent_type": rel.parent_type, + "parent_id": rel.parent_id, + "child_type": rel.child_type, + "child_id": rel.child_id, + "sort_order": rel.sort_order, + } + + _handlers["attach-child"] = _attach_child + + # --- detach-child --- + async def _detach_child(): + """Soft-delete a ContainerRelation.""" + from shared.services.relationships import detach_child + + data = await request.get_json(force=True) + deleted = await detach_child( + g.s, + parent_type=data["parent_type"], + parent_id=data["parent_id"], + child_type=data["child_type"], + child_id=data["child_id"], + ) + return {"deleted": deleted} + + _handlers["detach-child"] = _detach_child + + return bp diff --git a/relations/bp/data/__init__.py b/relations/bp/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/relations/bp/data/routes.py b/relations/bp/data/routes.py new file mode 100644 index 0000000..15d921a --- /dev/null +++ b/relations/bp/data/routes.py @@ -0,0 +1,56 @@ +"""Relations app data endpoints.""" +from __future__ import annotations + +from quart import Blueprint, g, jsonify, request + +from shared.infrastructure.data_client import DATA_HEADER + + +def register() -> Blueprint: + bp = Blueprint("data", __name__, url_prefix="/internal/data") + + @bp.before_request + async def _require_data_header(): + if not request.headers.get(DATA_HEADER): + return jsonify({"error": "forbidden"}), 403 + from shared.infrastructure.internal_auth import validate_internal_request + if not validate_internal_request(): + return jsonify({"error": "forbidden"}), 403 + + _handlers: dict[str, object] = {} + + @bp.get("/") + async def handle_query(query_name: str): + handler = _handlers.get(query_name) + if handler is None: + return jsonify({"error": "unknown query"}), 404 + result = await handler() + return jsonify(result) + + # --- get-children --- + async def _get_children(): + """Return ContainerRelation children for a parent.""" + from shared.services.relationships import get_children + + parent_type = request.args.get("parent_type", "") + parent_id = request.args.get("parent_id", type=int) + child_type = request.args.get("child_type") + if not parent_type or parent_id is None: + return [] + rels = await get_children(g.s, parent_type, parent_id, child_type) + return [ + { + "id": r.id, + "parent_type": r.parent_type, + "parent_id": r.parent_id, + "child_type": r.child_type, + "child_id": r.child_id, + "sort_order": r.sort_order, + "label": r.label, + } + for r in rels + ] + + _handlers["get-children"] = _get_children + + return bp diff --git a/relations/entrypoint.sh b/relations/entrypoint.sh new file mode 100644 index 0000000..dcb4cb7 --- /dev/null +++ b/relations/entrypoint.sh @@ -0,0 +1,61 @@ +#!/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 + +# Create own database + run own migrations +if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then + python3 -c " +import os, re +url = os.environ['ALEMBIC_DATABASE_URL'] +m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url) +if not m: + print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation') + exit(0) +user, password, host, port, dbname = m.groups() + +import psycopg +conn = psycopg.connect( + f'postgresql://{user}:{password}@{host}:{port}/postgres', + autocommit=True, +) +cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,)) +if not cur.fetchone(): + conn.execute(f'CREATE DATABASE {dbname}') + print(f'Created database {dbname}') +else: + print(f'Database {dbname} already exists') +conn.close() +" || echo "DB creation failed (non-fatal), continuing..." + + echo "Running relations Alembic migrations..." + if [ -d relations ]; then (cd relations && alembic upgrade head); else alembic upgrade head; fi +fi + +# 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.flushdb() +print('Redis cache cleared.') +" || echo "Redis flush failed (non-fatal), continuing..." +fi + +# Start the app +RELOAD_FLAG="" +if [[ "${RELOAD:-}" == "true" ]]; then + RELOAD_FLAG="--reload" + echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..." +else + echo "Starting Hypercorn (${APP_MODULE:-app:app})..." +fi +PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG} diff --git a/relations/models/__init__.py b/relations/models/__init__.py new file mode 100644 index 0000000..90d5ced --- /dev/null +++ b/relations/models/__init__.py @@ -0,0 +1 @@ +from shared.models.container_relation import ContainerRelation diff --git a/relations/path_setup.py b/relations/path_setup.py new file mode 100644 index 0000000..c7166f7 --- /dev/null +++ b/relations/path_setup.py @@ -0,0 +1,9 @@ +import sys +import os + +_app_dir = os.path.dirname(os.path.abspath(__file__)) +_project_root = os.path.dirname(_app_dir) + +for _p in (_project_root, _app_dir): + if _p not in sys.path: + sys.path.insert(0, _p) diff --git a/relations/services/__init__.py b/relations/services/__init__.py new file mode 100644 index 0000000..faa4bb1 --- /dev/null +++ b/relations/services/__init__.py @@ -0,0 +1,6 @@ +"""Relations app service registration.""" +from __future__ import annotations + + +def register_domain_services() -> None: + """Register services for the relations app.""" diff --git a/shared/infrastructure/factory.py b/shared/infrastructure/factory.py index 920c85c..aa23814 100644 --- a/shared/infrastructure/factory.py +++ b/shared/infrastructure/factory.py @@ -12,7 +12,7 @@ from shared.config import init_config, config, pretty from shared.models import KV # ensure shared models imported # Register all app model classes with SQLAlchemy so cross-domain # relationship() string references resolve correctly. -for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "account.models"): +for _mod in ("blog.models", "market.models", "cart.models", "events.models", "federation.models", "account.models", "relations.models", "likes.models", "orders.models"): try: __import__(_mod) except ImportError: diff --git a/shared/infrastructure/urls.py b/shared/infrastructure/urls.py index 28bcb45..92f49a4 100644 --- a/shared/infrastructure/urls.py +++ b/shared/infrastructure/urls.py @@ -41,6 +41,10 @@ def federation_url(path: str = "/") -> str: return app_url("federation", path) +def orders_url(path: str = "/") -> str: + return app_url("orders", path) + + def account_url(path: str = "/") -> str: return app_url("account", path) diff --git a/shared/models/__init__.py b/shared/models/__init__.py index c7303ee..8562f6c 100644 --- a/shared/models/__init__.py +++ b/shared/models/__init__.py @@ -10,11 +10,11 @@ from .ghost_membership_entities import ( GhostNewsletter, UserNewsletter, GhostTier, GhostSubscription, ) -from .ghost_content import Tag, Post, Author, PostAuthor, PostTag, PostLike +from .ghost_content import Tag, Post, Author, PostAuthor, PostTag from .page_config import PageConfig from .order import Order, OrderItem from .market import ( - Product, ProductLike, ProductImage, ProductSection, + Product, ProductImage, ProductSection, NavTop, NavSub, Listing, ListingItem, LinkError, LinkExternal, SubcategoryRedirect, ProductLog, ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, diff --git a/shared/models/ghost_content.py b/shared/models/ghost_content.py index 9aa13b1..bb2e8be 100644 --- a/shared/models/ghost_content.py +++ b/shared/models/ghost_content.py @@ -131,12 +131,6 @@ class Post(Base): back_populates="posts", order_by="PostTag.sort_order", ) - likes: Mapped[List["PostLike"]] = relationship( - "PostLike", - back_populates="post", - cascade="all, delete-orphan", - passive_deletes=True, - ) class Author(Base): __tablename__ = "authors" @@ -198,15 +192,3 @@ class PostTag(Base): sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False) -class PostLike(Base): - __tablename__ = "post_likes" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, nullable=False) - post_id: Mapped[int] = mapped_column(ForeignKey("posts.id", ondelete="CASCADE"), nullable=False) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) - - post: Mapped["Post"] = relationship("Post", back_populates="likes", foreign_keys=[post_id]) diff --git a/shared/models/market.py b/shared/models/market.py index 587632a..a019c83 100644 --- a/shared/models/market.py +++ b/shared/models/market.py @@ -107,28 +107,9 @@ class Product(Base): cascade="all, delete-orphan", ) - likes = relationship( - "ProductLike", - back_populates="product", - cascade="all, delete-orphan", - ) # cart_items and order_items live in a separate domain (cart DB) # — cross-domain relationships removed -from sqlalchemy import Column - -class ProductLike(Base): - __tablename__ = "product_likes" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, nullable=False) - product_slug: Mapped[str] = mapped_column(ForeignKey("products.slug", ondelete="CASCADE")) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) - deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True)) - product: Mapped["Product"] = relationship("Product", back_populates="likes", foreign_keys=[product_slug]) - class ProductImage(Base): __tablename__ = "product_images" diff --git a/shared/services/market_impl.py b/shared/services/market_impl.py index f2c3fb7..5a7bbbb 100644 --- a/shared/services/market_impl.py +++ b/shared/services/market_impl.py @@ -93,7 +93,7 @@ class SqlMarketService: existing.deleted_at = None # revive existing.name = name await session.flush() - await call_action("cart", "attach-child", payload={ + await call_action("relations", "attach-child", payload={ "parent_type": container_type, "parent_id": container_id, "child_type": "market", "child_id": existing.id, }) @@ -106,7 +106,7 @@ class SqlMarketService: ) session.add(market) await session.flush() - await call_action("cart", "attach-child", payload={ + await call_action("relations", "attach-child", payload={ "parent_type": container_type, "parent_id": container_id, "child_type": "market", "child_id": market.id, }) @@ -130,7 +130,7 @@ class SqlMarketService: market.deleted_at = utcnow() await session.flush() - await call_action("cart", "detach-child", payload={ + await call_action("relations", "detach-child", payload={ "parent_type": container_type, "parent_id": container_id, "child_type": "market", "child_id": market.id, })