From dd52417241230ca7bdad778a780dc8d686265b96 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 26 Feb 2026 17:43:21 +0000 Subject: [PATCH] Fix cross-DB queries: move page_configs to cart, fix OAuth code_hash lookup page_configs table lives in db_cart but blog was querying it directly, causing UndefinedTableError. Move all PageConfig read/write endpoints to cart service and have blog proxy via fetch_data/call_action. Also fix OAuth callback to use code_hash lookup (codes are now stored hashed) and pass grant_token in redirect URL to prevent auth loops. Co-Authored-By: Claude Opus 4.6 --- account/bp/auth/routes.py | 2 +- blog/bp/actions/routes.py | 46 ++----------- blog/bp/blog/ghost_db.py | 20 ++++-- blog/bp/data/routes.py | 71 +++++--------------- blog/bp/post/admin/routes.py | 103 ++++++++++------------------- blog/bp/post/services/markets.py | 11 ++- cart/bp/actions/routes.py | 62 +++++++++++++++++ cart/bp/cart/global_routes.py | 2 +- cart/bp/cart/services/page_cart.py | 2 +- cart/bp/data/routes.py | 73 ++++++++++++++++++++ shared/infrastructure/oauth.py | 16 ++++- 11 files changed, 228 insertions(+), 180 deletions(-) diff --git a/account/bp/auth/routes.py b/account/bp/auth/routes.py index 3f9bb9e..ed0b69b 100644 --- a/account/bp/auth/routes.py +++ b/account/bp/auth/routes.py @@ -123,7 +123,7 @@ def register(url_prefix="/auth"): sep = "&" if "?" in redirect_uri else "?" return redirect( f"{redirect_uri}{sep}code={code}&state={state}" - f"&account_did={account_did}" + f"&account_did={account_did}&grant_token={grant_token}" ) # --- OAuth2 token exchange (for external clients like artdag) ------------- diff --git a/blog/bp/actions/routes.py b/blog/bp/actions/routes.py index 6fd8022..2d1c896 100644 --- a/blog/bp/actions/routes.py +++ b/blog/bp/actions/routes.py @@ -31,51 +31,13 @@ def register() -> Blueprint: result = await handler() return jsonify(result or {"ok": True}) - # --- update-page-config --- + # --- update-page-config (proxy to cart, where page_configs table lives) --- async def _update_page_config(): - """Create or update a PageConfig (used by events payment admin).""" - from shared.models.page_config import PageConfig - from sqlalchemy import select + """Create or update a PageConfig — proxies to cart service.""" + from shared.infrastructure.actions import call_action 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 - - 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 "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, - "sumup_merchant_code": pc.sumup_merchant_code, - "sumup_checkout_prefix": pc.sumup_checkout_prefix, - "sumup_configured": bool(pc.sumup_api_key), - } + return await call_action("cart", "update-page-config", payload=data) _handlers["update-page-config"] = _update_page_config diff --git a/blog/bp/blog/ghost_db.py b/blog/bp/blog/ghost_db.py index 1e9eda6..e193b85 100644 --- a/blog/bp/blog/ghost_db.py +++ b/blog/bp/blog/ghost_db.py @@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload, joinedload from models.ghost_content import Post, Author, Tag, PostTag -from shared.models.page_config import PageConfig from models.tag_group import TagGroup, TagGroupTag @@ -280,7 +279,6 @@ class DBClient: joinedload(Post.primary_tag), selectinload(Post.authors), selectinload(Post.tags), - joinedload(Post.page_config), ) .limit(limit) .offset(offset_val) @@ -288,10 +286,24 @@ class DBClient: rows: List[Post] = list((await self.sess.execute(q)).scalars()) + # Load PageConfigs from cart service (page_configs lives in db_cart) + post_ids = [p.id for p in rows] + pc_map: Dict[int, dict] = {} + if post_ids: + from shared.infrastructure.data_client import fetch_data + 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 + except Exception: + pass # graceful degradation — pages render without features + def _page_to_public(p: Post) -> Dict[str, Any]: d = _post_to_public(p) - pc = p.page_config - d["features"] = pc.features if pc else {} + pc = pc_map.get(p.id) + d["features"] = pc["features"] if pc else {} return d pages_list = [_page_to_public(p) for p in rows] diff --git a/blog/bp/data/routes.py b/blog/bp/data/routes.py index 878eaae..afe6356 100644 --- a/blog/bp/data/routes.py +++ b/blog/bp/data/routes.py @@ -7,25 +7,9 @@ from __future__ import annotations from quart import Blueprint, g, jsonify, request -from sqlalchemy import select - -from shared.infrastructure.data_client import DATA_HEADER +from shared.infrastructure.data_client import DATA_HEADER, fetch_data from shared.contracts.dtos import dto_to_dict from shared.services.registry import services -from shared.models.page_config import PageConfig - - -def _page_config_dict(pc: PageConfig) -> 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, - } def register() -> Blueprint: @@ -90,55 +74,30 @@ def register() -> Blueprint: _handlers["search-posts"] = _search_posts - # --- page-config --- + # --- page-config (proxy to cart, where page_configs table lives) --- async def _page_config(): """Return a single PageConfig by container_type + container_id.""" - 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) + 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) _handlers["page-config"] = _page_config - # --- page-config-by-id --- + # --- page-config-by-id (proxy to cart) --- async def _page_config_by_id(): - """Return a single PageConfig by its primary key.""" - 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) + return await fetch_data("cart", "page-config-by-id", + params={"id": request.args.get("id", "")}, + required=False) _handlers["page-config-by-id"] = _page_config_by_id - # --- page-configs-batch --- + # --- page-configs-batch (proxy to cart) --- async def _page_configs_batch(): - """Return PageConfigs for multiple container_ids (comma-separated).""" - 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()] + 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 [] _handlers["page-configs-batch"] = _page_configs_batch diff --git a/blog/bp/post/admin/routes.py b/blog/bp/post/admin/routes.py index 603e927..1ff3393 100644 --- a/blog/bp/post/admin/routes.py +++ b/blog/bp/post/admin/routes.py @@ -22,24 +22,23 @@ def register(): @require_admin async def admin(slug: str): from shared.browser.app.utils.htmx import is_htmx_request - from shared.models.page_config import PageConfig - from sqlalchemy import select as sa_select + from shared.infrastructure.data_client import fetch_data - # Load features for page admin + # Load features for page admin (page_configs lives in db_cart) post = (g.post_data or {}).get("post", {}) features = {} sumup_configured = False sumup_merchant_code = "" sumup_checkout_prefix = "" if post.get("is_page"): - pc = (await g.s.execute( - sa_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 "" + 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 "" ctx = { "features": features, @@ -61,12 +60,9 @@ def register(): @bp.put("/features/") @require_admin async def update_features(slug: str): - """Update PageConfig.features for a page.""" - from shared.models.page_config import PageConfig - from models.ghost_content import Post - from sqlalchemy import select as sa_select + """Update PageConfig.features for a page (page_configs lives in db_cart).""" + from shared.infrastructure.actions import call_action from quart import jsonify - import json post = g.post_data.get("post") if not post or not post.get("is_page"): @@ -74,21 +70,9 @@ def register(): post_id = post["id"] - # Load or create PageConfig - pc = (await g.s.execute( - sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id) - )).scalar_one_or_none() - if pc is None: - pc = PageConfig(container_type="page", container_id=post_id, features={}) - g.s.add(pc) - await g.s.flush() - from shared.services.relationships import attach_child - await attach_child(g.s, "page", post_id, "page_config", pc.id) - # Parse request body body = await request.get_json() if body is None: - # Fall back to form data form = await request.form body = {} for key in ("calendar", "market"): @@ -99,38 +83,30 @@ def register(): if not isinstance(body, dict): return jsonify({"error": "Expected JSON object with feature flags."}), 400 - # Merge features - features = dict(pc.features or {}) - for key, val in body.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 + # Update via cart action (page_configs lives in db_cart) + result = await call_action("cart", "update-page-config", payload={ + "container_type": "page", + "container_id": post_id, + "features": body, + }) - pc.features = features - from sqlalchemy.orm.attributes import flag_modified - flag_modified(pc, "features") - await g.s.flush() + features = result.get("features", {}) - # Return updated features panel html = await render_template( "_types/post/admin/_features_panel.html", features=features, post=post, - sumup_configured=bool(pc.sumup_api_key), - sumup_merchant_code=pc.sumup_merchant_code or "", - sumup_checkout_prefix=pc.sumup_checkout_prefix or "", + sumup_configured=result.get("sumup_configured", False), + sumup_merchant_code=result.get("sumup_merchant_code") or "", + sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "", ) return await make_response(html) @bp.put("/admin/sumup/") @require_admin async def update_sumup(slug: str): - """Update PageConfig SumUp credentials for a page.""" - from shared.models.page_config import PageConfig - from sqlalchemy import select as sa_select + """Update PageConfig SumUp credentials for a page (page_configs lives in db_cart).""" + from shared.infrastructure.actions import call_action from quart import jsonify post = g.post_data.get("post") @@ -139,37 +115,30 @@ def register(): post_id = post["id"] - pc = (await g.s.execute( - sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id) - )).scalar_one_or_none() - if pc is None: - pc = PageConfig(container_type="page", container_id=post_id, features={}) - g.s.add(pc) - await g.s.flush() - from shared.services.relationships import attach_child - await attach_child(g.s, "page", post_id, "page_config", pc.id) - form = await request.form merchant_code = (form.get("merchant_code") or "").strip() api_key = (form.get("api_key") or "").strip() checkout_prefix = (form.get("checkout_prefix") or "").strip() - pc.sumup_merchant_code = merchant_code or None - pc.sumup_checkout_prefix = checkout_prefix or None - # Only update API key if non-empty (allows updating other fields without re-entering key) + payload: dict = { + "container_type": "page", + "container_id": post_id, + "sumup_merchant_code": merchant_code or None, + "sumup_checkout_prefix": checkout_prefix or None, + } if api_key: - pc.sumup_api_key = api_key + payload["sumup_api_key"] = api_key - await g.s.flush() + result = await call_action("cart", "update-page-config", payload=payload) - features = pc.features or {} + features = result.get("features", {}) html = await render_template( "_types/post/admin/_features_panel.html", features=features, post=post, - sumup_configured=bool(pc.sumup_api_key), - sumup_merchant_code=pc.sumup_merchant_code or "", - sumup_checkout_prefix=pc.sumup_checkout_prefix or "", + sumup_configured=result.get("sumup_configured", False), + sumup_merchant_code=result.get("sumup_merchant_code") or "", + sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "", ) return await make_response(html) diff --git a/blog/bp/post/services/markets.py b/blog/bp/post/services/markets.py index 49ca157..630c785 100644 --- a/blog/bp/post/services/markets.py +++ b/blog/bp/post/services/markets.py @@ -3,12 +3,11 @@ from __future__ import annotations import re import unicodedata -from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from shared.models.page_config import PageConfig from shared.contracts.dtos import MarketPlaceDTO from shared.infrastructure.actions import call_action, ActionError +from shared.infrastructure.data_client import fetch_data from shared.services.registry import services @@ -42,10 +41,10 @@ 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.") - pc = (await sess.execute( - select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id) - )).scalar_one_or_none() - if pc is None or not (pc.features or {}).get("market"): + raw_pc = await fetch_data("cart", "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"): raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.") try: diff --git a/cart/bp/actions/routes.py b/cart/bp/actions/routes.py index 0bcbb24..279ee88 100644 --- a/cart/bp/actions/routes.py +++ b/cart/bp/actions/routes.py @@ -47,4 +47,66 @@ 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 + + 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 + + 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 + return bp diff --git a/cart/bp/cart/global_routes.py b/cart/bp/cart/global_routes.py index dd24665..87c2908 100644 --- a/cart/bp/cart/global_routes.py +++ b/cart/bp/cart/global_routes.py @@ -256,7 +256,7 @@ def register(url_prefix: str) -> Blueprint: 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", + 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", diff --git a/cart/bp/cart/services/page_cart.py b/cart/bp/cart/services/page_cart.py index cf20f8a..efb9232 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("blog", "page-configs-batch", + raw_pcs = await fetch_data("cart", "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 d401c15..5a07c7b 100644 --- a/cart/bp/data/routes.py +++ b/cart/bp/data/routes.py @@ -111,4 +111,77 @@ 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 + 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/shared/infrastructure/oauth.py b/shared/infrastructure/oauth.py index 2121d31..ea2cd85 100644 --- a/shared/infrastructure/oauth.py +++ b/shared/infrastructure/oauth.py @@ -95,23 +95,33 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: if not code or not state or state != expected_state: current_app.logger.warning("OAuth callback: bad state or missing code") + # Set _pnone_at to prevent immediate re-trigger of prompt=none + import time as _time + qsession["_pnone_at"] = _time.time() return redirect("/") expected_redirect = app_url(app_name, "/auth/callback") now = datetime.now(timezone.utc) + # OAuthCode uses hashed storage — look up by code_hash + import hashlib + code_hash = hashlib.sha256(code.encode()).hexdigest() + # OAuthCode lives in db_account — use account session async with get_account_session() as s: async with s.begin(): result = await s.execute( select(OAuthCode) - .where(OAuthCode.code == code) + .where(OAuthCode.code_hash == code_hash) .with_for_update() ) oauth_code = result.scalar_one_or_none() if not oauth_code: current_app.logger.warning("OAuth callback: code not found") + # Set _pnone_at to prevent redirect loop + import time as _time + qsession["_pnone_at"] = _time.time() return redirect("/") if oauth_code.used_at is not None: @@ -132,7 +142,9 @@ def create_oauth_blueprint(app_name: str) -> Blueprint: oauth_code.used_at = now user_id = oauth_code.user_id - grant_token = oauth_code.grant_token + + # grant_token is passed in the redirect URL (raw, for session storage) + grant_token = request.args.get("grant_token") # Set local session with grant token for revocation checking qsession[SESSION_USER_KEY] = user_id