3 Commits

Author SHA1 Message Date
e45edbf362 Drop container_relations from blog DB — now lives in cart
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:51:53 +00:00
1f3d98ecc1 Move container_relations to cart service for cross-service ownership
container_relations is a generic parent/child graph used by blog
(menu_nodes), market (marketplaces), and events (calendars). Move it
to cart as shared infrastructure. All services now call cart actions
(attach-child/detach-child) instead of querying the table directly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:49:30 +00:00
dd52417241 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 <noreply@anthropic.com>
2026-02-26 17:43:21 +00:00
18 changed files with 423 additions and 196 deletions

View File

@@ -123,7 +123,7 @@ def register(url_prefix="/auth"):
sep = "&" if "?" in redirect_uri else "?" sep = "&" if "?" in redirect_uri else "?"
return redirect( return redirect(
f"{redirect_uri}{sep}code={code}&state={state}" 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) ------------- # --- OAuth2 token exchange (for external clients like artdag) -------------

View File

@@ -6,7 +6,6 @@ MODELS = [
"shared.models.kv", "shared.models.kv",
"shared.models.menu_item", "shared.models.menu_item",
"shared.models.menu_node", "shared.models.menu_node",
"shared.models.container_relation",
"blog.models.snippet", "blog.models.snippet",
"blog.models.tag_group", "blog.models.tag_group",
] ]
@@ -14,7 +13,7 @@ MODELS = [
TABLES = frozenset({ TABLES = frozenset({
"posts", "authors", "post_authors", "tags", "post_tags", "post_likes", "posts", "authors", "post_authors", "tags", "post_tags", "post_likes",
"snippets", "tag_groups", "tag_group_tags", "snippets", "tag_groups", "tag_group_tags",
"menu_items", "menu_nodes", "kv", "container_relations", "menu_items", "menu_nodes", "kv",
}) })
run_alembic(context.config, MODELS, TABLES) run_alembic(context.config, MODELS, TABLES)

View File

@@ -0,0 +1,40 @@
"""Drop container_relations table — moved to cart service.
Revision ID: blog_0002
Revises: blog_0001
Create Date: 2026-02-26
"""
import sqlalchemy as sa
from alembic import op
revision = "blog_0002"
down_revision = "blog_0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_index("ix_container_relations_child", table_name="container_relations")
op.drop_index("ix_container_relations_parent", table_name="container_relations")
op.drop_table("container_relations")
def downgrade() -> None:
op.create_table(
"container_relations",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
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),
sa.Column("label", sa.String(255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
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"])

View File

@@ -31,51 +31,13 @@ def register() -> Blueprint:
result = await handler() result = await handler()
return jsonify(result or {"ok": True}) 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(): async def _update_page_config():
"""Create or update a PageConfig (used by events payment admin).""" """Create or update a PageConfig — proxies to cart service."""
from shared.models.page_config import PageConfig from shared.infrastructure.actions import call_action
from sqlalchemy import select
data = await request.get_json(force=True) data = await request.get_json(force=True)
container_type = data.get("container_type", "page") return await call_action("cart", "update-page-config", payload=data)
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),
}
_handlers["update-page-config"] = _update_page_config _handlers["update-page-config"] = _update_page_config

View File

@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from models.ghost_content import Post, Author, Tag, PostTag from models.ghost_content import Post, Author, Tag, PostTag
from shared.models.page_config import PageConfig
from models.tag_group import TagGroup, TagGroupTag from models.tag_group import TagGroup, TagGroupTag
@@ -280,7 +279,6 @@ class DBClient:
joinedload(Post.primary_tag), joinedload(Post.primary_tag),
selectinload(Post.authors), selectinload(Post.authors),
selectinload(Post.tags), selectinload(Post.tags),
joinedload(Post.page_config),
) )
.limit(limit) .limit(limit)
.offset(offset_val) .offset(offset_val)
@@ -288,10 +286,24 @@ class DBClient:
rows: List[Post] = list((await self.sess.execute(q)).scalars()) 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]: def _page_to_public(p: Post) -> Dict[str, Any]:
d = _post_to_public(p) d = _post_to_public(p)
pc = p.page_config pc = pc_map.get(p.id)
d["features"] = pc.features if pc else {} d["features"] = pc["features"] if pc else {}
return d return d
pages_list = [_page_to_public(p) for p in rows] pages_list = [_page_to_public(p) for p in rows]

View File

@@ -7,25 +7,9 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from sqlalchemy import select 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.contracts.dtos import dto_to_dict
from shared.services.registry import services 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: def register() -> Blueprint:
@@ -90,55 +74,30 @@ def register() -> Blueprint:
_handlers["search-posts"] = _search_posts _handlers["search-posts"] = _search_posts
# --- page-config --- # --- page-config (proxy to cart, where page_configs table lives) ---
async def _page_config(): async def _page_config():
"""Return a single PageConfig by container_type + container_id.""" """Return a single PageConfig by container_type + container_id."""
ct = request.args.get("container_type", "page") return await fetch_data("cart", "page-config",
cid = request.args.get("container_id", type=int) params={"container_type": request.args.get("container_type", "page"),
if cid is None: "container_id": request.args.get("container_id", "")},
return None required=False)
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 _handlers["page-config"] = _page_config
# --- page-config-by-id --- # --- page-config-by-id (proxy to cart) ---
async def _page_config_by_id(): async def _page_config_by_id():
"""Return a single PageConfig by its primary key.""" return await fetch_data("cart", "page-config-by-id",
pc_id = request.args.get("id", type=int) params={"id": request.args.get("id", "")},
if pc_id is None: required=False)
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 _handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch --- # --- page-configs-batch (proxy to cart) ---
async def _page_configs_batch(): async def _page_configs_batch():
"""Return PageConfigs for multiple container_ids (comma-separated).""" return await fetch_data("cart", "page-configs-batch",
ct = request.args.get("container_type", "page") params={"container_type": request.args.get("container_type", "page"),
ids_raw = request.args.get("ids", "") "ids": request.args.get("ids", "")},
if not ids_raw: required=False) or []
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 _handlers["page-configs-batch"] = _page_configs_batch

View File

@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
from shared.models.menu_node import MenuNode from shared.models.menu_node import MenuNode
from models.ghost_content import Post from models.ghost_content import Post
from shared.services.relationships import attach_child, detach_child from shared.infrastructure.actions import call_action
class MenuItemError(ValueError): class MenuItemError(ValueError):
@@ -80,7 +80,10 @@ async def create_menu_item(
) )
session.add(menu_node) session.add(menu_node)
await session.flush() await session.flush()
await attach_child(session, "page", post_id, "menu_node", menu_node.id) await call_action("cart", "attach-child", payload={
"parent_type": "page", "parent_id": post_id,
"child_type": "menu_node", "child_id": menu_node.id,
})
return menu_node return menu_node
@@ -131,8 +134,14 @@ async def update_menu_item(
await session.flush() await session.flush()
if post_id is not None and post_id != old_post_id: if post_id is not None and post_id != old_post_id:
await detach_child(session, "page", old_post_id, "menu_node", menu_node.id) await call_action("cart", "detach-child", payload={
await attach_child(session, "page", post_id, "menu_node", menu_node.id) "parent_type": "page", "parent_id": old_post_id,
"child_type": "menu_node", "child_id": menu_node.id,
})
await call_action("cart", "attach-child", payload={
"parent_type": "page", "parent_id": post_id,
"child_type": "menu_node", "child_id": menu_node.id,
})
return menu_node return menu_node
@@ -145,7 +154,10 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
menu_node.deleted_at = func.now() menu_node.deleted_at = func.now()
await session.flush() await session.flush()
await detach_child(session, "page", menu_node.container_id, "menu_node", menu_node.id) await call_action("cart", "detach-child", payload={
"parent_type": "page", "parent_id": menu_node.container_id,
"child_type": "menu_node", "child_id": menu_node.id,
})
return True return True

View File

@@ -22,24 +22,23 @@ def register():
@require_admin @require_admin
async def admin(slug: str): async def admin(slug: str):
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.models.page_config import PageConfig from shared.infrastructure.data_client import fetch_data
from sqlalchemy import select as sa_select
# Load features for page admin # Load features for page admin (page_configs lives in db_cart)
post = (g.post_data or {}).get("post", {}) post = (g.post_data or {}).get("post", {})
features = {} features = {}
sumup_configured = False sumup_configured = False
sumup_merchant_code = "" sumup_merchant_code = ""
sumup_checkout_prefix = "" sumup_checkout_prefix = ""
if post.get("is_page"): if post.get("is_page"):
pc = (await g.s.execute( raw_pc = await fetch_data("cart", "page-config",
sa_select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post["id"]) params={"container_type": "page", "container_id": post["id"]},
)).scalar_one_or_none() required=False)
if pc: if raw_pc:
features = pc.features or {} features = raw_pc.get("features") or {}
sumup_configured = bool(pc.sumup_api_key) sumup_configured = bool(raw_pc.get("sumup_api_key"))
sumup_merchant_code = pc.sumup_merchant_code or "" sumup_merchant_code = raw_pc.get("sumup_merchant_code") or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or "" sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or ""
ctx = { ctx = {
"features": features, "features": features,
@@ -61,12 +60,9 @@ def register():
@bp.put("/features/") @bp.put("/features/")
@require_admin @require_admin
async def update_features(slug: str): async def update_features(slug: str):
"""Update PageConfig.features for a page.""" """Update PageConfig.features for a page (page_configs lives in db_cart)."""
from shared.models.page_config import PageConfig from shared.infrastructure.actions import call_action
from models.ghost_content import Post
from sqlalchemy import select as sa_select
from quart import jsonify from quart import jsonify
import json
post = g.post_data.get("post") post = g.post_data.get("post")
if not post or not post.get("is_page"): if not post or not post.get("is_page"):
@@ -74,21 +70,9 @@ def register():
post_id = post["id"] 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 # Parse request body
body = await request.get_json() body = await request.get_json()
if body is None: if body is None:
# Fall back to form data
form = await request.form form = await request.form
body = {} body = {}
for key in ("calendar", "market"): for key in ("calendar", "market"):
@@ -99,38 +83,30 @@ def register():
if not isinstance(body, dict): if not isinstance(body, dict):
return jsonify({"error": "Expected JSON object with feature flags."}), 400 return jsonify({"error": "Expected JSON object with feature flags."}), 400
# Merge features # Update via cart action (page_configs lives in db_cart)
features = dict(pc.features or {}) result = await call_action("cart", "update-page-config", payload={
for key, val in body.items(): "container_type": "page",
if isinstance(val, bool): "container_id": post_id,
features[key] = val "features": body,
elif val in ("true", "1", "on"): })
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features features = result.get("features", {})
from sqlalchemy.orm.attributes import flag_modified
flag_modified(pc, "features")
await g.s.flush()
# Return updated features panel
html = await render_template( html = await render_template(
"_types/post/admin/_features_panel.html", "_types/post/admin/_features_panel.html",
features=features, features=features,
post=post, post=post,
sumup_configured=bool(pc.sumup_api_key), sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=pc.sumup_merchant_code or "", sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=pc.sumup_checkout_prefix or "", sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
) )
return await make_response(html) return await make_response(html)
@bp.put("/admin/sumup/") @bp.put("/admin/sumup/")
@require_admin @require_admin
async def update_sumup(slug: str): async def update_sumup(slug: str):
"""Update PageConfig SumUp credentials for a page.""" """Update PageConfig SumUp credentials for a page (page_configs lives in db_cart)."""
from shared.models.page_config import PageConfig from shared.infrastructure.actions import call_action
from sqlalchemy import select as sa_select
from quart import jsonify from quart import jsonify
post = g.post_data.get("post") post = g.post_data.get("post")
@@ -139,37 +115,30 @@ def register():
post_id = post["id"] 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 form = await request.form
merchant_code = (form.get("merchant_code") or "").strip() merchant_code = (form.get("merchant_code") or "").strip()
api_key = (form.get("api_key") or "").strip() api_key = (form.get("api_key") or "").strip()
checkout_prefix = (form.get("checkout_prefix") or "").strip() checkout_prefix = (form.get("checkout_prefix") or "").strip()
pc.sumup_merchant_code = merchant_code or None payload: dict = {
pc.sumup_checkout_prefix = checkout_prefix or None "container_type": "page",
# Only update API key if non-empty (allows updating other fields without re-entering key) "container_id": post_id,
"sumup_merchant_code": merchant_code or None,
"sumup_checkout_prefix": checkout_prefix or None,
}
if api_key: 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( html = await render_template(
"_types/post/admin/_features_panel.html", "_types/post/admin/_features_panel.html",
features=features, features=features,
post=post, post=post,
sumup_configured=bool(pc.sumup_api_key), sumup_configured=result.get("sumup_configured", False),
sumup_merchant_code=pc.sumup_merchant_code or "", sumup_merchant_code=result.get("sumup_merchant_code") or "",
sumup_checkout_prefix=pc.sumup_checkout_prefix or "", sumup_checkout_prefix=result.get("sumup_checkout_prefix") or "",
) )
return await make_response(html) return await make_response(html)

View File

@@ -3,12 +3,11 @@ from __future__ import annotations
import re import re
import unicodedata import unicodedata
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.page_config import PageConfig
from shared.contracts.dtos import MarketPlaceDTO from shared.contracts.dtos import MarketPlaceDTO
from shared.infrastructure.actions import call_action, ActionError from shared.infrastructure.actions import call_action, ActionError
from shared.infrastructure.data_client import fetch_data
from shared.services.registry import services 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: if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.") raise MarketError("Markets can only be created on pages, not posts.")
pc = (await sess.execute( raw_pc = await fetch_data("cart", "page-config",
select(PageConfig).where(PageConfig.container_type == "page", PageConfig.container_id == post_id) params={"container_type": "page", "container_id": post_id},
)).scalar_one_or_none() required=False)
if pc is None or not (pc.features or {}).get("market"): 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.") raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
try: try:

View File

@@ -5,10 +5,11 @@ MODELS = [
"shared.models.market", # CartItem lives here "shared.models.market", # CartItem lives here
"shared.models.order", "shared.models.order",
"shared.models.page_config", "shared.models.page_config",
"shared.models.container_relation",
] ]
TABLES = frozenset({ TABLES = frozenset({
"cart_items", "orders", "order_items", "page_configs", "cart_items", "orders", "order_items", "page_configs", "container_relations",
}) })
run_alembic(context.config, MODELS, TABLES) run_alembic(context.config, MODELS, TABLES)

View File

@@ -0,0 +1,40 @@
"""Add container_relations table to cart DB.
Generic parent/child graph for inter-service container relationships
(page→market, page→calendar, page→menu_node, etc.).
Revision ID: cart_0003
Revises: cart_0002
Create Date: 2026-02-26
"""
import sqlalchemy as sa
from alembic import op
revision = "cart_0003"
down_revision = "cart_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
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), server_default=sa.func.now(), nullable=False),
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() -> None:
op.drop_table("container_relations")

View File

@@ -47,4 +47,109 @@ def register() -> Blueprint:
_handlers["adopt-cart-for-user"] = _adopt_cart _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
# --- 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 return bp

View File

@@ -256,7 +256,7 @@ def register(url_prefix: str) -> Blueprint:
if order.page_config_id: if order.page_config_id:
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict 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}, params={"id": order.page_config_id},
required=False) required=False)
post = await fetch_data("blog", "post-by-id", post = await fetch_data("blog", "post-by-id",

View File

@@ -178,7 +178,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
p = dto_from_dict(PostDTO, raw_p) p = dto_from_dict(PostDTO, raw_p)
posts_by_id[p.id] = 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", params={"container_type": "page",
"ids": ",".join(str(i) for i in post_ids)}, "ids": ",".join(str(i) for i in post_ids)},
required=False) or [] required=False) or []

View File

@@ -111,4 +111,103 @@ def register() -> Blueprint:
_handlers["cart-items"] = _cart_items _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 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,
}

View File

@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar from models.calendars import Calendar
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.relationships import attach_child, detach_child from shared.infrastructure.actions import call_action
import unicodedata import unicodedata
import re import re
@@ -71,7 +71,10 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
cal.deleted_at = utcnow() cal.deleted_at = utcnow()
await sess.flush() await sess.flush()
await detach_child(sess, "page", cal.container_id, "calendar", cal.id) await call_action("cart", "detach-child", payload={
"parent_type": "page", "parent_id": cal.container_id,
"child_type": "calendar", "child_id": cal.id,
})
return True return True
async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar: async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calendar:
@@ -105,14 +108,20 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
if existing.deleted_at is not None: if existing.deleted_at is not None:
existing.deleted_at = None # revive existing.deleted_at = None # revive
await sess.flush() await sess.flush()
await attach_child(sess, "page", post_id, "calendar", existing.id) await call_action("cart", "attach-child", payload={
"parent_type": "page", "parent_id": post_id,
"child_type": "calendar", "child_id": existing.id,
})
return existing return existing
raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.') raise CalendarError(f'Calendar with slug "{slug}" already exists for post {post_id}.')
cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug) cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
sess.add(cal) sess.add(cal)
await sess.flush() await sess.flush()
await attach_child(sess, "page", post_id, "calendar", cal.id) await call_action("cart", "attach-child", payload={
"parent_type": "page", "parent_id": post_id,
"child_type": "calendar", "child_id": cal.id,
})
return cal return cal

View File

@@ -95,23 +95,33 @@ def create_oauth_blueprint(app_name: str) -> Blueprint:
if not code or not state or state != expected_state: if not code or not state or state != expected_state:
current_app.logger.warning("OAuth callback: bad state or missing code") 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("/") return redirect("/")
expected_redirect = app_url(app_name, "/auth/callback") expected_redirect = app_url(app_name, "/auth/callback")
now = datetime.now(timezone.utc) 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 # OAuthCode lives in db_account — use account session
async with get_account_session() as s: async with get_account_session() as s:
async with s.begin(): async with s.begin():
result = await s.execute( result = await s.execute(
select(OAuthCode) select(OAuthCode)
.where(OAuthCode.code == code) .where(OAuthCode.code_hash == code_hash)
.with_for_update() .with_for_update()
) )
oauth_code = result.scalar_one_or_none() oauth_code = result.scalar_one_or_none()
if not oauth_code: if not oauth_code:
current_app.logger.warning("OAuth callback: code not found") 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("/") return redirect("/")
if oauth_code.used_at is not None: 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 oauth_code.used_at = now
user_id = oauth_code.user_id 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 # Set local session with grant token for revocation checking
qsession[SESSION_USER_KEY] = user_id qsession[SESSION_USER_KEY] = user_id

View File

@@ -12,7 +12,7 @@ from shared.models.market import Product
from shared.models.market_place import MarketPlace from shared.models.market_place import MarketPlace
from shared.browser.app.utils import utcnow from shared.browser.app.utils import utcnow
from shared.contracts.dtos import MarketPlaceDTO, ProductDTO from shared.contracts.dtos import MarketPlaceDTO, ProductDTO
from shared.services.relationships import attach_child, detach_child from shared.infrastructure.actions import call_action
def _mp_to_dto(mp: MarketPlace) -> MarketPlaceDTO: def _mp_to_dto(mp: MarketPlace) -> MarketPlaceDTO:
@@ -93,7 +93,10 @@ class SqlMarketService:
existing.deleted_at = None # revive existing.deleted_at = None # revive
existing.name = name existing.name = name
await session.flush() await session.flush()
await attach_child(session, container_type, container_id, "market", existing.id) await call_action("cart", "attach-child", payload={
"parent_type": container_type, "parent_id": container_id,
"child_type": "market", "child_id": existing.id,
})
return _mp_to_dto(existing) return _mp_to_dto(existing)
raise ValueError(f'Market with slug "{slug}" already exists for this container.') raise ValueError(f'Market with slug "{slug}" already exists for this container.')
@@ -103,7 +106,10 @@ class SqlMarketService:
) )
session.add(market) session.add(market)
await session.flush() await session.flush()
await attach_child(session, container_type, container_id, "market", market.id) await call_action("cart", "attach-child", payload={
"parent_type": container_type, "parent_id": container_id,
"child_type": "market", "child_id": market.id,
})
return _mp_to_dto(market) return _mp_to_dto(market)
async def soft_delete_marketplace( async def soft_delete_marketplace(
@@ -124,5 +130,8 @@ class SqlMarketService:
market.deleted_at = utcnow() market.deleted_at = utcnow()
await session.flush() await session.flush()
await detach_child(session, container_type, container_id, "market", market.id) await call_action("cart", "detach-child", payload={
"parent_type": container_type, "parent_id": container_id,
"child_type": "market", "child_id": market.id,
})
return True return True