Split cart into 4 microservices: relations, likes, orders, page-config→blog
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Phase 1 - Relations service (internal): owns ContainerRelation, exposes
get-children data + attach/detach-child actions. Retargeted events, blog,
market callers from cart to relations.

Phase 2 - Likes service (internal): unified Like model replaces ProductLike
and PostLike with generic target_type/target_slug/target_id. Exposes
is-liked, liked-slugs, liked-ids data + toggle action.

Phase 3 - PageConfig → blog: moved ownership to blog with direct DB queries,
removed proxy endpoints from cart.

Phase 4 - Orders service (public): owns Order/OrderItem + SumUp checkout
flow. Cart checkout now delegates to orders via create-order action.
Webhook/return routes and reconciliation moved to orders.

Phase 5 - Infrastructure: docker-compose, deploy.sh, Dockerfiles updated
for all 3 new services. Added orders_url helper and factory model imports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 09:03:33 +00:00
parent 76a9436ea1
commit fa431ee13e
125 changed files with 3459 additions and 860 deletions

View File

@@ -38,6 +38,12 @@ COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/ COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/ COPY federation/models/ ./federation/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY account/entrypoint.sh /usr/local/bin/entrypoint.sh COPY account/entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -46,6 +46,12 @@ COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/ COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/ COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# Copy built editor assets from stage 1 # Copy built editor assets from stage 1
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/ COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/

View File

@@ -6,14 +6,16 @@ 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.page_config",
"blog.models.snippet", "blog.models.snippet",
"blog.models.tag_group", "blog.models.tag_group",
] ]
TABLES = frozenset({ TABLES = frozenset({
"posts", "authors", "post_authors", "tags", "post_tags", "post_likes", "posts", "authors", "post_authors", "tags", "post_tags",
"snippets", "tag_groups", "tag_group_tags", "snippets", "tag_groups", "tag_group_tags",
"menu_items", "menu_nodes", "kv", "menu_items", "menu_nodes", "kv",
"page_configs",
}) })
run_alembic(context.config, MODELS, TABLES) run_alembic(context.config, MODELS, TABLES)

View File

@@ -31,13 +31,65 @@ def register() -> Blueprint:
result = await handler() result = await handler()
return jsonify(result or {"ok": True}) return jsonify(result or {"ok": True})
# --- update-page-config (proxy to cart, where page_configs table lives) --- # --- update-page-config ---
async def _update_page_config(): async def _update_page_config():
"""Create or update a PageConfig — proxies to cart service.""" """Create or update a PageConfig (page_configs now lives in db_blog)."""
from shared.infrastructure.actions import call_action 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) data = await request.get_json(force=True)
return await call_action("cart", "update-page-config", payload=data) container_type = data.get("container_type", "page")
container_id = data.get("container_id")
if container_id is None:
return {"error": "container_id required"}, 400
pc = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if pc is None:
pc = PageConfig(
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "features" in data:
features = dict(pc.features or {})
for key, val in data["features"].items():
if isinstance(val, bool):
features[key] = val
elif val in ("true", "1", "on"):
features[key] = True
elif val in ("false", "0", "off", None):
features[key] = False
pc.features = features
flag_modified(pc, "features")
if "sumup_merchant_code" in data:
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
if "sumup_checkout_prefix" in data:
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
if "sumup_api_key" in data:
pc.sumup_api_key = data["sumup_api_key"] or None
await g.s.flush()
return {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
"sumup_configured": bool(pc.sumup_api_key),
}
_handlers["update-page-config"] = _update_page_config _handlers["update-page-config"] = _update_page_config

View File

@@ -286,17 +286,28 @@ 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) # Load PageConfigs directly (page_configs now lives in db_blog)
post_ids = [p.id for p in rows] post_ids = [p.id for p in rows]
pc_map: Dict[int, dict] = {} pc_map: Dict[int, dict] = {}
if post_ids: if post_ids:
from shared.infrastructure.data_client import fetch_data from shared.models.page_config import PageConfig
try: try:
raw_pcs = await fetch_data("cart", "page-configs-batch", pc_result = await self.sess.execute(
params={"container_type": "page", "ids": ",".join(str(i) for i in post_ids)}) select(PageConfig).where(
if isinstance(raw_pcs, list): PageConfig.container_type == "page",
for pc in raw_pcs: PageConfig.container_id.in_(post_ids),
pc_map[pc["container_id"]] = pc )
)
for pc in pc_result.scalars().all():
pc_map[pc.container_id] = {
"id": pc.id,
"container_type": pc.container_type,
"container_id": pc.container_id,
"features": pc.features or {},
"sumup_merchant_code": pc.sumup_merchant_code,
"sumup_api_key": pc.sumup_api_key,
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
}
except Exception: except Exception:
pass # graceful degradation — pages render without features pass # graceful degradation — pages render without features

View File

@@ -1,8 +1,7 @@
import re import re
from ..ghost_db import DBClient # adjust import path from ..ghost_db import DBClient # adjust import path
from sqlalchemy import select from shared.infrastructure.data_client import fetch_data
from models.ghost_content import PostLike
from shared.infrastructure.fragments import fetch_fragment from shared.infrastructure.fragments import fetch_fragment
from quart import g from quart import g
@@ -68,22 +67,15 @@ async def posts_data(
post_ids = [p["id"] for p in posts] post_ids = [p["id"] for p in posts]
# Add is_liked field to each post for current user # Add is_liked field to each post for current user
if g.user: if g.user and post_ids:
# Fetch all likes for this user and these posts in one query liked_ids_list = await fetch_data("likes", "liked-ids", params={
liked_posts = await session.execute( "user_id": g.user.id, "target_type": "post",
select(PostLike.post_id).where( }, required=False) or []
PostLike.user_id == g.user.id, liked_post_ids = set(liked_ids_list)
PostLike.post_id.in_(post_ids),
PostLike.deleted_at.is_(None),
)
)
liked_post_ids = {row[0] for row in liked_posts}
# Add is_liked to each post
for post in posts: for post in posts:
post["is_liked"] = post["id"] in liked_post_ids post["is_liked"] = post["id"] in liked_post_ids
else: else:
# Not logged in - no posts are liked
for post in posts: for post in posts:
post["is_liked"] = False post["is_liked"] = False

View File

@@ -7,7 +7,7 @@ from __future__ import annotations
from quart import Blueprint, g, jsonify, request from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER, fetch_data from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services from shared.services.registry import services
@@ -74,31 +74,112 @@ def register() -> Blueprint:
_handlers["search-posts"] = _search_posts _handlers["search-posts"] = _search_posts
# --- page-config (proxy to cart, where page_configs table lives) --- # --- page-config-ensure ---
async def _page_config_ensure():
"""Get or create a PageConfig for a container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
container_type = request.args.get("container_type", "page")
container_id = request.args.get("container_id", type=int)
if container_id is None:
return {"error": "container_id required"}, 400
row = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
g.s.add(row)
await g.s.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
_handlers["page-config-ensure"] = _page_config_ensure
# --- page-config ---
async def _page_config(): async def _page_config():
"""Return a single PageConfig by container_type + container_id.""" """Return a single PageConfig by container_type + container_id."""
return await fetch_data("cart", "page-config", from sqlalchemy import select
params={"container_type": request.args.get("container_type", "page"), from shared.models.page_config import PageConfig
"container_id": request.args.get("container_id", "")},
required=False) 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 _handlers["page-config"] = _page_config
# --- page-config-by-id (proxy to cart) --- # --- page-config-by-id ---
async def _page_config_by_id(): async def _page_config_by_id():
return await fetch_data("cart", "page-config-by-id", """Return a single PageConfig by its primary key."""
params={"id": request.args.get("id", "")}, from shared.models.page_config import PageConfig
required=False)
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 _handlers["page-config-by-id"] = _page_config_by_id
# --- page-configs-batch (proxy to cart) --- # --- page-configs-batch ---
async def _page_configs_batch(): async def _page_configs_batch():
return await fetch_data("cart", "page-configs-batch", """Return PageConfigs for multiple container_ids (comma-separated)."""
params={"container_type": request.args.get("container_type", "page"), from sqlalchemy import select
"ids": request.args.get("ids", "")}, from shared.models.page_config import PageConfig
required=False) or []
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 _handlers["page-configs-batch"] = _page_configs_batch
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

@@ -80,7 +80,7 @@ async def create_menu_item(
) )
session.add(menu_node) session.add(menu_node)
await session.flush() await session.flush()
await call_action("cart", "attach-child", payload={ await call_action("relations", "attach-child", payload={
"parent_type": "page", "parent_id": post_id, "parent_type": "page", "parent_id": post_id,
"child_type": "menu_node", "child_id": menu_node.id, "child_type": "menu_node", "child_id": menu_node.id,
}) })
@@ -134,11 +134,11 @@ 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 call_action("cart", "detach-child", payload={ await call_action("relations", "detach-child", payload={
"parent_type": "page", "parent_id": old_post_id, "parent_type": "page", "parent_id": old_post_id,
"child_type": "menu_node", "child_id": menu_node.id, "child_type": "menu_node", "child_id": menu_node.id,
}) })
await call_action("cart", "attach-child", payload={ await call_action("relations", "attach-child", payload={
"parent_type": "page", "parent_id": post_id, "parent_type": "page", "parent_id": post_id,
"child_type": "menu_node", "child_id": menu_node.id, "child_type": "menu_node", "child_id": menu_node.id,
}) })
@@ -154,7 +154,7 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
menu_node.deleted_at = func.now() menu_node.deleted_at = func.now()
await session.flush() await session.flush()
await call_action("cart", "detach-child", payload={ await call_action("relations", "detach-child", payload={
"parent_type": "page", "parent_id": menu_node.container_id, "parent_type": "page", "parent_id": menu_node.container_id,
"child_type": "menu_node", "child_id": menu_node.id, "child_type": "menu_node", "child_id": menu_node.id,
}) })

View File

@@ -22,23 +22,27 @@ 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.infrastructure.data_client import fetch_data from sqlalchemy import select
from shared.models.page_config import PageConfig
# Load features for page admin (page_configs lives in db_cart) # Load features for page admin (page_configs now lives in db_blog)
post = (g.post_data or {}).get("post", {}) 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"):
raw_pc = await fetch_data("cart", "page-config", pc = (await g.s.execute(
params={"container_type": "page", "container_id": post["id"]}, select(PageConfig).where(
required=False) PageConfig.container_type == "page",
if raw_pc: PageConfig.container_id == post["id"],
features = raw_pc.get("features") or {} )
sumup_configured = bool(raw_pc.get("sumup_api_key")) )).scalar_one_or_none()
sumup_merchant_code = raw_pc.get("sumup_merchant_code") or "" if pc:
sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or "" features = pc.features or {}
sumup_configured = bool(pc.sumup_api_key)
sumup_merchant_code = pc.sumup_merchant_code or ""
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
ctx = { ctx = {
"features": features, "features": features,
@@ -84,7 +88,7 @@ def register():
return jsonify({"error": "Expected JSON object with feature flags."}), 400 return jsonify({"error": "Expected JSON object with feature flags."}), 400
# Update via cart action (page_configs lives in db_cart) # Update via cart action (page_configs lives in db_cart)
result = await call_action("cart", "update-page-config", payload={ result = await call_action("blog", "update-page-config", payload={
"container_type": "page", "container_type": "page",
"container_id": post_id, "container_id": post_id,
"features": body, "features": body,
@@ -129,7 +133,7 @@ def register():
if api_key: if api_key:
payload["sumup_api_key"] = api_key payload["sumup_api_key"] = api_key
result = await call_action("cart", "update-page-config", payload=payload) result = await call_action("blog", "update-page-config", payload=payload)
features = result.get("features", {}) features = result.get("features", {})
html = await render_template( html = await render_template(

View File

@@ -11,8 +11,8 @@ from quart import (
request, request,
) )
from .services.post_data import post_data from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from shared.infrastructure.data_client import fetch_data from shared.infrastructure.data_client import fetch_data
from shared.infrastructure.actions import call_action
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
@@ -144,11 +144,10 @@ def register():
post_id = g.post_data["post"]["id"] post_id = g.post_data["post"]["id"]
user_id = g.user.id user_id = g.user.id
liked, error = await toggle_post_like(g.s, user_id, post_id) result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "post", "target_id": post_id,
if error: })
resp = make_response(error, 404) liked = result["liked"]
return resp
html = await render_template( html = await render_template(
"_types/browse/like/button.html", "_types/browse/like/button.html",

View File

@@ -41,7 +41,7 @@ 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.")
raw_pc = await fetch_data("cart", "page-config", raw_pc = await fetch_data("blog", "page-config",
params={"container_type": "page", "container_id": post_id}, params={"container_type": "page", "container_id": post_id},
required=False) required=False)
if raw_pc is None or not (raw_pc.get("features") or {}).get("market"): if raw_pc is None or not (raw_pc.get("features") or {}).get("market"):

View File

@@ -1,6 +1,5 @@
from ...blog.ghost_db import DBClient # adjust import path from ...blog.ghost_db import DBClient # adjust import path
from sqlalchemy import select from shared.infrastructure.data_client import fetch_data
from models.ghost_content import PostLike
from quart import g from quart import g
async def post_data(slug, session, include_drafts=False): async def post_data(slug, session, include_drafts=False):
@@ -15,14 +14,10 @@ async def post_data(slug, session, include_drafts=False):
# Check if current user has liked this post # Check if current user has liked this post
is_liked = False is_liked = False
if g.user: if g.user:
liked_record = await session.scalar( liked_data = await fetch_data("likes", "is-liked", params={
select(PostLike).where( "user_id": g.user.id, "target_type": "post", "target_id": post["id"],
PostLike.user_id == g.user.id, }, required=False)
PostLike.post_id == post["id"], is_liked = (liked_data or {}).get("liked", False)
PostLike.deleted_at.is_(None),
)
)
is_liked = liked_record is not None
# Add is_liked to post dict # Add is_liked to post dict
post["is_liked"] = is_liked post["is_liked"] = is_liked

View File

@@ -1,58 +0,0 @@
from __future__ import annotations
from typing import Optional
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.ghost_content import Post, PostLike
async def toggle_post_like(
session: AsyncSession,
user_id: int,
post_id: int,
) -> tuple[bool, Optional[str]]:
"""
Toggle a post like for a given user using soft deletes.
Returns (liked_state, error_message).
- If error_message is not None, an error occurred.
- liked_state indicates whether post is now liked (True) or unliked (False).
"""
# Verify post exists
post_exists = await session.scalar(
select(Post.id).where(Post.id == post_id, Post.deleted_at.is_(None))
)
if not post_exists:
return False, "Post not found"
# Check if like exists (not deleted)
existing = await session.scalar(
select(PostLike).where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
)
if existing:
# Unlike: soft delete the like
await session.execute(
update(PostLike)
.where(
PostLike.user_id == user_id,
PostLike.post_id == post_id,
PostLike.deleted_at.is_(None),
)
.values(deleted_at=func.now())
)
return False, None
else:
# Like: add a new like
new_like = PostLike(
user_id=user_id,
post_id=post_id,
)
session.add(new_like)
return True, None

View File

@@ -1,4 +1,4 @@
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
from .snippet import Snippet from .snippet import Snippet
from .tag_group import TagGroup, TagGroupTag from .tag_group import TagGroup, TagGroupTag

View File

@@ -1,3 +1,3 @@
from shared.models.ghost_content import ( # noqa: F401 from shared.models.ghost_content import ( # noqa: F401
Tag, Post, Author, PostAuthor, PostTag, PostLike, Tag, Post, Author, PostAuthor, PostTag,
) )

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
def register_domain_services() -> None: def register_domain_services() -> None:
"""Register services for the blog app. """Register services for the blog app.
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike. Blog owns: Post, Tag, Author, PostAuthor, PostTag.
Cross-app calls go over HTTP via call_action() / fetch_data(). Cross-app calls go over HTTP via call_action() / fetch_data().
""" """
from shared.services.registry import services from shared.services.registry import services

View File

@@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/ COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/ COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY cart/entrypoint.sh /usr/local/bin/entrypoint.sh COPY cart/entrypoint.sh /usr/local/bin/entrypoint.sh

View File

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

View File

@@ -15,7 +15,6 @@ from bp import (
register_cart_overview, register_cart_overview,
register_page_cart, register_page_cart,
register_cart_global, register_cart_global,
register_orders,
register_fragments, register_fragments,
register_actions, register_actions,
register_data, register_data,
@@ -121,7 +120,6 @@ def _make_page_config(raw: dict) -> SimpleNamespace:
def create_app() -> "Quart": def create_app() -> "Quart":
from shared.services.registry import services
from services import register_domain_services from services import register_domain_services
app = create_base_app( app = create_base_app(
@@ -184,10 +182,7 @@ def create_app() -> "Quart":
# --- Blueprint registration --- # --- Blueprint registration ---
# Static prefixes first, dynamic (page_slug) last # Static prefixes first, dynamic (page_slug) last
# Orders blueprint # Global routes (add, quantity, delete, checkout — specific paths under /)
app.register_blueprint(register_orders(url_prefix="/orders"))
# Global routes (webhook, return, add — specific paths under /)
app.register_blueprint( app.register_blueprint(
register_cart_global(url_prefix="/"), register_cart_global(url_prefix="/"),
url_prefix="/", url_prefix="/",
@@ -205,65 +200,6 @@ def create_app() -> "Quart":
url_prefix="/<page_slug>", url_prefix="/<page_slug>",
) )
# --- Reconcile stale pending orders on startup ---
@app.before_serving
async def _reconcile_pending_orders():
"""Check SumUp status for orders stuck in 'pending' with a checkout ID.
Handles the case where SumUp webhooks fired while the service was down
or were rejected (e.g. CSRF). Runs once on boot.
"""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select as sel
from shared.db.session import get_session
from shared.models.order import Order
from shared.infrastructure.data_client import fetch_data
from bp.cart.services.check_sumup_status import check_sumup_status
log = logging.getLogger("cart.reconcile")
try:
async with get_session() as sess:
async with sess.begin():
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
result = await sess.execute(
sel(Order)
.where(
Order.status == "pending",
Order.sumup_checkout_id.isnot(None),
Order.created_at < cutoff,
)
.limit(50)
)
stale_orders = result.scalars().all()
if not stale_orders:
return
log.info("Reconciling %d stale pending orders", len(stale_orders))
for order in stale_orders:
try:
# Fetch page_config from blog if order has one
pc = None
if order.page_config_id:
raw_pc = await fetch_data(
"blog", "page-config-by-id",
params={"id": order.page_config_id},
required=False,
)
if raw_pc:
pc = _make_page_config(raw_pc)
await check_sumup_status(sess, order, page_config=pc)
log.info(
"Order %d reconciled: %s",
order.id, order.status,
)
except Exception:
log.exception("Failed to reconcile order %d", order.id)
except Exception:
log.exception("Order reconciliation failed")
return app return app

View File

@@ -1,8 +1,6 @@
from .cart.overview_routes import register as register_cart_overview from .cart.overview_routes import register as register_cart_overview
from .cart.page_routes import register as register_page_cart from .cart.page_routes import register as register_page_cart
from .cart.global_routes import register as register_cart_global from .cart.global_routes import register as register_cart_global
from .order.routes import register as register_order
from .orders.routes import register as register_orders
from .fragments import register_fragments from .fragments import register_fragments
from .actions import register_actions from .actions import register_actions
from .data import register_data from .data import register_data

View File

@@ -47,109 +47,26 @@ def register() -> Blueprint:
_handlers["adopt-cart-for-user"] = _adopt_cart _handlers["adopt-cart-for-user"] = _adopt_cart
# --- update-page-config --- # --- clear-cart-for-order ---
async def _update_page_config(): async def _clear_cart_for_order():
"""Create or update a PageConfig (page_configs lives in db_cart).""" """Soft-delete cart items after an order is paid. Called by orders service."""
from shared.models.page_config import PageConfig from bp.cart.services.clear_cart_for_order import clear_cart_for_order
from sqlalchemy import select from shared.models.order import Order
from sqlalchemy.orm.attributes import flag_modified
data = await request.get_json(force=True) data = await request.get_json()
container_type = data.get("container_type", "page") user_id = data.get("user_id")
container_id = data.get("container_id") session_id = data.get("session_id")
if container_id is None: page_post_id = data.get("page_post_id")
return {"error": "container_id required"}, 400
pc = (await g.s.execute( # Build a minimal order-like object with the fields clear_cart_for_order needs
select(PageConfig).where( order = type("_Order", (), {
PageConfig.container_type == container_type, "user_id": user_id,
PageConfig.container_id == container_id, "session_id": session_id,
) })()
)).scalar_one_or_none()
if pc is None: await clear_cart_for_order(g.s, order, page_post_id=page_post_id)
pc = PageConfig( return {"ok": True}
container_type=container_type,
container_id=container_id,
features=data.get("features", {}),
)
g.s.add(pc)
await g.s.flush()
if "features" in data: _handlers["clear-cart-for-order"] = _clear_cart_for_order
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

@@ -1,4 +1,4 @@
# bp/cart/global_routes.py — Global cart routes (webhook, return, add) # bp/cart/global_routes.py — Global cart routes (add, quantity, delete, checkout)
from __future__ import annotations from __future__ import annotations
@@ -6,7 +6,6 @@ from quart import Blueprint, g, request, render_template, redirect, url_for, mak
from sqlalchemy import select from sqlalchemy import select
from shared.models.market import CartItem from shared.models.market import CartItem
from shared.models.order import Order
from shared.infrastructure.actions import call_action from shared.infrastructure.actions import call_action
from .services import ( from .services import (
current_cart_identity, current_cart_identity,
@@ -16,20 +15,11 @@ from .services import (
calendar_total, calendar_total,
get_ticket_cart_entries, get_ticket_cart_entries,
ticket_total, ticket_total,
check_sumup_status,
) )
from .services.checkout import ( from .services.checkout import (
find_or_create_cart_item, find_or_create_cart_item,
create_order_from_cart,
resolve_page_config, resolve_page_config,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
validate_webhook_secret,
get_order_with_details,
) )
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.browser.app.csrf import csrf_exempt
def register(url_prefix: str) -> Blueprint: def register(url_prefix: str) -> Blueprint:
@@ -141,7 +131,7 @@ def register(url_prefix: str) -> Blueprint:
@bp.post("/checkout/") @bp.post("/checkout/")
async def checkout(): async def checkout():
"""Legacy global checkout (for orphan items without page scope).""" """Global checkout — delegates order creation to orders service."""
cart = await get_cart(g.s) cart = await get_cart(g.s)
calendar_entries = await get_calendar_cart_entries(g.s) calendar_entries = await get_calendar_cart_entries(g.s)
tickets = await get_ticket_cart_entries(g.s) tickets = await get_ticket_cart_entries(g.s)
@@ -168,151 +158,63 @@ def register(url_prefix: str) -> Blueprint:
return await make_response(html, 400) return await make_response(html, 400)
ident = current_cart_identity() ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
calendar_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
)
# Serialize cart items for the orders service
cart_items_data = []
for ci in cart:
cart_items_data.append({
"product_id": ci.product_id,
"product_title": ci.product_title,
"product_slug": ci.product_slug,
"product_image": ci.product_image,
"product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None,
"product_special_price": float(ci.product_special_price) if ci.product_special_price else None,
"product_price_currency": ci.product_price_currency,
"quantity": ci.quantity,
})
# Serialize calendar entries and tickets
cal_data = []
for e in calendar_entries:
cal_data.append({
"id": e.id,
"calendar_container_id": getattr(e, "calendar_container_id", None),
})
ticket_data = []
for t in tickets:
ticket_data.append({
"id": t.id,
"calendar_container_id": getattr(t, "calendar_container_id", None),
})
page_post_id = None
if page_config: if page_config:
order.page_config_id = page_config.id page_post_id = getattr(page_config, "container_id", None)
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True) result = await call_action("orders", "create-order", payload={
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config) "cart_items": cart_items_data,
description = build_sumup_description(cart, order.id, ticket_count=len(tickets)) "calendar_entries": cal_data,
"tickets": ticket_data,
"user_id": ident.get("user_id"),
"session_id": ident.get("session_id"),
"product_total": float(product_total),
"calendar_total": float(calendar_amount),
"ticket_total": float(ticket_amount),
"page_post_id": page_post_id,
})
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True) # Update redirect/webhook URLs with real order_id
webhook_url = build_webhook_url(webhook_base_url) order_id = result["order_id"]
hosted_url = result.get("sumup_hosted_url")
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url: if not hosted_url:
html = await render_template( html = await render_template(
"_types/cart/checkout_error.html", "_types/cart/checkout_error.html",
order=order, order=None,
error="No hosted checkout URL returned from SumUp.", error="No hosted checkout URL returned from SumUp.",
) )
return await make_response(html, 500) return await make_response(html, 500)
return redirect(hosted_url) return redirect(hosted_url)
@csrf_exempt
@bp.post("/checkout/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int):
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
if not validate_webhook_secret(request.args.get("token")):
return "", 204
try:
payload = await request.get_json()
except Exception:
payload = None
if not isinstance(payload, dict):
return "", 204
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
return "", 204
checkout_id = payload.get("id")
if not checkout_id:
return "", 204
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return "", 204
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
return "", 204
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return "", 204
@bp.get("/checkout/return/<int:order_id>/")
async def checkout_return(order_id: int):
"""Handle the browser returning from SumUp after payment."""
order = await get_order_with_details(g.s, order_id)
if not order:
html = await render_template(
"_types/cart/checkout_return.html",
order=None,
status="missing",
calendar_entries=[],
)
return await make_response(html)
# Resolve page/market slugs so product links render correctly
if order.page_config_id:
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
raw_pc = await fetch_data("cart", "page-config-by-id",
params={"id": order.page_config_id},
required=False)
post = await fetch_data("blog", "post-by-id",
params={"id": raw_pc["container_id"]},
required=False) if raw_pc else None
if post:
g.page_slug = post["slug"]
# Fetch marketplace slug from market service
mps = await fetch_data(
"market", "marketplaces-for-container",
params={"type": "page", "id": post["id"]},
required=False,
) or []
if mps:
g.market_slug = mps[0].get("slug")
if order.sumup_checkout_id:
try:
await check_sumup_status(g.s, order)
except Exception:
pass
status = (order.status or "pending").lower()
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
raw_entries = await fetch_data("events", "entries-for-order",
params={"order_id": order.id}, required=False) or []
calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
raw_tickets = await fetch_data("events", "tickets-for-order",
params={"order_id": order.id}, required=False) or []
order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets]
await g.s.flush()
html = await render_template(
"_types/cart/checkout_return.html",
order=order,
status=status,
calendar_entries=calendar_entries,
order_tickets=order_tickets,
)
return await make_response(html)
return bp return bp

View File

@@ -5,8 +5,7 @@ from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, make_response, url_for from quart import Blueprint, g, render_template, redirect, make_response, url_for
from shared.browser.app.utils.htmx import is_htmx_request from shared.browser.app.utils.htmx import is_htmx_request
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout from shared.infrastructure.actions import call_action
from shared.config import config
from .services import ( from .services import (
total, total,
calendar_total, calendar_total,
@@ -14,12 +13,6 @@ from .services import (
) )
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
from .services.ticket_groups import group_tickets from .services.ticket_groups import group_tickets
from .services.checkout import (
create_order_from_cart,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
)
from .services import current_cart_identity from .services import current_cart_identity
@@ -56,7 +49,6 @@ def register(url_prefix: str) -> Blueprint:
@bp.post("/checkout/") @bp.post("/checkout/")
async def page_checkout(): async def page_checkout():
post = g.page_post post = g.page_post
page_config = getattr(g, "page_config", None)
cart = await get_cart_for_page(g.s, post.id) cart = await get_cart_for_page(g.s, post.id)
cal_entries = await get_calendar_entries_for_page(g.s, post.id) cal_entries = await get_calendar_entries_for_page(g.s, post.id)
@@ -65,61 +57,51 @@ def register(url_prefix: str) -> Blueprint:
if not cart and not cal_entries and not page_tickets: if not cart and not cal_entries and not page_tickets:
return redirect(url_for("page_cart.page_view")) return redirect(url_for("page_cart.page_view"))
product_total = total(cart) or 0 product_total_val = total(cart) or 0
calendar_amount = calendar_total(cal_entries) or 0 calendar_amount = calendar_total(cal_entries) or 0
ticket_amount = ticket_total(page_tickets) or 0 ticket_amount = ticket_total(page_tickets) or 0
cart_total = product_total + calendar_amount + ticket_amount cart_total = product_total_val + calendar_amount + ticket_amount
if cart_total <= 0: if cart_total <= 0:
return redirect(url_for("page_cart.page_view")) return redirect(url_for("page_cart.page_view"))
# Create order scoped to this page
ident = current_cart_identity() ident = current_cart_identity()
order = await create_order_from_cart(
g.s,
cart,
cal_entries,
ident.get("user_id"),
ident.get("session_id"),
product_total,
calendar_amount,
ticket_total=ticket_amount,
page_post_id=post.id,
)
# Set page_config on order # Serialize cart items for the orders service
if page_config: cart_items_data = []
order.page_config_id = page_config.id for ci in cart:
cart_items_data.append({
"product_id": ci.product_id,
"product_title": ci.product_title,
"product_slug": ci.product_slug,
"product_image": ci.product_image,
"product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None,
"product_special_price": float(ci.product_special_price) if ci.product_special_price else None,
"product_price_currency": ci.product_price_currency,
"quantity": ci.quantity,
})
# Build SumUp checkout details — webhook/return use global routes cal_data = [{"id": e.id, "calendar_container_id": getattr(e, "calendar_container_id", None)} for e in cal_entries]
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True) ticket_data = [{"id": t.id, "calendar_container_id": getattr(t, "calendar_container_id", None)} for t in page_tickets]
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True) result = await call_action("orders", "create-order", payload={
webhook_url = build_webhook_url(webhook_base_url) "cart_items": cart_items_data,
"calendar_entries": cal_data,
"tickets": ticket_data,
"user_id": ident.get("user_id"),
"session_id": ident.get("session_id"),
"product_total": float(product_total_val),
"calendar_total": float(calendar_amount),
"ticket_total": float(ticket_amount),
"page_post_id": post.id,
})
checkout_data = await sumup_create_checkout( hosted_url = result.get("sumup_hosted_url")
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url: if not hosted_url:
html = await render_template( html = await render_template(
"_types/cart/checkout_error.html", "_types/cart/checkout_error.html",
order=order, order=None,
error="No hosted checkout URL returned from SumUp.", error="No hosted checkout URL returned from SumUp.",
) )
return await make_response(html, 500) return await make_response(html, 500)

View File

@@ -1,9 +1,7 @@
from .get_cart import get_cart from .get_cart import get_cart
from .identity import current_cart_identity from .identity import current_cart_identity
from .total import total from .total import total
from .clear_cart_for_order import clear_cart_for_order
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
from .check_sumup_status import check_sumup_status
from .page_cart import ( from .page_cart import (
get_cart_for_page, get_cart_for_page,
get_calendar_entries_for_page, get_calendar_entries_for_page,

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("cart", "page-configs-batch", raw_pcs = await fetch_data("blog", "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

@@ -45,41 +45,6 @@ def register() -> Blueprint:
_handlers["cart-summary"] = _cart_summary _handlers["cart-summary"] = _cart_summary
# --- page-config-ensure ---
async def _page_config_ensure():
"""Get or create a PageConfig for a container_type + container_id."""
from sqlalchemy import select
from shared.models.page_config import PageConfig
container_type = request.args.get("container_type", "page")
container_id = request.args.get("container_id", type=int)
if container_id is None:
return {"error": "container_id required"}, 400
row = (await g.s.execute(
select(PageConfig).where(
PageConfig.container_type == container_type,
PageConfig.container_id == container_id,
)
)).scalar_one_or_none()
if row is None:
row = PageConfig(
container_type=container_type,
container_id=container_id,
features={},
)
g.s.add(row)
await g.s.flush()
return {
"id": row.id,
"container_type": row.container_type,
"container_id": row.container_id,
}
_handlers["page-config-ensure"] = _page_config_ensure
# --- cart-items (product slugs + quantities for template rendering) --- # --- cart-items (product slugs + quantities for template rendering) ---
async def _cart_items(): async def _cart_items():
from sqlalchemy import select from sqlalchemy import select
@@ -111,103 +76,4 @@ 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

@@ -1,2 +0,0 @@
from .order import Order, OrderItem
from .page_config import PageConfig

View File

@@ -2,7 +2,7 @@
set -euo pipefail set -euo pipefail
REGISTRY="registry.rose-ash.com:5000" REGISTRY="registry.rose-ash.com:5000"
APPS="blog market cart events federation account" APPS="blog market cart events federation account relations likes orders"
usage() { usage() {
echo "Usage: deploy.sh [app ...]" echo "Usage: deploy.sh [app ...]"

View File

@@ -25,6 +25,12 @@ x-sibling-models: &sibling-models
- ./federation/models:/app/federation/models:ro - ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro - ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro - ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
services: services:
blog: blog:
@@ -55,6 +61,12 @@ services:
- ./federation/models:/app/federation/models:ro - ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro - ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro - ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
market: market:
ports: ports:
@@ -85,6 +97,12 @@ services:
- ./federation/models:/app/federation/models:ro - ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro - ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro - ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
cart: cart:
ports: ports:
@@ -114,6 +132,12 @@ services:
- ./federation/models:/app/federation/models:ro - ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro - ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro - ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
events: events:
ports: ports:
@@ -143,6 +167,12 @@ services:
- ./federation/models:/app/federation/models:ro - ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro - ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro - ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
federation: federation:
ports: ports:
@@ -172,6 +202,12 @@ services:
- ./events/models:/app/events/models:ro - ./events/models:/app/events/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro - ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro - ./account/models:/app/account/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
account: account:
ports: ports:
@@ -201,6 +237,103 @@ services:
- ./events/models:/app/events/models:ro - ./events/models:/app/events/models:ro
- ./federation/__init__.py:/app/federation/__init__.py:ro - ./federation/__init__.py:/app/federation/__init__.py:ro
- ./federation/models:/app/federation/models:ro - ./federation/models:/app/federation/models:ro
- ./relations/__init__.py:/app/relations/__init__.py:ro
- ./relations/models:/app/relations/models:ro
- ./likes/__init__.py:/app/likes/__init__.py:ro
- ./likes/models:/app/likes/models:ro
- ./orders/__init__.py:/app/orders/__init__.py:ro
- ./orders/models:/app/orders/models:ro
relations:
ports:
- "8008:8000"
environment:
<<: *dev-env
volumes:
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
- ./shared:/app/shared
- ./relations/alembic.ini:/app/relations/alembic.ini:ro
- ./relations/alembic:/app/relations/alembic:ro
- ./relations/app.py:/app/app.py
- ./relations/bp:/app/bp
- ./relations/services:/app/services
- ./relations/models:/app/models
- ./relations/path_setup.py:/app/path_setup.py
- ./relations/entrypoint.sh:/usr/local/bin/entrypoint.sh
# sibling models
- ./blog/__init__.py:/app/blog/__init__.py:ro
- ./blog/models:/app/blog/models:ro
- ./market/__init__.py:/app/market/__init__.py:ro
- ./market/models:/app/market/models:ro
- ./cart/__init__.py:/app/cart/__init__.py:ro
- ./cart/models:/app/cart/models:ro
- ./events/__init__.py:/app/events/__init__.py:ro
- ./events/models:/app/events/models:ro
- ./federation/__init__.py:/app/federation/__init__.py:ro
- ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro
likes:
ports:
- "8009:8000"
environment:
<<: *dev-env
volumes:
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
- ./shared:/app/shared
- ./likes/alembic.ini:/app/likes/alembic.ini:ro
- ./likes/alembic:/app/likes/alembic:ro
- ./likes/app.py:/app/app.py
- ./likes/bp:/app/bp
- ./likes/services:/app/services
- ./likes/models:/app/models
- ./likes/path_setup.py:/app/path_setup.py
- ./likes/entrypoint.sh:/usr/local/bin/entrypoint.sh
# sibling models
- ./blog/__init__.py:/app/blog/__init__.py:ro
- ./blog/models:/app/blog/models:ro
- ./market/__init__.py:/app/market/__init__.py:ro
- ./market/models:/app/market/models:ro
- ./cart/__init__.py:/app/cart/__init__.py:ro
- ./cart/models:/app/cart/models:ro
- ./events/__init__.py:/app/events/__init__.py:ro
- ./events/models:/app/events/models:ro
- ./federation/__init__.py:/app/federation/__init__.py:ro
- ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro
orders:
ports:
- "8010:8000"
environment:
<<: *dev-env
volumes:
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
- ./shared:/app/shared
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
- ./orders/alembic:/app/orders/alembic:ro
- ./orders/app.py:/app/app.py
- ./orders/bp:/app/bp
- ./orders/services:/app/services
- ./orders/templates:/app/templates
- ./orders/models:/app/models
- ./orders/path_setup.py:/app/path_setup.py
- ./orders/entrypoint.sh:/usr/local/bin/entrypoint.sh
# sibling models
- ./blog/__init__.py:/app/blog/__init__.py:ro
- ./blog/models:/app/blog/models:ro
- ./market/__init__.py:/app/market/__init__.py:ro
- ./market/models:/app/market/models:ro
- ./cart/__init__.py:/app/cart/__init__.py:ro
- ./cart/models:/app/cart/models:ro
- ./events/__init__.py:/app/events/__init__.py:ro
- ./events/models:/app/events/models:ro
- ./federation/__init__.py:/app/federation/__init__.py:ro
- ./federation/models:/app/federation/models:ro
- ./account/__init__.py:/app/account/__init__.py:ro
- ./account/models:/app/account/models:ro
networks: networks:
appnet: appnet:

View File

@@ -32,6 +32,9 @@ x-app-env: &app-env
APP_URL_EVENTS: https://events.rose-ash.com APP_URL_EVENTS: https://events.rose-ash.com
APP_URL_FEDERATION: https://federation.rose-ash.com APP_URL_FEDERATION: https://federation.rose-ash.com
APP_URL_ACCOUNT: https://account.rose-ash.com APP_URL_ACCOUNT: https://account.rose-ash.com
APP_URL_ORDERS: https://orders.rose-ash.com
APP_URL_RELATIONS: http://relations:8000
APP_URL_LIKES: http://likes:8000
APP_URL_ARTDAG: https://celery-artdag.rose-ash.com APP_URL_ARTDAG: https://celery-artdag.rose-ash.com
APP_URL_ARTDAG_L2: https://artdag.rose-ash.com APP_URL_ARTDAG_L2: https://artdag.rose-ash.com
INTERNAL_URL_BLOG: http://blog:8000 INTERNAL_URL_BLOG: http://blog:8000
@@ -40,6 +43,9 @@ x-app-env: &app-env
INTERNAL_URL_EVENTS: http://events:8000 INTERNAL_URL_EVENTS: http://events:8000
INTERNAL_URL_FEDERATION: http://federation:8000 INTERNAL_URL_FEDERATION: http://federation:8000
INTERNAL_URL_ACCOUNT: http://account:8000 INTERNAL_URL_ACCOUNT: http://account:8000
INTERNAL_URL_ORDERS: http://orders:8000
INTERNAL_URL_RELATIONS: http://relations:8000
INTERNAL_URL_LIKES: http://likes:8000
INTERNAL_URL_ARTDAG: http://l1-server:8100 INTERNAL_URL_ARTDAG: http://l1-server:8100
AP_DOMAIN: federation.rose-ash.com AP_DOMAIN: federation.rose-ash.com
AP_DOMAIN_BLOG: blog.rose-ash.com AP_DOMAIN_BLOG: blog.rose-ash.com
@@ -147,6 +153,54 @@ services:
RUN_MIGRATIONS: "true" RUN_MIGRATIONS: "true"
WORKERS: "1" WORKERS: "1"
relations:
<<: *app-common
image: registry.rose-ash.com:5000/relations:latest
build:
context: .
dockerfile: relations/Dockerfile
environment:
<<: *app-env
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_relations
ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_relations
REDIS_URL: redis://redis:6379/6
DATABASE_HOST: db
DATABASE_PORT: "5432"
RUN_MIGRATIONS: "true"
WORKERS: "1"
likes:
<<: *app-common
image: registry.rose-ash.com:5000/likes:latest
build:
context: .
dockerfile: likes/Dockerfile
environment:
<<: *app-env
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_likes
ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_likes
REDIS_URL: redis://redis:6379/7
DATABASE_HOST: db
DATABASE_PORT: "5432"
RUN_MIGRATIONS: "true"
WORKERS: "1"
orders:
<<: *app-common
image: registry.rose-ash.com:5000/orders:latest
build:
context: .
dockerfile: orders/Dockerfile
environment:
<<: *app-env
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_orders
ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_orders
REDIS_URL: redis://redis:6379/8
DATABASE_HOST: db
DATABASE_PORT: "5432"
RUN_MIGRATIONS: "true"
WORKERS: "1"
db: db:
image: postgres:16 image: postgres:16
environment: environment:

View File

@@ -37,6 +37,12 @@ COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/ COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/ COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY events/entrypoint.sh /usr/local/bin/entrypoint.sh COPY events/entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -71,7 +71,7 @@ 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 call_action("cart", "detach-child", payload={ await call_action("relations", "detach-child", payload={
"parent_type": "page", "parent_id": cal.container_id, "parent_type": "page", "parent_id": cal.container_id,
"child_type": "calendar", "child_id": cal.id, "child_type": "calendar", "child_id": cal.id,
}) })
@@ -108,7 +108,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
if existing.deleted_at is not None: if existing.deleted_at is not None:
existing.deleted_at = None # revive existing.deleted_at = None # revive
await sess.flush() await sess.flush()
await call_action("cart", "attach-child", payload={ await call_action("relations", "attach-child", payload={
"parent_type": "page", "parent_id": post_id, "parent_type": "page", "parent_id": post_id,
"child_type": "calendar", "child_id": existing.id, "child_type": "calendar", "child_id": existing.id,
}) })
@@ -118,7 +118,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug) 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 call_action("cart", "attach-child", payload={ await call_action("relations", "attach-child", payload={
"parent_type": "page", "parent_id": post_id, "parent_type": "page", "parent_id": post_id,
"child_type": "calendar", "child_id": cal.id, "child_type": "calendar", "child_id": cal.id,
}) })

View File

@@ -38,6 +38,12 @@ COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/ COPY events/models/ ./events/models/
COPY account/__init__.py ./account/__init__.py COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/ COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY federation/entrypoint.sh /usr/local/bin/entrypoint.sh COPY federation/entrypoint.sh /usr/local/bin/entrypoint.sh

53
likes/Dockerfile Normal file
View File

@@ -0,0 +1,53 @@
# syntax=docker/dockerfile:1
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code
COPY shared/ ./shared/
# App code
COPY likes/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
COPY likes/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

0
likes/__init__.py Normal file
View File

35
likes/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

12
likes/alembic/env.py Normal file
View File

@@ -0,0 +1,12 @@
from alembic import context
from shared.db.alembic_env import run_alembic
MODELS = [
"likes.models.like",
]
TABLES = frozenset({
"likes",
})
run_alembic(context.config, MODELS, TABLES)

View File

@@ -0,0 +1,47 @@
"""Initial likes tables
Revision ID: likes_0001
Revises: None
Create Date: 2026-02-27
"""
import sqlalchemy as sa
from alembic import op
revision = "likes_0001"
down_revision = None
branch_labels = None
depends_on = None
def _table_exists(conn, name):
result = conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t"
), {"t": name})
return result.scalar() is not None
def upgrade():
if _table_exists(op.get_bind(), "likes"):
return
op.create_table(
"likes",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer, nullable=False, index=True),
sa.Column("target_type", sa.String(32), nullable=False),
sa.Column("target_slug", sa.String(255), nullable=True),
sa.Column("target_id", sa.Integer, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.UniqueConstraint("user_id", "target_type", "target_slug",
name="uq_likes_user_type_slug"),
sa.UniqueConstraint("user_id", "target_type", "target_id",
name="uq_likes_user_type_id"),
)
op.create_index("ix_likes_target", "likes", ["target_type", "target_slug"])
def downgrade():
op.drop_table("likes")

22
likes/app.py Normal file
View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import path_setup # noqa: F401
from shared.infrastructure.factory import create_base_app
from bp import register_actions, register_data
from services import register_domain_services
def create_app() -> "Quart":
app = create_base_app(
"likes",
domain_services_fn=register_domain_services,
)
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
return app
app = create_app()

2
likes/bp/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .data.routes import register as register_data
from .actions.routes import register as register_actions

View File

View File

@@ -0,0 +1,81 @@
"""Likes app action endpoints."""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.actions import ACTION_HEADER
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- toggle ---
async def _toggle():
"""Toggle a like. Returns {"liked": bool}."""
from sqlalchemy import select, update, func
from likes.models.like import Like
data = await request.get_json(force=True)
user_id = data["user_id"]
target_type = data["target_type"]
target_slug = data.get("target_slug")
target_id = data.get("target_id")
filters = [
Like.user_id == user_id,
Like.target_type == target_type,
Like.deleted_at.is_(None),
]
if target_slug is not None:
filters.append(Like.target_slug == target_slug)
elif target_id is not None:
filters.append(Like.target_id == target_id)
else:
return {"error": "target_slug or target_id required"}, 400
existing = await g.s.scalar(select(Like).where(*filters))
if existing:
# Unlike: soft delete
await g.s.execute(
update(Like).where(Like.id == existing.id).values(deleted_at=func.now())
)
return {"liked": False}
else:
# Like: insert new
new_like = Like(
user_id=user_id,
target_type=target_type,
target_slug=target_slug,
target_id=target_id,
)
g.s.add(new_like)
await g.s.flush()
return {"liked": True}
_handlers["toggle"] = _toggle
return bp

View File

109
likes/bp/data/routes.py Normal file
View File

@@ -0,0 +1,109 @@
"""Likes app data endpoints."""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
# --- is-liked ---
async def _is_liked():
"""Check if a user has liked a specific target."""
from sqlalchemy import select
from likes.models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
target_slug = request.args.get("target_slug")
target_id = request.args.get("target_id", type=int)
if not user_id or not target_type:
return {"liked": False}
filters = [
Like.user_id == user_id,
Like.target_type == target_type,
Like.deleted_at.is_(None),
]
if target_slug is not None:
filters.append(Like.target_slug == target_slug)
elif target_id is not None:
filters.append(Like.target_id == target_id)
else:
return {"liked": False}
row = await g.s.scalar(select(Like.id).where(*filters))
return {"liked": row is not None}
_handlers["is-liked"] = _is_liked
# --- liked-slugs ---
async def _liked_slugs():
"""Return all liked target_slugs for a user + target_type."""
from sqlalchemy import select
from likes.models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
if not user_id or not target_type:
return []
result = await g.s.execute(
select(Like.target_slug).where(
Like.user_id == user_id,
Like.target_type == target_type,
Like.target_slug.isnot(None),
Like.deleted_at.is_(None),
)
)
return list(result.scalars().all())
_handlers["liked-slugs"] = _liked_slugs
# --- liked-ids ---
async def _liked_ids():
"""Return all liked target_ids for a user + target_type."""
from sqlalchemy import select
from likes.models.like import Like
user_id = request.args.get("user_id", type=int)
target_type = request.args.get("target_type", "")
if not user_id or not target_type:
return []
result = await g.s.execute(
select(Like.target_id).where(
Like.user_id == user_id,
Like.target_type == target_type,
Like.target_id.isnot(None),
Like.deleted_at.is_(None),
)
)
return list(result.scalars().all())
_handlers["liked-ids"] = _liked_ids
return bp

61
likes/entrypoint.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Optional: wait for Postgres to be reachable
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
for i in {1..60}; do
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
sleep 1
done
fi
# Create own database + run own migrations
if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then
python3 -c "
import os, re
url = os.environ['ALEMBIC_DATABASE_URL']
m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url)
if not m:
print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation')
exit(0)
user, password, host, port, dbname = m.groups()
import psycopg
conn = psycopg.connect(
f'postgresql://{user}:{password}@{host}:{port}/postgres',
autocommit=True,
)
cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,))
if not cur.fetchone():
conn.execute(f'CREATE DATABASE {dbname}')
print(f'Created database {dbname}')
else:
print(f'Database {dbname} already exists')
conn.close()
" || echo "DB creation failed (non-fatal), continuing..."
echo "Running likes Alembic migrations..."
if [ -d likes ]; then (cd likes && alembic upgrade head); else alembic upgrade head; fi
fi
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
echo "Flushing Redis cache..."
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushdb()
print('Redis cache cleared.')
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
fi
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG}

1
likes/models/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .like import Like

36
likes/models/like.py Normal file
View File

@@ -0,0 +1,36 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import Integer, String, DateTime, Index, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from shared.db.base import Base
class Like(Base):
__tablename__ = "likes"
__table_args__ = (
UniqueConstraint("user_id", "target_type", "target_slug",
name="uq_likes_user_type_slug"),
UniqueConstraint("user_id", "target_type", "target_id",
name="uq_likes_user_type_id"),
Index("ix_likes_target", "target_type", "target_slug"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
target_type: Mapped[str] = mapped_column(String(32), nullable=False)
target_slug: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
target_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False,
)
deleted_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True,
)

9
likes/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

View File

@@ -0,0 +1,6 @@
"""Likes app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the likes app."""

View File

@@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/ COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/ COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
COPY orders/__init__.py ./orders/__init__.py
COPY orders/models/ ./orders/models/
# ---------- Runtime setup ---------- # ---------- Runtime setup ----------
COPY market/entrypoint.sh /usr/local/bin/entrypoint.sh COPY market/entrypoint.sh /usr/local/bin/entrypoint.sh

View File

@@ -9,7 +9,7 @@ MODELS = [
TABLES = frozenset({ TABLES = frozenset({
"products", "product_images", "product_sections", "product_labels", "products", "product_images", "product_sections", "product_labels",
"product_stickers", "product_attributes", "product_nutrition", "product_stickers", "product_attributes", "product_nutrition",
"product_allergens", "product_likes", "product_allergens",
"market_places", "nav_tops", "nav_subs", "market_places", "nav_tops", "nav_subs",
"listings", "listing_items", "listings", "listing_items",
"link_errors", "link_externals", "subcategory_redirects", "product_logs", "link_errors", "link_externals", "subcategory_redirects", "product_logs",

View File

@@ -12,9 +12,9 @@ from models.market import (
Listing, ListingItem, Listing, ListingItem,
NavTop, NavSub, NavTop, NavSub,
ProductSticker, ProductLabel, ProductSticker, ProductLabel,
ProductAttribute, ProductNutrition, ProductAllergen, ProductLike ProductAttribute, ProductNutrition, ProductAllergen,
) )
from shared.infrastructure.data_client import fetch_data
from sqlalchemy import func, case from sqlalchemy import func, case
@@ -72,26 +72,8 @@ async def db_nav(session, market_id=None) -> Dict:
async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]: async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
liked_product_ids_subq = (
select(ProductLike.product_slug)
.where(
and_(
ProductLike.user_id == user_id,
ProductLike.deleted_at.is_(None)
)
)
)
is_liked_case = case(
(and_(
(Product.slug.in_(liked_product_ids_subq)),
Product.deleted_at.is_(None)
), True),
else_=False
).label("is_liked")
q = ( q = (
select(Product, is_liked_case) select(Product)
.where(Product.slug == slug, Product.deleted_at.is_(None)) .where(Product.slug == slug, Product.deleted_at.is_(None))
.options( .options(
selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))), selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))),
@@ -105,11 +87,17 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
) )
result = await session.execute(q) result = await session.execute(q)
row = result.first() if result is not None else None p = result.scalars().first()
p, is_liked = row if row else (None, None)
if not p: if not p:
return None return None
is_liked = False
if user_id:
liked_data = await fetch_data("likes", "is-liked", params={
"user_id": user_id, "target_type": "product", "target_slug": slug,
}, required=False)
is_liked = (liked_data or {}).get("liked", False)
gallery = [ gallery = [
img.url img.url
for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0)) for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0))
@@ -170,26 +158,9 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]: async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]:
liked_product_ids_subq = (
select(ProductLike.product_slug)
.where(
and_(
ProductLike.user_id == user_id,
ProductLike.deleted_at.is_(None)
)
)
)
is_liked_case = case(
(
(Product.slug.in_(liked_product_ids_subq)),
True
),
else_=False
).label("is_liked")
q = ( q = (
select(Product, is_liked_case) select(Product)
.where(Product.id == id) .where(Product.id == id)
.options( .options(
selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))), selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))),
@@ -203,11 +174,17 @@ async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]:
) )
result = await session.execute(q) result = await session.execute(q)
row = result.first() if result is not None else None p = result.scalars().first()
p, is_liked = row if row else (None, None)
if not p: if not p:
return None return None
is_liked = False
if user_id:
liked_data = await fetch_data("likes", "is-liked", params={
"user_id": user_id, "target_type": "product", "target_slug": p.slug,
}, required=False)
is_liked = (liked_data or {}).get("liked", False)
gallery = [ gallery = [
img.url img.url
for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0)) for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0))
@@ -367,40 +344,25 @@ async def db_products_nocounts(
) )
if search_q: if search_q:
filter_conditions.append(func.lower(Product.description_short).contains(search_q)) filter_conditions.append(func.lower(Product.description_short).contains(search_q))
# Fetch liked slugs from likes service (once)
liked_slugs_set: set[str] = set()
if user_id and (liked or True):
liked_slugs_list = await fetch_data("likes", "liked-slugs", params={
"user_id": user_id, "target_type": "product",
}, required=False) or []
liked_slugs_set = set(liked_slugs_list)
if liked: if liked:
liked_subq = liked_subq = ( if not liked_slugs_set:
select(ProductLike.product_slug) return {"total_pages": 1, "items": []}
.where( filter_conditions.append(Product.slug.in_(liked_slugs_set))
and_(
ProductLike.user_id == user_id,
ProductLike.deleted_at.is_(None)
)
)
.subquery()
)
filter_conditions.append(Product.slug.in_(liked_subq))
filtered_count_query = select(func.count(Product.id)).where(Product.id.in_(base_ids), *filter_conditions) filtered_count_query = select(func.count(Product.id)).where(Product.id.in_(base_ids), *filter_conditions)
total_filtered = (await session.execute(filtered_count_query)).scalars().one() total_filtered = (await session.execute(filtered_count_query)).scalars().one()
total_pages = max(1, (total_filtered + page_size - 1) // page_size) total_pages = max(1, (total_filtered + page_size - 1) // page_size)
page = max(1, page) page = max(1, page)
q_filtered = select(Product).where(Product.id.in_(base_ids), *filter_conditions).options(
liked_product_slugs_subq = (
select(ProductLike.product_slug)
.where(
and_(
ProductLike.user_id == user_id,
ProductLike.deleted_at.is_(None)
)
)
)
is_liked_case = case(
(Product.slug.in_(liked_product_slugs_subq), True),
else_=False
).label("is_liked")
q_filtered = select(Product, is_liked_case).where(Product.id.in_(base_ids), *filter_conditions).options(
selectinload(Product.images), selectinload(Product.images),
selectinload(Product.sections), selectinload(Product.sections),
selectinload(Product.labels), selectinload(Product.labels),
@@ -434,10 +396,11 @@ async def db_products_nocounts(
offset_val = (page - 1) * page_size offset_val = (page - 1) * page_size
q_filtered = q_filtered.offset(offset_val).limit(page_size) q_filtered = q_filtered.offset(offset_val).limit(page_size)
products_page = (await session.execute(q_filtered)).all() products_page = (await session.execute(q_filtered)).scalars().all()
items: List[Dict] = [] items: List[Dict] = []
for p, is_liked in products_page: for p in products_page:
is_liked = p.slug in liked_slugs_set
gallery_imgs = sorted((img for img in p.images), key=lambda i: (i.kind or "gallery", i.position or 0)) gallery_imgs = sorted((img for img in p.images), key=lambda i: (i.kind or "gallery", i.position or 0))
gallery = [img.url for img in gallery_imgs if (img.kind or "gallery") == "gallery"] gallery = [img.url for img in gallery_imgs if (img.kind or "gallery") == "gallery"]
embedded = [img.url for img in sorted(p.images, key=lambda i: i.position or 0) if (img.kind or "") == "embedded"] embedded = [img.url for img in sorted(p.images, key=lambda i: i.position or 0) if (img.kind or "") == "embedded"]
@@ -580,30 +543,14 @@ async def db_products_counts(
labels_list: List[Dict] = [] labels_list: List[Dict] = []
liked_count = 0 liked_count = 0
search_count = 0 search_count = 0
liked_product_slugs_subq = ( if user_id:
select(ProductLike.product_slug) liked_slugs_list = await fetch_data("likes", "liked-slugs", params={
.where(ProductLike.user_id == user_id, ProductLike.deleted_at.is_(None)) "user_id": user_id, "target_type": "product",
) }, required=False) or []
liked_count = await session.scalar( liked_slugs_in_base = set(liked_slugs_list) & set(base_products_slugs)
select(func.count(Product.id)) liked_count = len(liked_slugs_in_base)
.where( else:
Product.id.in_(base_ids), liked_count = 0
Product.slug.in_(liked_product_slugs_subq),
Product.deleted_at.is_(None)
)
)
liked_count = (await session.execute(
select(func.count())
.select_from(ProductLike)
.where(
ProductLike.user_id == user_id,
ProductLike.product_slug.in_(
select(Product.slug).where(Product.id.in_(base_ids))
),
ProductLike.deleted_at.is_(None)
)
)).scalar_one() if user_id else 0
# Brand counts # Brand counts
brand_count_rows = await session.execute( brand_count_rows = await session.execute(

View File

@@ -13,8 +13,7 @@ from .blacklist.product_details import is_blacklisted_heading
from shared.utils import host_url from shared.utils import host_url
from sqlalchemy import select from shared.infrastructure.data_client import fetch_data
from models import ProductLike
from ...market.filters.qs import decode from ...market.filters.qs import decode
@@ -171,15 +170,9 @@ async def _is_liked(user_id: int | None, slug: str) -> bool:
""" """
if not user_id: if not user_id:
return False return False
# because ProductLike has composite PK (user_id, product_slug), liked_data = await fetch_data("likes", "is-liked", params={
# we can fetch it by primary key dict: "user_id": user_id, "target_type": "product", "target_slug": slug,
row = await g.s.execute( }, required=False)
select(ProductLike).where( return (liked_data or {}).get("liked", False)
ProductLike.user_id == user_id,
ProductLike.product_slug == slug,
)
)
row.scalar_one_or_none()
return row is not None

View File

@@ -10,7 +10,7 @@ from quart import (
) )
from sqlalchemy import select, func, update from sqlalchemy import select, func, update
from models.market import Product, ProductLike from models.market import Product
from ..browse.services.slugs import canonical_html_slug from ..browse.services.slugs import canonical_html_slug
from ..browse.services.blacklist.product import is_product_blocked from ..browse.services.blacklist.product import is_product_blocked
from ..browse.services import db_backend as cb from ..browse.services import db_backend as cb
@@ -18,7 +18,8 @@ from ..browse.services import _massage_product
from shared.utils import host_url from shared.utils import host_url
from shared.browser.app.redis_cacher import cache_page, clear_cache from shared.browser.app.redis_cacher import cache_page, clear_cache
from ..cart.services import total from ..cart.services import total
from .services.product_operations import toggle_product_like, massage_full_product from shared.infrastructure.actions import call_action
from .services.product_operations import massage_full_product
def register(): def register():
@@ -132,11 +133,10 @@ def register():
user_id = g.user.id user_id = g.user.id
liked, error = await toggle_product_like(g.s, user_id, product_slug) result = await call_action("likes", "toggle", payload={
"user_id": user_id, "target_type": "product", "target_slug": product_slug,
if error: })
resp = make_response(error, 404) liked = result["liked"]
return resp
html = await render_template( html = await render_template(
"_types/browse/like/button.html", "_types/browse/like/button.html",

View File

@@ -1,11 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from models.market import Product
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.market import Product, ProductLike
def massage_full_product(product: Product) -> dict: def massage_full_product(product: Product) -> dict:
@@ -44,52 +39,3 @@ def massage_full_product(product: Product) -> dict:
return _massage_product(d) return _massage_product(d)
async def toggle_product_like(
session: AsyncSession,
user_id: int,
product_slug: str,
) -> tuple[bool, Optional[str]]:
"""
Toggle a product like for a given user using soft deletes.
Returns (liked_state, error_message).
- If error_message is not None, an error occurred.
- liked_state indicates whether product is now liked (True) or unliked (False).
"""
from sqlalchemy import func, update
# Get product_id from slug
product_id = await session.scalar(
select(Product.id).where(Product.slug == product_slug, Product.deleted_at.is_(None))
)
if not product_id:
return False, "Product not found"
# Check if like exists (not deleted)
existing = await session.scalar(
select(ProductLike).where(
ProductLike.user_id == user_id,
ProductLike.product_slug == product_slug,
ProductLike.deleted_at.is_(None),
)
)
if existing:
# Unlike: soft delete the like
await session.execute(
update(ProductLike)
.where(
ProductLike.user_id == user_id,
ProductLike.product_slug == product_slug,
ProductLike.deleted_at.is_(None),
)
.values(deleted_at=func.now())
)
return False, None
else:
# Like: add a new like
new_like = ProductLike(
user_id=user_id,
product_slug=product_slug,
)
session.add(new_like)
return True, None

View File

@@ -1,5 +1,5 @@
from .market import ( from .market import (
Product, ProductLike, ProductImage, ProductSection, Product, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem, NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog, LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,

View File

@@ -1,5 +1,5 @@
from shared.models.market import ( # noqa: F401 from shared.models.market import ( # noqa: F401
Product, ProductLike, ProductImage, ProductSection, Product, ProductImage, ProductSection,
NavTop, NavSub, Listing, ListingItem, NavTop, NavSub, Listing, ListingItem,
LinkError, LinkExternal, SubcategoryRedirect, ProductLog, LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen, ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,

56
orders/Dockerfile Normal file
View File

@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
# ---------- Python application ----------
FROM python:3.11-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \
PIP_NO_CACHE_DIR=1 \
APP_PORT=8000 \
APP_MODULE=app:app
WORKDIR /app
# Install system deps + psql client
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY shared/requirements.txt ./requirements.txt
RUN pip install -r requirements.txt
# Shared code (replaces submodule)
COPY shared/ ./shared/
# App code
COPY orders/ ./
# Sibling models for cross-domain SQLAlchemy imports
COPY blog/__init__.py ./blog/__init__.py
COPY blog/models/ ./blog/models/
COPY market/__init__.py ./market/__init__.py
COPY market/models/ ./market/models/
COPY cart/__init__.py ./cart/__init__.py
COPY cart/models/ ./cart/models/
COPY events/__init__.py ./events/__init__.py
COPY events/models/ ./events/models/
COPY federation/__init__.py ./federation/__init__.py
COPY federation/models/ ./federation/models/
COPY account/__init__.py ./account/__init__.py
COPY account/models/ ./account/models/
COPY relations/__init__.py ./relations/__init__.py
COPY relations/models/ ./relations/models/
COPY likes/__init__.py ./likes/__init__.py
COPY likes/models/ ./likes/models/
# ---------- Runtime setup ----------
COPY orders/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN useradd -m -u 10001 appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE ${APP_PORT}
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

0
orders/__init__.py Normal file
View File

35
orders/alembic.ini Normal file
View File

@@ -0,0 +1,35 @@
[alembic]
script_location = alembic
sqlalchemy.url =
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s

12
orders/alembic/env.py Normal file
View File

@@ -0,0 +1,12 @@
from alembic import context
from shared.db.alembic_env import run_alembic
MODELS = [
"shared.models.order",
]
TABLES = frozenset({
"orders", "order_items",
})
run_alembic(context.config, MODELS, TABLES)

View File

@@ -0,0 +1,67 @@
"""Initial orders tables
Revision ID: orders_0001
Revises: None
Create Date: 2026-02-27
"""
import sqlalchemy as sa
from alembic import op
revision = "orders_0001"
down_revision = None
branch_labels = None
depends_on = None
def _table_exists(conn, name):
result = conn.execute(sa.text(
"SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name=:t"
), {"t": name})
return result.scalar() is not None
def upgrade():
if not _table_exists(op.get_bind(), "orders"):
op.create_table(
"orders",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.Integer, nullable=True),
sa.Column("session_id", sa.String(64), nullable=True),
sa.Column("page_config_id", sa.Integer, nullable=True),
sa.Column("status", sa.String(32), nullable=False, server_default="pending"),
sa.Column("currency", sa.String(16), nullable=False, server_default="GBP"),
sa.Column("total_amount", sa.Numeric(12, 2), nullable=False),
sa.Column("description", sa.Text, nullable=True),
sa.Column("sumup_reference", sa.String(255), nullable=True),
sa.Column("sumup_checkout_id", sa.String(128), nullable=True),
sa.Column("sumup_status", sa.String(32), nullable=True),
sa.Column("sumup_hosted_url", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_orders_session_id", "orders", ["session_id"])
op.create_index("ix_orders_page_config_id", "orders", ["page_config_id"])
op.create_index("ix_orders_description", "orders", ["description"], postgresql_using="hash")
op.create_index("ix_orders_sumup_reference", "orders", ["sumup_reference"])
op.create_index("ix_orders_sumup_checkout_id", "orders", ["sumup_checkout_id"])
if not _table_exists(op.get_bind(), "order_items"):
op.create_table(
"order_items",
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
sa.Column("order_id", sa.Integer, sa.ForeignKey("orders.id", ondelete="CASCADE"), nullable=False),
sa.Column("product_id", sa.Integer, nullable=False),
sa.Column("product_title", sa.String(512), nullable=True),
sa.Column("product_slug", sa.String(512), nullable=True),
sa.Column("product_image", sa.Text, nullable=True),
sa.Column("quantity", sa.Integer, nullable=False, server_default="1"),
sa.Column("unit_price", sa.Numeric(12, 2), nullable=False),
sa.Column("currency", sa.String(16), nullable=False, server_default="GBP"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
def downgrade():
op.drop_table("order_items")
op.drop_table("orders")

129
orders/app.py Normal file
View File

@@ -0,0 +1,129 @@
from __future__ import annotations
import path_setup # noqa: F401 # adds shared/ to sys.path
from pathlib import Path
from types import SimpleNamespace
from quart import g, abort, request
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import (
register_orders,
register_order,
register_checkout,
register_fragments,
register_actions,
register_data,
)
async def orders_context() -> dict:
"""Orders app context processor."""
from shared.infrastructure.context import base_context
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
ctx = await base_context()
ctx["menu_items"] = []
user = getattr(g, "user", None)
ident = current_cart_identity()
cart_params = {}
if ident["user_id"] is not None:
cart_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
cart_params["session_id"] = ident["session_id"]
cart_mini_html, auth_menu_html, nav_tree_html = await fetch_fragments([
("cart", "cart-mini", cart_params or None),
("account", "auth-menu", {"email": user.email} if user else None),
("blog", "nav-tree", {"app_name": "orders", "path": request.path}),
])
ctx["cart_mini_html"] = cart_mini_html
ctx["auth_menu_html"] = auth_menu_html
ctx["nav_tree_html"] = nav_tree_html
return ctx
def _make_page_config(raw: dict) -> SimpleNamespace:
"""Convert a page-config JSON dict to a namespace for SumUp helpers."""
return SimpleNamespace(**raw)
def create_app() -> "Quart":
from services import register_domain_services
app = create_base_app(
"orders",
context_fn=orders_context,
domain_services_fn=register_domain_services,
)
# App-specific templates override shared templates
app_templates = str(Path(__file__).resolve().parent / "templates")
app.jinja_loader = ChoiceLoader([
FileSystemLoader(app_templates),
app.jinja_loader,
])
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# Orders list at /
app.register_blueprint(register_orders(url_prefix="/"))
# Checkout webhook + return
app.register_blueprint(register_checkout())
# --- Reconcile stale pending orders on startup ---
@app.before_serving
async def _reconcile_pending_orders():
"""Check SumUp status for orders stuck in 'pending' with a checkout ID."""
import logging
from datetime import datetime, timezone, timedelta
from sqlalchemy import select as sel
from shared.db.session import get_session
from shared.models.order import Order
from services.check_sumup_status import check_sumup_status
log = logging.getLogger("orders.reconcile")
try:
async with get_session() as sess:
async with sess.begin():
cutoff = datetime.now(timezone.utc) - timedelta(minutes=2)
result = await sess.execute(
sel(Order)
.where(
Order.status == "pending",
Order.sumup_checkout_id.isnot(None),
Order.created_at < cutoff,
)
.limit(50)
)
stale_orders = result.scalars().all()
if not stale_orders:
return
log.info("Reconciling %d stale pending orders", len(stale_orders))
for order in stale_orders:
try:
await check_sumup_status(sess, order)
log.info(
"Order %d reconciled: %s",
order.id, order.status,
)
except Exception:
log.exception("Failed to reconcile order %d", order.id)
except Exception:
log.exception("Order reconciliation failed")
return app
app = create_app()

6
orders/bp/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from .order.routes import register as register_order
from .orders.routes import register as register_orders
from .checkout.routes import register as register_checkout
from .data.routes import register as register_data
from .actions.routes import register as register_actions
from .fragments.routes import register as register_fragments

View File

108
orders/bp/actions/routes.py Normal file
View File

@@ -0,0 +1,108 @@
"""Orders app action endpoints."""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.actions import ACTION_HEADER
def register() -> Blueprint:
bp = Blueprint("actions", __name__, url_prefix="/internal/actions")
@bp.before_request
async def _require_action_header():
if not request.headers.get(ACTION_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.post("/<action_name>")
async def handle_action(action_name: str):
handler = _handlers.get(action_name)
if handler is None:
return jsonify({"error": "unknown action"}), 404
try:
result = await handler()
return jsonify(result)
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Action %s failed", action_name)
return jsonify({"error": str(exc)}), 500
# --- create-order ---
async def _create_order():
"""Create an order from cart data. Called by cart during checkout."""
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.infrastructure.urls import orders_url
from services.checkout import (
create_order,
resolve_page_config_from_post_id,
build_sumup_description,
build_sumup_reference,
build_webhook_url,
)
data = await request.get_json(force=True)
cart_items = data.get("cart_items", [])
calendar_entries = data.get("calendar_entries", [])
tickets = data.get("tickets", [])
user_id = data.get("user_id")
session_id = data.get("session_id")
product_total = data.get("product_total", 0)
calendar_total = data.get("calendar_total", 0)
ticket_total = data.get("ticket_total", 0)
page_post_id = data.get("page_post_id")
order = await create_order(
g.s, cart_items, calendar_entries,
user_id, session_id,
product_total, calendar_total,
ticket_total=ticket_total,
page_post_id=page_post_id,
)
page_config = None
if page_post_id:
page_config = await resolve_page_config_from_post_id(page_post_id)
if page_config:
order.page_config_id = page_config.id
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
description = build_sumup_description(cart_items, order.id, ticket_count=len(tickets))
# Build URLs using orders service's own domain
redirect_url = orders_url(f"/checkout/return/{order.id}/")
webhook_base_url = orders_url(f"/checkout/webhook/{order.id}/")
webhook_url = build_webhook_url(webhook_base_url)
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
description=description,
page_config=page_config,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
order.description = checkout_data.get("description")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
return {
"order_id": order.id,
"sumup_hosted_url": hosted_url,
"page_config_id": order.page_config_id,
"sumup_reference": order.sumup_reference,
"description": order.description,
}
_handlers["create-order"] = _create_order
return bp

View File

View File

@@ -0,0 +1,99 @@
"""Checkout webhook + return routes (moved from cart/bp/cart/global_routes.py)."""
from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from sqlalchemy import select
from shared.models.order import Order
from shared.browser.app.csrf import csrf_exempt
from services.checkout import validate_webhook_secret, get_order_with_details
from services.check_sumup_status import check_sumup_status
def register() -> Blueprint:
bp = Blueprint("checkout", __name__, url_prefix="/checkout")
@csrf_exempt
@bp.post("/webhook/<int:order_id>/")
async def checkout_webhook(order_id: int):
"""Webhook endpoint for SumUp CHECKOUT_STATUS_CHANGED events."""
if not validate_webhook_secret(request.args.get("token")):
return "", 204
try:
payload = await request.get_json()
except Exception:
payload = None
if not isinstance(payload, dict):
return "", 204
if payload.get("event_type") != "CHECKOUT_STATUS_CHANGED":
return "", 204
checkout_id = payload.get("id")
if not checkout_id:
return "", 204
result = await g.s.execute(select(Order).where(Order.id == order_id))
order = result.scalar_one_or_none()
if not order:
return "", 204
if order.sumup_checkout_id and order.sumup_checkout_id != checkout_id:
return "", 204
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return "", 204
@bp.get("/return/<int:order_id>/")
async def checkout_return(order_id: int):
"""Handle the browser returning from SumUp after payment."""
order = await get_order_with_details(g.s, order_id)
if not order:
html = await render_template(
"_types/cart/checkout_return.html",
order=None, status="missing", calendar_entries=[],
)
return await make_response(html)
if order.page_config_id:
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
raw_pc = await fetch_data("blog", "page-config-by-id",
params={"id": order.page_config_id}, required=False)
post = await fetch_data("blog", "post-by-id",
params={"id": raw_pc["container_id"]}, required=False) if raw_pc else None
if post:
g.page_slug = post["slug"]
mps = await fetch_data(
"market", "marketplaces-for-container",
params={"type": "page", "id": post["id"]}, required=False,
) or []
if mps:
g.market_slug = mps[0].get("slug")
if order.sumup_checkout_id:
try:
await check_sumup_status(g.s, order)
except Exception:
pass
status = (order.status or "pending").lower()
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
raw_entries = await fetch_data("events", "entries-for-order",
params={"order_id": order.id}, required=False) or []
calendar_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
raw_tickets = await fetch_data("events", "tickets-for-order",
params={"order_id": order.id}, required=False) or []
order_tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets]
await g.s.flush()
html = await render_template(
"_types/cart/checkout_return.html",
order=order, status=status,
calendar_entries=calendar_entries,
order_tickets=order_tickets,
)
return await make_response(html)
return bp

View File

30
orders/bp/data/routes.py Normal file
View File

@@ -0,0 +1,30 @@
"""Orders app data endpoints."""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
def register() -> Blueprint:
bp = Blueprint("data", __name__, url_prefix="/internal/data")
@bp.before_request
async def _require_data_header():
if not request.headers.get(DATA_HEADER):
return jsonify({"error": "forbidden"}), 403
from shared.infrastructure.internal_auth import validate_internal_request
if not validate_internal_request():
return jsonify({"error": "forbidden"}), 403
_handlers: dict[str, object] = {}
@bp.get("/<query_name>")
async def handle_query(query_name: str):
handler = _handlers.get(query_name)
if handler is None:
return jsonify({"error": "unknown query"}), 404
result = await handler()
return jsonify(result)
return bp

View File

View File

@@ -0,0 +1,42 @@
"""Orders app fragment endpoints.
Fragments:
account-nav-item "orders" link for account dashboard
"""
from __future__ import annotations
from quart import Blueprint, Response, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
def register():
bp = Blueprint("fragments", __name__, url_prefix="/internal/fragments")
async def _account_nav_item():
from shared.infrastructure.urls import orders_url
href = orders_url("/")
return (
'<div class="relative nav-group">'
f'<a href="{href}" class="justify-center cursor-pointer flex flex-row '
'items-center gap-2 rounded bg-stone-200 text-black p-3" data-hx-disable>'
'orders</a></div>'
)
_handlers = {
"account-nav-item": _account_nav_item,
}
@bp.before_request
async def _require_fragment_header():
if not request.headers.get(FRAGMENT_HEADER):
return Response("", status=403)
@bp.get("/<fragment_type>")
async def get_fragment(fragment_type: str):
handler = _handlers.get(fragment_type)
if handler is None:
return Response("", status=200, content_type="text/html")
html = await handler()
return Response(html, status=200, content_type="text/html")
return bp

View File

View File

View File

@@ -0,0 +1,74 @@
# suma_browser/app/bp/order/filters/qs.py
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:
"""
Decode current query string into an OrderQuery(page, search).
"""
try:
page = int(request.args.get("page", 1) or 1)
except ValueError:
page = 1
search = request.args.get("search") or None
return OrderQuery(page, search)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Behaviour:
- If filters change and you don't explicitly pass page,
the page is reset to 1 (same pattern as browse/blog).
- You can clear search with search=None.
"""
q = decode()
base_search = q.search or None
base_page = int(q.page or 1)
def makeqs(
*,
clear_filters: bool = False,
search: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
) -> str:
filters_changed = False
# --- search logic ---
if search is KEEP and not clear_filters:
final_search = base_search
else:
filters_changed = True
final_search = (search or None)
# --- page logic ---
if page is None:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# --- build params ---
params: list[tuple[str, str]] = []
if final_search:
params.append(("search", final_search))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

124
orders/bp/order/routes.py Normal file
View File

@@ -0,0 +1,124 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from shared.models.order import Order
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
from shared.config import config
from shared.infrastructure.cart_identity import current_cart_identity
from services.check_sumup_status import check_sumup_status
from shared.browser.app.utils.htmx import is_htmx_request
from .filters.qs import makeqs_factory, decode
def _owner_filter():
"""Return SQLAlchemy clause restricting orders to current user/session."""
ident = current_cart_identity()
if ident["user_id"]:
return Order.user_id == ident["user_id"]
if ident["session_id"]:
return Order.session_id == ident["session_id"]
return None
def register() -> Blueprint:
bp = Blueprint("order", __name__, url_prefix='/<int:order_id>')
@bp.before_request
def route():
g.makeqs_factory = makeqs_factory
@bp.get("/")
async def order_detail(order_id: int):
"""Show a single order + items."""
owner = _owner_filter()
if owner is None:
return await make_response("Order not found", 404)
result = await g.s.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id, owner)
)
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if not is_htmx_request():
html = await render_template("_types/order/index.html", order=order)
else:
html = await render_template("_types/order/_oob_elements.html", order=order)
return await make_response(html)
@bp.get("/pay/")
async def order_pay(order_id: int):
"""Re-open the SumUp payment page for this order."""
owner = _owner_filter()
if owner is None:
return await make_response("Order not found", 404)
result = await g.s.execute(select(Order).where(Order.id == order_id, owner))
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if order.status == "paid":
return redirect(url_for("orders.order.order_detail", order_id=order.id))
if order.sumup_hosted_url:
return redirect(order.sumup_hosted_url)
redirect_url = url_for("checkout.checkout_return", order_id=order.id, _external=True)
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
webhook_url = url_for("checkout.checkout_webhook", order_id=order.id, _external=True)
if webhook_secret:
from urllib.parse import urlencode
sep = "&" if "?" in webhook_url else "?"
webhook_url = f"{webhook_url}{sep}{urlencode({'token': webhook_secret})}"
checkout_data = await sumup_create_checkout(
order,
redirect_url=redirect_url,
webhook_url=webhook_url,
)
order.sumup_checkout_id = checkout_data.get("id")
order.sumup_status = checkout_data.get("status")
hosted_cfg = checkout_data.get("hosted_checkout") or {}
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
order.sumup_hosted_url = hosted_url
await g.s.flush()
if not hosted_url:
html = await render_template(
"_types/cart/checkout_error.html",
order=order,
error="No hosted checkout URL returned from SumUp when trying to reopen payment.",
)
return await make_response(html, 500)
return redirect(hosted_url)
@bp.post("/recheck/")
async def order_recheck(order_id: int):
"""Manually re-check this order's status with SumUp."""
owner = _owner_filter()
if owner is None:
return await make_response("Order not found", 404)
result = await g.s.execute(select(Order).where(Order.id == order_id, owner))
order = result.scalar_one_or_none()
if not order:
return await make_response("Order not found", 404)
if not order.sumup_checkout_id:
return redirect(url_for("orders.order.order_detail", order_id=order.id))
try:
await check_sumup_status(g.s, order)
except Exception:
pass
return redirect(url_for("orders.order.order_detail", order_id=order.id))
return bp

View File

View File

View File

@@ -0,0 +1,77 @@
# suma_browser/app/bp/orders/filters/qs.py
from quart import request
from typing import Iterable, Optional, Union
from shared.browser.app.filters.qs_base import KEEP, build_qs
from shared.browser.app.filters.query_types import OrderQuery
def decode() -> OrderQuery:
"""
Decode current query string into an OrderQuery(page, search).
"""
try:
page = int(request.args.get("page", 1) or 1)
except ValueError:
page = 1
search = request.args.get("search") or None
return OrderQuery(page, search)
def makeqs_factory():
"""
Build a makeqs(...) that starts from the current filters + page.
Behaviour:
- If filters change and you don't explicitly pass page,
the page is reset to 1 (same pattern as browse/blog).
- You can clear search with search=None.
"""
q = decode()
base_search = q.search or None
base_page = int(q.page or 1)
def makeqs(
*,
clear_filters: bool = False,
search: Union[str, None, object] = KEEP,
page: Union[int, None, object] = None,
extra: Optional[Iterable[tuple]] = None,
leading_q: bool = True,
) -> str:
filters_changed = False
# --- search logic ---
if search is KEEP and not clear_filters:
final_search = base_search
else:
filters_changed = True
if search is KEEP:
final_search = None
else:
final_search = (search or None)
# --- page logic ---
if page is None:
final_page = 1 if filters_changed else base_page
else:
final_page = page
# --- build params ---
params: list[tuple[str, str]] = []
if final_search:
params.append(("search", final_search))
if final_page is not None:
params.append(("page", str(final_page)))
if extra:
for k, v in extra:
if v is not None:
params.append((k, str(v)))
return build_qs(params, leading_q=leading_q)
return makeqs

138
orders/bp/orders/routes.py Normal file
View File

@@ -0,0 +1,138 @@
from __future__ import annotations
from quart import Blueprint, g, render_template, redirect, url_for, make_response
from sqlalchemy import select, func, or_, cast, String, exists
from sqlalchemy.orm import selectinload
from shared.models.order import Order, OrderItem
from shared.infrastructure.http_utils import vary as _vary, current_url_without_page as _current_url_without_page
from shared.infrastructure.cart_identity import current_cart_identity
from shared.browser.app.utils.htmx import is_htmx_request
from bp.order.routes import register as register_order
from .filters.qs import makeqs_factory, decode
def register(url_prefix: str) -> Blueprint:
bp = Blueprint("orders", __name__, url_prefix=url_prefix)
bp.register_blueprint(register_order())
ORDERS_PER_PAGE = 10
oob = {
"extends": "_types/root/_index.html",
"child_id": "auth-header-child",
"header": "_types/auth/header/_header.html",
"nav": "_types/auth/_nav.html",
"main": "_types/auth/_main_panel.html",
}
@bp.context_processor
def inject_oob():
return {"oob": oob}
@bp.before_request
def route():
g.makeqs_factory = makeqs_factory
@bp.before_request
async def _require_identity():
"""Orders require a logged-in user or at least a cart session."""
ident = current_cart_identity()
if not ident["user_id"] and not ident["session_id"]:
return redirect(url_for("auth.login_form"))
@bp.get("/")
async def list_orders():
ident = current_cart_identity()
if ident["user_id"]:
owner_clause = Order.user_id == ident["user_id"]
elif ident["session_id"]:
owner_clause = Order.session_id == ident["session_id"]
else:
return redirect(url_for("auth.login_form"))
q = decode()
page, search = q.page, q.search
if page < 1:
page = 1
where_clause = None
if search:
term = f"%{search.strip()}%"
conditions = [
Order.status.ilike(term),
Order.currency.ilike(term),
Order.sumup_checkout_id.ilike(term),
Order.sumup_status.ilike(term),
Order.description.ilike(term),
]
conditions.append(
exists(
select(1)
.select_from(OrderItem)
.where(
OrderItem.order_id == Order.id,
or_(
OrderItem.product_title.ilike(term),
OrderItem.product_slug.ilike(term),
),
)
)
)
try:
search_id = int(search)
except (TypeError, ValueError):
search_id = None
if search_id is not None:
conditions.append(Order.id == search_id)
else:
conditions.append(cast(Order.id, String).ilike(term))
where_clause = or_(*conditions)
count_stmt = select(func.count()).select_from(Order).where(owner_clause)
if where_clause is not None:
count_stmt = count_stmt.where(where_clause)
total_count_result = await g.s.execute(count_stmt)
total_count = total_count_result.scalar_one() or 0
total_pages = max(1, (total_count + ORDERS_PER_PAGE - 1) // ORDERS_PER_PAGE)
if page > total_pages:
page = total_pages
offset = (page - 1) * ORDERS_PER_PAGE
stmt = (
select(Order)
.where(owner_clause)
.order_by(Order.created_at.desc())
.offset(offset)
.limit(ORDERS_PER_PAGE)
)
if where_clause is not None:
stmt = stmt.where(where_clause)
result = await g.s.execute(stmt)
orders = result.scalars().all()
context = {
"orders": orders,
"page": page,
"total_pages": total_pages,
"search": search,
"search_count": total_count,
}
if not is_htmx_request():
html = await render_template("_types/orders/index.html", **context)
elif page > 1:
html = await render_template("_types/orders/_rows.html", **context)
else:
html = await render_template("_types/orders/_oob_elements.html", **context)
resp = await make_response(html)
resp.headers["Hx-Push-Url"] = _current_url_without_page()
return _vary(resp)
return bp

61
orders/entrypoint.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Optional: wait for Postgres to be reachable
if [[ -n "${DATABASE_HOST:-}" && -n "${DATABASE_PORT:-}" ]]; then
echo "Waiting for Postgres at ${DATABASE_HOST}:${DATABASE_PORT}..."
for i in {1..60}; do
(echo > /dev/tcp/${DATABASE_HOST}/${DATABASE_PORT}) >/dev/null 2>&1 && break || true
sleep 1
done
fi
# Create own database + run own migrations
if [[ "${RUN_MIGRATIONS:-}" == "true" && -n "${ALEMBIC_DATABASE_URL:-}" ]]; then
python3 -c "
import os, re
url = os.environ['ALEMBIC_DATABASE_URL']
m = re.match(r'postgresql\+\w+://([^:]+):([^@]+)@([^:]+):(\d+)/(.+)', url)
if not m:
print('Could not parse ALEMBIC_DATABASE_URL, skipping DB creation')
exit(0)
user, password, host, port, dbname = m.groups()
import psycopg
conn = psycopg.connect(
f'postgresql://{user}:{password}@{host}:{port}/postgres',
autocommit=True,
)
cur = conn.execute('SELECT 1 FROM pg_database WHERE datname = %s', (dbname,))
if not cur.fetchone():
conn.execute(f'CREATE DATABASE {dbname}')
print(f'Created database {dbname}')
else:
print(f'Database {dbname} already exists')
conn.close()
" || echo "DB creation failed (non-fatal), continuing..."
echo "Running orders Alembic migrations..."
if [ -d orders ]; then (cd orders && alembic upgrade head); else alembic upgrade head; fi
fi
# Clear Redis page cache on deploy
if [[ -n "${REDIS_URL:-}" && "${REDIS_URL}" != "no" ]]; then
echo "Flushing Redis cache..."
python3 -c "
import redis, os
r = redis.from_url(os.environ['REDIS_URL'])
r.flushdb()
print('Redis cache cleared.')
" || echo "Redis flush failed (non-fatal), continuing..."
fi
# Start the app
RELOAD_FLAG=""
if [[ "${RELOAD:-}" == "true" ]]; then
RELOAD_FLAG="--reload"
echo "Starting Hypercorn (${APP_MODULE:-app:app}) with auto-reload..."
else
echo "Starting Hypercorn (${APP_MODULE:-app:app})..."
fi
PYTHONUNBUFFERED=1 exec hypercorn "${APP_MODULE:-app:app}" --bind 0.0.0.0:${PORT:-8000} --workers ${WORKERS:-2} --keep-alive 75 ${RELOAD_FLAG}

View File

@@ -0,0 +1 @@
from shared.models.order import Order, OrderItem

9
orders/path_setup.py Normal file
View File

@@ -0,0 +1,9 @@
import sys
import os
_app_dir = os.path.dirname(os.path.abspath(__file__))
_project_root = os.path.dirname(_app_dir)
for _p in (_project_root, _app_dir):
if _p not in sys.path:
sys.path.insert(0, _p)

View File

@@ -0,0 +1,6 @@
"""Orders app service registration."""
from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the orders app."""

View File

@@ -0,0 +1,63 @@
"""Check SumUp checkout status and update order accordingly.
Moved from cart/bp/cart/services/check_sumup_status.py.
"""
from types import SimpleNamespace
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_activity
from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
async def check_sumup_status(session, order, *, page_config=None):
# Auto-fetch page_config from blog if order has one and caller didn't provide it
if page_config is None and order.page_config_id:
raw_pc = await fetch_data(
"blog", "page-config-by-id",
params={"id": order.page_config_id},
required=False,
)
if raw_pc:
page_config = SimpleNamespace(**raw_pc)
checkout_data = await sumup_get_checkout(order.sumup_checkout_id, page_config=page_config)
order.sumup_status = checkout_data.get("status") or order.sumup_status
sumup_status = (order.sumup_status or "").upper()
if sumup_status == "PAID":
if order.status != "paid":
order.status = "paid"
await call_action("events", "confirm-entries-for-order", payload={
"order_id": order.id, "user_id": order.user_id,
"session_id": order.session_id,
})
await call_action("events", "confirm-tickets-for-order", payload={
"order_id": order.id,
})
page_post_id = page_config.container_id if page_config else None
await call_action("cart", "clear-cart-for-order", payload={
"user_id": order.user_id,
"session_id": order.session_id,
"page_post_id": page_post_id,
})
await emit_activity(
session,
activity_type="rose:OrderPaid",
actor_uri="internal:orders",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": order.user_id,
},
source_type="order",
source_id=order.id,
)
elif sumup_status == "FAILED":
order.status = "failed"
else:
order.status = sumup_status.lower() or order.status
await session.flush()

147
orders/services/checkout.py Normal file
View File

@@ -0,0 +1,147 @@
"""Order creation and SumUp checkout helpers.
Moved from cart/bp/cart/services/checkout.py.
Only the order-side logic lives here; find_or_create_cart_item stays in cart.
"""
from __future__ import annotations
from typing import Optional
from urllib.parse import urlencode
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models.order import Order, OrderItem
from shared.config import config
from shared.events import emit_activity
from shared.infrastructure.actions import call_action
from shared.infrastructure.data_client import fetch_data
async def resolve_page_config_from_post_id(post_id: int) -> Optional[SimpleNamespace]:
"""Fetch the PageConfig for *post_id* from the blog service."""
raw_pc = await fetch_data(
"blog", "page-config",
params={"container_type": "page", "container_id": post_id},
required=False,
)
return SimpleNamespace(**raw_pc) if raw_pc else None
async def create_order(
session: AsyncSession,
cart_items: list[dict],
calendar_entries: list,
user_id: Optional[int],
session_id: Optional[str],
product_total: float,
calendar_total: float,
*,
ticket_total: float = 0,
page_post_id: int | None = None,
) -> Order:
"""Create an Order + OrderItems from serialized cart data."""
cart_total = product_total + calendar_total + ticket_total
currency = (cart_items[0].get("product_price_currency") if cart_items else None) or "GBP"
order = Order(
user_id=user_id,
session_id=session_id,
status="pending",
currency=currency,
total_amount=cart_total,
)
session.add(order)
await session.flush()
for ci in cart_items:
price = ci.get("product_special_price") or ci.get("product_regular_price") or 0
oi = OrderItem(
order=order,
product_id=ci["product_id"],
product_title=ci.get("product_title"),
product_slug=ci.get("product_slug"),
product_image=ci.get("product_image"),
quantity=ci.get("quantity", 1),
unit_price=price,
currency=currency,
)
session.add(oi)
await call_action("events", "claim-entries-for-order", payload={
"order_id": order.id, "user_id": user_id,
"session_id": session_id, "page_post_id": page_post_id,
})
await call_action("events", "claim-tickets-for-order", payload={
"order_id": order.id, "user_id": user_id,
"session_id": session_id, "page_post_id": page_post_id,
})
await emit_activity(
session,
activity_type="Create",
actor_uri="internal:orders",
object_type="rose:Order",
object_data={
"order_id": order.id,
"user_id": user_id,
"session_id": session_id,
},
source_type="order",
source_id=order.id,
)
return order
def build_sumup_description(cart_items: list[dict], order_id: int, *, ticket_count: int = 0) -> str:
titles = [ci.get("product_title") for ci in cart_items if ci.get("product_title")]
item_count = sum(ci.get("quantity", 1) for ci in cart_items)
parts = []
if titles:
if len(titles) <= 3:
parts.append(", ".join(titles))
else:
parts.append(", ".join(titles[:3]) + f" + {len(titles) - 3} more")
if ticket_count:
parts.append(f"{ticket_count} ticket{'s' if ticket_count != 1 else ''}")
summary = ", ".join(parts) if parts else "order items"
total_count = item_count + ticket_count
return f"Order {order_id} ({total_count} item{'s' if total_count != 1 else ''}): {summary}"
def build_sumup_reference(order_id: int, page_config=None) -> str:
if page_config and page_config.sumup_checkout_prefix:
prefix = page_config.sumup_checkout_prefix
else:
sumup_cfg = config().get("sumup", {}) or {}
prefix = sumup_cfg.get("checkout_reference_prefix", "")
return f"{prefix}{order_id}"
def build_webhook_url(base_url: str) -> str:
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if webhook_secret:
sep = "&" if "?" in base_url else "?"
return f"{base_url}{sep}{urlencode({'token': webhook_secret})}"
return base_url
def validate_webhook_secret(token: Optional[str]) -> bool:
sumup_cfg = config().get("sumup", {}) or {}
webhook_secret = sumup_cfg.get("webhook_secret")
if not webhook_secret:
return True
return token is not None and token == webhook_secret
async def get_order_with_details(session: AsyncSession, order_id: int) -> Optional[Order]:
result = await session.execute(
select(Order)
.options(selectinload(Order.items))
.where(Order.id == order_id)
)
return result.scalar_one_or_none()

View File

@@ -0,0 +1,38 @@
{% extends '_types/root/index.html' %}
{% block filter %}
<header class="mb-6 sm:mb-8">
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
Checkout error
</h1>
<p class="text-xs sm:text-sm text-stone-600">
We tried to start your payment with SumUp but hit a problem.
</p>
</header>
{% endblock %}
{% block content %}
<div class="max-w-full px-3 py-3 space-y-4">
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
<p class="font-medium">Something went wrong.</p>
<p>
{{ error or "Unexpected error while creating the hosted checkout session." }}
</p>
{% if order %}
<p class="text-xs text-rose-800/80">
Order ID: <span class="font-mono">#{{ order.id }}</span>
</p>
{% endif %}
</div>
<div>
<a
href="{{ cart_url('/') }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa fa-shopping-cart mr-2" aria-hidden="true"></i>
Back to cart
</a>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,68 @@
{% extends '_types/root/index.html' %}
{% block filter %}
<header class="mb-1 sm:mb-2 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div class="space-y-1">
<h1 class="text-xl sm:text-2xl md:text-3xl font-semibold tracking-tight">
{% if order.status == 'paid' %}
Payment received
{% elif order.status == 'failed' %}
Payment failed
{% elif order.status == 'missing' %}
Order not found
{% else %}
Payment status: {{ order.status|default('pending')|capitalize }}
{% endif %}
</h1>
<p class="text-xs sm:text-sm text-stone-600">
{% if order.status == 'paid' %}
Thanks for your order.
{% elif order.status == 'failed' %}
Something went wrong while processing your payment. You can try again below.
{% elif order.status == 'missing' %}
We couldn't find that order it may have expired or never been created.
{% else %}
Were still waiting for a final confirmation from SumUp.
{% endif %}
</p>
</div>
</header>
{% endblock %}
{% block aside %}
{# no aside content for now #}
{% endblock %}
{% block content %}
<div class="max-w-full px-1 py-1">
{% if order %}
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2">
{% include '_types/order/_summary.html' %}
</div>
{% else %}
<div class="rounded-2xl border border-dashed border-rose-300 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-800">
We couldnt find that order. If you reached this page from an old link, please start a new order.
</div>
{% endif %}
{% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %}
{% include '_types/order/_ticket_items.html' %}
{% if order.status == 'failed' and order %}
<div class="rounded-2xl border border-rose-200 bg-rose-50/80 p-4 sm:p-6 text-sm text-rose-900 space-y-2">
<p class="font-medium">Your payment was not completed.</p>
<p>
You can go back to your cart and try checkout again. If the problem persists,
please contact us and mention order <span class="font-mono">#{{ order.id }}</span>.
</p>
</div>
{% elif order.status == 'paid' %}
<div class="rounded-2xl border border-emerald-200 bg-emerald-50/80 p-4 sm:p-6 text-sm text-emerald-900 space-y-2">
<p class="font-medium">All done!</p>
<p>Well start processing your order shortly.</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{# --- NEW: calendar bookings in this order --- #}
{% if order and calendar_entries %}
<section class="mt-6 space-y-3">
<h2 class="text-base sm:text-lg font-semibold">
Calendar bookings in this order
</h2>
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
{% for entry in calendar_entries %}
<li class="px-4 py-3 flex items-start justify-between text-sm">
<div>
<div class="font-medium flex items-center gap-2">
{{ entry.name }}
{# Small status pill #}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if entry.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif entry.state == 'provisional' %}
bg-amber-100 text-amber-800
{% elif entry.state == 'ordered' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ entry.state|capitalize }}
</span>
</div>
<div class="text-xs text-stone-500">
{{ entry.start_at.strftime('%-d %b %Y, %H:%M') }}
{% if entry.end_at %}
{{ entry.end_at.strftime('%-d %b %Y, %H:%M') }}
{% endif %}
</div>
</div>
<div class="ml-4 font-medium">
£{{ "%.2f"|format(entry.cost or 0) }}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -0,0 +1,51 @@
{# Items list #}
{% if order and order.items %}
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6">
<h2 class="text-sm sm:text-base font-semibold mb-3">
Items
</h2>
<ul class="divide-y divide-stone-100 text-xs sm:text-sm">
{% for item in order.items %}
<li>
<a class="w-full py-2 flex gap-3" href="{{ market_product_url(item.product_slug) }}">
{# Thumbnail #}
<div class="w-12 h-12 sm:w-14 sm:h-14 rounded-md bg-stone-100 flex-shrink-0 overflow-hidden">
{% if item.product_image %}
<img
src="{{ item.product_image }}"
alt="{{ item.product_title or 'Product image' }}"
class="w-full h-full object-contain object-center"
loading="lazy"
decoding="async"
>
{% else %}
<div class="w-full h-full flex items-center justify-center text-[9px] text-stone-400">
No image
</div>
{% endif %}
</div>
{# Text + pricing #}
<div class="flex-1 flex justify-between gap-3">
<div>
<p class="font-medium">
{{ item.product_title or 'Unknown product' }}
</p>
<p class="text-[11px] text-stone-500">
Product ID: {{ item.product_id }}
</p>
</div>
<div class="text-right whitespace-nowrap">
<p>Qty: {{ item.quantity }}</p>
<p>
{{ item.currency or order.currency or 'GBP' }}
{{ '%.2f'|format(item.unit_price or 0) }}
</p>
</div>
</div>
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}

View File

@@ -0,0 +1,7 @@
<div class="max-w-full px-3 py-3 space-y-4">
{# Order summary card #}
{% include '_types/order/_summary.html' %}
{% include '_types/order/_items.html' %}
{% include '_types/order/_calendar_items.html' %}
</div>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,30 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('orders-header-child', 'order-header-child', '_types/order/header/_header.html')}}
{% from '_types/order/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block mobile_menu %}
{% include '_types/order/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/order/_main_panel.html" %}
{% endblock %}

View File

@@ -0,0 +1,52 @@
<div class="rounded-2xl border border-stone-200 bg-white/80 p-4 sm:p-6 space-y-2 text-xs sm:text-sm text-stone-800">
<p>
<span class="font-medium">Order ID:</span>
<span class="font-mono">#{{ order.id }}</span>
</p>
<p>
<span class="font-medium">Created:</span>
{% if order.created_at %}
{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}
{% else %}
{% endif %}
</p>
<p>
<span class="font-medium">Description:</span>
{{ order.description or '' }}
</p>
<p>
<span class="font-medium">Status:</span>
<span class="inline-flex items-center rounded-full px-2 py-1 text-[11px] font-medium
{% if order.status == 'paid' %}
bg-emerald-50 text-emerald-700 border border-emerald-200
{% elif order.status == 'failed' %}
bg-rose-50 text-rose-700 border border-rose-200
{% else %}
bg-stone-50 text-stone-700 border border-stone-200
{% endif %}
">
{{ order.status or 'pending' }}
</span>
</p>
<p>
<span class="font-medium">Currency:</span>
{{ order.currency or 'GBP' }}
</p>
<p>
<span class="font-medium">Total:</span>
{% if order.total_amount %}
{{ order.currency or 'GBP' }} {{ '%.2f'|format(order.total_amount) }}
{% else %}
{% endif %}
</p>
</div>

View File

@@ -0,0 +1,49 @@
{# --- Tickets in this order --- #}
{% if order and order_tickets %}
<section class="mt-6 space-y-3">
<h2 class="text-base sm:text-lg font-semibold">
Event tickets in this order
</h2>
<ul class="divide-y divide-stone-200 rounded-2xl border border-stone-200 bg-white/80">
{% for tk in order_tickets %}
<li class="px-4 py-3 flex items-start justify-between text-sm">
<div>
<div class="font-medium flex items-center gap-2">
{{ tk.entry_name }}
{# Small status pill #}
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium
{% if tk.state == 'confirmed' %}
bg-emerald-100 text-emerald-800
{% elif tk.state == 'reserved' %}
bg-amber-100 text-amber-800
{% elif tk.state == 'checked_in' %}
bg-blue-100 text-blue-800
{% else %}
bg-stone-100 text-stone-700
{% endif %}
">
{{ tk.state|replace('_', ' ')|capitalize }}
</span>
</div>
{% if tk.ticket_type_name %}
<div class="text-xs text-stone-500">{{ tk.ticket_type_name }}</div>
{% endif %}
<div class="text-xs text-stone-500">
{{ tk.entry_start_at.strftime('%-d %b %Y, %H:%M') }}
{% if tk.entry_end_at %}
{{ tk.entry_end_at.strftime('%-d %b %Y, %H:%M') }}
{% endif %}
</div>
<div class="text-xs text-stone-400 font-mono mt-0.5">
{{ tk.code }}
</div>
</div>
<div class="ml-4 font-medium">
£{{ "%.2f"|format(tk.price or 0) }}
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}

View File

@@ -0,0 +1,17 @@
{% import 'macros/links.html' as links %}
{% macro header_row(oob=False) %}
{% call links.menu_row(id='order-row', oob=oob) %}
{% call links.link(url_for('orders.order.order_detail', order_id=order.id), hx_select_search ) %}
<i class="fa fa-gbp" aria-hidden="true"></i>
<div>
Order
</div>
<div>
{{ order.id }}
</div>
{% endcall %}
{% call links.desktop_nav() %}
{% include '_types/order/_nav.html' %}
{% endcall %}
{% endcall %}
{% endmacro %}

View File

@@ -0,0 +1,68 @@
{% extends '_types/orders/index.html' %}
{% block orders_header_child %}
{% from '_types/root/_n/macros.html' import index_row with context %}
{% call index_row('order-header-child', '_types/order/header/_header.html') %}
{% block order_header_child %}
{% endblock %}
{% endcall %}
{% endblock %}
{% block _main_mobile_menu %}
{% include '_types/order/_nav.html' %}
{% endblock %}
{% block filter %}
<header class="mb-6 sm:mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3 sm:gap-4">
<div class="space-y-1">
<p class="text-xs sm:text-sm text-stone-600">
Placed {% if order.created_at %}{{ order.created_at.strftime('%-d %b %Y, %H:%M') }}{% else %}—{% endif %} &middot; Status: {{ order.status or 'pending' }}
</p>
</div>
<div class="flex w-full sm:w-auto justify-start sm:justify-end gap-2">
<a
href="{{ url_for('orders.list_orders')|host }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa-solid fa-list mr-2" aria-hidden="true"></i>
All orders
</a>
{# Re-check status button #}
<form
method="post"
action="{{ url_for('orders.order.order_recheck', order_id=order.id)|host }}"
class="inline"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button
type="submit"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-stone-300 bg-white hover:bg-stone-50 transition"
>
<i class="fa-solid fa-rotate mr-2" aria-hidden="true"></i>
Re-check status
</button>
</form>
{% if order.status != 'paid' %}
<a
href="{{ url_for('orders.order.order_pay', order_id=order.id)|host }}"
class="inline-flex items-center px-3 py-2 text-xs sm:text-sm rounded-full border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 transition"
>
<i class="fa fa-credit-card mr-2" aria-hidden="true"></i>
Open payment page
</a>
{% endif %}
</div>
</header>
{% endblock %}
{% block content %}
{% include '_types/order/_main_panel.html' %}
{% endblock %}
{% block aside %}
{% endblock %}

View File

@@ -0,0 +1,26 @@
<div class="max-w-full px-3 py-3 space-y-3">
{% if not orders %}
<div class="rounded-2xl border border-dashed border-stone-300 bg-white/80 p-4 sm:p-6 text-sm text-stone-700">
No orders yet.
</div>
{% else %}
<div class="overflow-x-auto rounded-2xl border border-stone-200 bg-white/80">
<table class="min-w-full text-xs sm:text-sm">
<thead class="bg-stone-50 border-b border-stone-200 text-stone-600">
<tr>
<th class="px-3 py-2 text-left font-medium">Order</th>
<th class="px-3 py-2 text-left font-medium">Created</th>
<th class="px-3 py-2 text-left font-medium">Description</th>
<th class="px-3 py-2 text-left font-medium">Total</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium"></th>
</tr>
</thead>
<tbody>
{# rows + infinite-scroll sentinel #}
{% include "_types/orders/_rows.html" %}
</tbody>
</table>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,2 @@
{% from 'macros/admin_nav.html' import placeholder_nav %}
{{ placeholder_nav() }}

View File

@@ -0,0 +1,38 @@
{% extends 'oob_elements.html' %}
{# OOB elements for HTMX navigation - all elements that need updating #}
{# Import shared OOB macros #}
{% from '_types/root/header/_oob.html' import root_header_start, root_header_end with context %}
{% from '_types/root/_oob_menu.html' import mobile_menu with context %}
{# Header with app title - includes cart-mini, navigation, and market-specific header #}
{% block oobs %}
{% from '_types/root/_n/macros.html' import oob_header with context %}
{{oob_header('auth-header-child', 'orders-header-child', '_types/orders/header/_header.html')}}
{% from '_types/auth/header/_header.html' import header_row with context %}
{{ header_row(oob=True) }}
{% endblock %}
{% block aside %}
{% from 'macros/search.html' import search_desktop %}
{{ search_desktop(current_local_href, search, search_count, hx_select) }}
{% endblock %}
{% block filter %}
{% include '_types/orders/_summary.html' %}
{% endblock %}
{% block mobile_menu %}
{% include '_types/orders/_nav.html' %}
{% endblock %}
{% block content %}
{% include "_types/orders/_main_panel.html" %}
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More