Compare commits
3 Commits
98aee1f656
...
e45edbf362
| Author | SHA1 | Date | |
|---|---|---|---|
| e45edbf362 | |||
| 1f3d98ecc1 | |||
| dd52417241 |
@@ -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) -------------
|
||||
|
||||
@@ -6,7 +6,6 @@ MODELS = [
|
||||
"shared.models.kv",
|
||||
"shared.models.menu_item",
|
||||
"shared.models.menu_node",
|
||||
"shared.models.container_relation",
|
||||
"blog.models.snippet",
|
||||
"blog.models.tag_group",
|
||||
]
|
||||
@@ -14,7 +13,7 @@ MODELS = [
|
||||
TABLES = frozenset({
|
||||
"posts", "authors", "post_authors", "tags", "post_tags", "post_likes",
|
||||
"snippets", "tag_groups", "tag_group_tags",
|
||||
"menu_items", "menu_nodes", "kv", "container_relations",
|
||||
"menu_items", "menu_nodes", "kv",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
|
||||
40
blog/alembic/versions/0002_drop_container_relations.py
Normal file
40
blog/alembic/versions/0002_drop_container_relations.py
Normal 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"])
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from shared.models.menu_node import MenuNode
|
||||
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):
|
||||
@@ -80,7 +80,10 @@ async def create_menu_item(
|
||||
)
|
||||
session.add(menu_node)
|
||||
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
|
||||
|
||||
@@ -131,8 +134,14 @@ async def update_menu_item(
|
||||
await session.flush()
|
||||
|
||||
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 attach_child(session, "page", post_id, "menu_node", menu_node.id)
|
||||
await call_action("cart", "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={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
|
||||
return menu_node
|
||||
|
||||
@@ -145,7 +154,10 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
||||
|
||||
menu_node.deleted_at = func.now()
|
||||
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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -5,10 +5,11 @@ 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",
|
||||
"cart_items", "orders", "order_items", "page_configs", "container_relations",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
|
||||
40
cart/alembic/versions/0003_add_container_relations.py
Normal file
40
cart/alembic/versions/0003_add_container_relations.py
Normal 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")
|
||||
@@ -47,4 +47,109 @@ 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
|
||||
|
||||
# --- 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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -111,4 +111,103 @@ 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,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from models.calendars import Calendar
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
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 re
|
||||
|
||||
@@ -71,7 +71,10 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
|
||||
|
||||
cal.deleted_at = utcnow()
|
||||
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
|
||||
|
||||
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:
|
||||
existing.deleted_at = None # revive
|
||||
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
|
||||
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)
|
||||
sess.add(cal)
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ from shared.models.market import Product
|
||||
from shared.models.market_place import MarketPlace
|
||||
from shared.browser.app.utils import utcnow
|
||||
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:
|
||||
@@ -93,7 +93,10 @@ class SqlMarketService:
|
||||
existing.deleted_at = None # revive
|
||||
existing.name = name
|
||||
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)
|
||||
raise ValueError(f'Market with slug "{slug}" already exists for this container.')
|
||||
|
||||
@@ -103,7 +106,10 @@ class SqlMarketService:
|
||||
)
|
||||
session.add(market)
|
||||
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)
|
||||
|
||||
async def soft_delete_marketplace(
|
||||
@@ -124,5 +130,8 @@ class SqlMarketService:
|
||||
|
||||
market.deleted_at = utcnow()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user