Split cart into 4 microservices: relations, likes, orders, page-config→blog
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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:
@@ -38,6 +38,12 @@ COPY events/__init__.py ./events/__init__.py
|
||||
COPY events/models/ ./events/models/
|
||||
COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY account/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -46,6 +46,12 @@ COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# Copy built editor assets from stage 1
|
||||
COPY --from=editor-build /static/scripts/editor.js /static/scripts/editor.css shared/static/scripts/
|
||||
|
||||
@@ -6,14 +6,16 @@ MODELS = [
|
||||
"shared.models.kv",
|
||||
"shared.models.menu_item",
|
||||
"shared.models.menu_node",
|
||||
"shared.models.page_config",
|
||||
"blog.models.snippet",
|
||||
"blog.models.tag_group",
|
||||
]
|
||||
|
||||
TABLES = frozenset({
|
||||
"posts", "authors", "post_authors", "tags", "post_tags", "post_likes",
|
||||
"posts", "authors", "post_authors", "tags", "post_tags",
|
||||
"snippets", "tag_groups", "tag_group_tags",
|
||||
"menu_items", "menu_nodes", "kv",
|
||||
"page_configs",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
|
||||
@@ -31,13 +31,65 @@ def register() -> Blueprint:
|
||||
result = await handler()
|
||||
return jsonify(result or {"ok": True})
|
||||
|
||||
# --- update-page-config (proxy to cart, where page_configs table lives) ---
|
||||
# --- update-page-config ---
|
||||
async def _update_page_config():
|
||||
"""Create or update a PageConfig — proxies to cart service."""
|
||||
from shared.infrastructure.actions import call_action
|
||||
"""Create or update a PageConfig (page_configs now lives in db_blog)."""
|
||||
from shared.models.page_config import PageConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
return await call_action("cart", "update-page-config", payload=data)
|
||||
container_type = data.get("container_type", "page")
|
||||
container_id = data.get("container_id")
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=data.get("features", {}),
|
||||
)
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
|
||||
if "features" in data:
|
||||
features = dict(pc.features or {})
|
||||
for key, val in data["features"].items():
|
||||
if isinstance(val, bool):
|
||||
features[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
features[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
features[key] = False
|
||||
pc.features = features
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if "sumup_merchant_code" in data:
|
||||
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
|
||||
if "sumup_checkout_prefix" in data:
|
||||
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
|
||||
if "sumup_api_key" in data:
|
||||
pc.sumup_api_key = data["sumup_api_key"] or None
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
"sumup_configured": bool(pc.sumup_api_key),
|
||||
}
|
||||
|
||||
_handlers["update-page-config"] = _update_page_config
|
||||
|
||||
|
||||
@@ -286,17 +286,28 @@ class DBClient:
|
||||
|
||||
rows: List[Post] = list((await self.sess.execute(q)).scalars())
|
||||
|
||||
# Load PageConfigs from cart service (page_configs lives in db_cart)
|
||||
# Load PageConfigs directly (page_configs now lives in db_blog)
|
||||
post_ids = [p.id for p in rows]
|
||||
pc_map: Dict[int, dict] = {}
|
||||
if post_ids:
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.models.page_config import PageConfig
|
||||
try:
|
||||
raw_pcs = await fetch_data("cart", "page-configs-batch",
|
||||
params={"container_type": "page", "ids": ",".join(str(i) for i in post_ids)})
|
||||
if isinstance(raw_pcs, list):
|
||||
for pc in raw_pcs:
|
||||
pc_map[pc["container_id"]] = pc
|
||||
pc_result = await self.sess.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id.in_(post_ids),
|
||||
)
|
||||
)
|
||||
for pc in pc_result.scalars().all():
|
||||
pc_map[pc.container_id] = {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
except Exception:
|
||||
pass # graceful degradation — pages render without features
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import re
|
||||
|
||||
from ..ghost_db import DBClient # adjust import path
|
||||
from sqlalchemy import select
|
||||
from models.ghost_content import PostLike
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.infrastructure.fragments import fetch_fragment
|
||||
from quart import g
|
||||
|
||||
@@ -68,22 +67,15 @@ async def posts_data(
|
||||
post_ids = [p["id"] for p in posts]
|
||||
|
||||
# Add is_liked field to each post for current user
|
||||
if g.user:
|
||||
# Fetch all likes for this user and these posts in one query
|
||||
liked_posts = await session.execute(
|
||||
select(PostLike.post_id).where(
|
||||
PostLike.user_id == g.user.id,
|
||||
PostLike.post_id.in_(post_ids),
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
liked_post_ids = {row[0] for row in liked_posts}
|
||||
if g.user and post_ids:
|
||||
liked_ids_list = await fetch_data("likes", "liked-ids", params={
|
||||
"user_id": g.user.id, "target_type": "post",
|
||||
}, required=False) or []
|
||||
liked_post_ids = set(liked_ids_list)
|
||||
|
||||
# Add is_liked to each post
|
||||
for post in posts:
|
||||
post["is_liked"] = post["id"] in liked_post_ids
|
||||
else:
|
||||
# Not logged in - no posts are liked
|
||||
for post in posts:
|
||||
post["is_liked"] = False
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
from quart import Blueprint, g, jsonify, request
|
||||
|
||||
from shared.infrastructure.data_client import DATA_HEADER, fetch_data
|
||||
from shared.infrastructure.data_client import DATA_HEADER
|
||||
from shared.contracts.dtos import dto_to_dict
|
||||
from shared.services.registry import services
|
||||
|
||||
@@ -74,31 +74,112 @@ def register() -> Blueprint:
|
||||
|
||||
_handlers["search-posts"] = _search_posts
|
||||
|
||||
# --- page-config (proxy to cart, where page_configs table lives) ---
|
||||
# --- page-config-ensure ---
|
||||
async def _page_config_ensure():
|
||||
"""Get or create a PageConfig for a container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = request.args.get("container_id", type=int)
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
row = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
g.s.add(row)
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
_handlers["page-config-ensure"] = _page_config_ensure
|
||||
|
||||
# --- page-config ---
|
||||
async def _page_config():
|
||||
"""Return a single PageConfig by container_type + container_id."""
|
||||
return await fetch_data("cart", "page-config",
|
||||
params={"container_type": request.args.get("container_type", "page"),
|
||||
"container_id": request.args.get("container_id", "")},
|
||||
required=False)
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
cid = request.args.get("container_id", type=int)
|
||||
if cid is None:
|
||||
return None
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id == cid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config"] = _page_config
|
||||
|
||||
# --- page-config-by-id (proxy to cart) ---
|
||||
# --- page-config-by-id ---
|
||||
async def _page_config_by_id():
|
||||
return await fetch_data("cart", "page-config-by-id",
|
||||
params={"id": request.args.get("id", "")},
|
||||
required=False)
|
||||
"""Return a single PageConfig by its primary key."""
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
pc_id = request.args.get("id", type=int)
|
||||
if pc_id is None:
|
||||
return None
|
||||
pc = await g.s.get(PageConfig, pc_id)
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config-by-id"] = _page_config_by_id
|
||||
|
||||
# --- page-configs-batch (proxy to cart) ---
|
||||
# --- page-configs-batch ---
|
||||
async def _page_configs_batch():
|
||||
return await fetch_data("cart", "page-configs-batch",
|
||||
params={"container_type": request.args.get("container_type", "page"),
|
||||
"ids": request.args.get("ids", "")},
|
||||
required=False) or []
|
||||
"""Return PageConfigs for multiple container_ids (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_page_config_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
_handlers["page-configs-batch"] = _page_configs_batch
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
def _page_config_dict(pc) -> dict:
|
||||
"""Serialize PageConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ async def create_menu_item(
|
||||
)
|
||||
session.add(menu_node)
|
||||
await session.flush()
|
||||
await call_action("cart", "attach-child", payload={
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
@@ -134,11 +134,11 @@ async def update_menu_item(
|
||||
await session.flush()
|
||||
|
||||
if post_id is not None and post_id != old_post_id:
|
||||
await call_action("cart", "detach-child", payload={
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": old_post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
await call_action("cart", "attach-child", payload={
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
@@ -154,7 +154,7 @@ async def delete_menu_item(session: AsyncSession, item_id: int) -> bool:
|
||||
|
||||
menu_node.deleted_at = func.now()
|
||||
await session.flush()
|
||||
await call_action("cart", "detach-child", payload={
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": menu_node.container_id,
|
||||
"child_type": "menu_node", "child_id": menu_node.id,
|
||||
})
|
||||
|
||||
@@ -22,23 +22,27 @@ def register():
|
||||
@require_admin
|
||||
async def admin(slug: str):
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
# Load features for page admin (page_configs lives in db_cart)
|
||||
# Load features for page admin (page_configs now lives in db_blog)
|
||||
post = (g.post_data or {}).get("post", {})
|
||||
features = {}
|
||||
sumup_configured = False
|
||||
sumup_merchant_code = ""
|
||||
sumup_checkout_prefix = ""
|
||||
if post.get("is_page"):
|
||||
raw_pc = await fetch_data("cart", "page-config",
|
||||
params={"container_type": "page", "container_id": post["id"]},
|
||||
required=False)
|
||||
if raw_pc:
|
||||
features = raw_pc.get("features") or {}
|
||||
sumup_configured = bool(raw_pc.get("sumup_api_key"))
|
||||
sumup_merchant_code = raw_pc.get("sumup_merchant_code") or ""
|
||||
sumup_checkout_prefix = raw_pc.get("sumup_checkout_prefix") or ""
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == "page",
|
||||
PageConfig.container_id == post["id"],
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pc:
|
||||
features = pc.features or {}
|
||||
sumup_configured = bool(pc.sumup_api_key)
|
||||
sumup_merchant_code = pc.sumup_merchant_code or ""
|
||||
sumup_checkout_prefix = pc.sumup_checkout_prefix or ""
|
||||
|
||||
ctx = {
|
||||
"features": features,
|
||||
@@ -84,7 +88,7 @@ def register():
|
||||
return jsonify({"error": "Expected JSON object with feature flags."}), 400
|
||||
|
||||
# Update via cart action (page_configs lives in db_cart)
|
||||
result = await call_action("cart", "update-page-config", payload={
|
||||
result = await call_action("blog", "update-page-config", payload={
|
||||
"container_type": "page",
|
||||
"container_id": post_id,
|
||||
"features": body,
|
||||
@@ -129,7 +133,7 @@ def register():
|
||||
if api_key:
|
||||
payload["sumup_api_key"] = api_key
|
||||
|
||||
result = await call_action("cart", "update-page-config", payload=payload)
|
||||
result = await call_action("blog", "update-page-config", payload=payload)
|
||||
|
||||
features = result.get("features", {})
|
||||
html = await render_template(
|
||||
|
||||
@@ -11,8 +11,8 @@ from quart import (
|
||||
request,
|
||||
)
|
||||
from .services.post_data import post_data
|
||||
from .services.post_operations import toggle_post_like
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from shared.infrastructure.actions import call_action
|
||||
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
|
||||
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
|
||||
|
||||
@@ -144,11 +144,10 @@ def register():
|
||||
post_id = g.post_data["post"]["id"]
|
||||
user_id = g.user.id
|
||||
|
||||
liked, error = await toggle_post_like(g.s, user_id, post_id)
|
||||
|
||||
if error:
|
||||
resp = make_response(error, 404)
|
||||
return resp
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "post", "target_id": post_id,
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
html = await render_template(
|
||||
"_types/browse/like/button.html",
|
||||
|
||||
@@ -41,7 +41,7 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
|
||||
if not post.is_page:
|
||||
raise MarketError("Markets can only be created on pages, not posts.")
|
||||
|
||||
raw_pc = await fetch_data("cart", "page-config",
|
||||
raw_pc = await fetch_data("blog", "page-config",
|
||||
params={"container_type": "page", "container_id": post_id},
|
||||
required=False)
|
||||
if raw_pc is None or not (raw_pc.get("features") or {}).get("market"):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from ...blog.ghost_db import DBClient # adjust import path
|
||||
from sqlalchemy import select
|
||||
from models.ghost_content import PostLike
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from quart import g
|
||||
|
||||
async def post_data(slug, session, include_drafts=False):
|
||||
@@ -15,14 +14,10 @@ async def post_data(slug, session, include_drafts=False):
|
||||
# Check if current user has liked this post
|
||||
is_liked = False
|
||||
if g.user:
|
||||
liked_record = await session.scalar(
|
||||
select(PostLike).where(
|
||||
PostLike.user_id == g.user.id,
|
||||
PostLike.post_id == post["id"],
|
||||
PostLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
is_liked = liked_record is not None
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": g.user.id, "target_type": "post", "target_id": post["id"],
|
||||
}, required=False)
|
||||
is_liked = (liked_data or {}).get("liked", False)
|
||||
|
||||
# Add is_liked to post dict
|
||||
post["is_liked"] = is_liked
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag, PostLike
|
||||
from .ghost_content import Post, Author, Tag, PostAuthor, PostTag
|
||||
from .snippet import Snippet
|
||||
from .tag_group import TagGroup, TagGroupTag
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
from shared.models.ghost_content import ( # noqa: F401
|
||||
Tag, Post, Author, PostAuthor, PostTag, PostLike,
|
||||
Tag, Post, Author, PostAuthor, PostTag,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the blog app.
|
||||
|
||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
|
||||
Blog owns: Post, Tag, Author, PostAuthor, PostTag.
|
||||
Cross-app calls go over HTTP via call_action() / fetch_data().
|
||||
"""
|
||||
from shared.services.registry import services
|
||||
|
||||
@@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY cart/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -3,13 +3,10 @@ from shared.db.alembic_env import run_alembic
|
||||
|
||||
MODELS = [
|
||||
"shared.models.market", # CartItem lives here
|
||||
"shared.models.order",
|
||||
"shared.models.page_config",
|
||||
"shared.models.container_relation",
|
||||
]
|
||||
|
||||
TABLES = frozenset({
|
||||
"cart_items", "orders", "order_items", "page_configs", "container_relations",
|
||||
"cart_items",
|
||||
})
|
||||
|
||||
run_alembic(context.config, MODELS, TABLES)
|
||||
|
||||
66
cart/app.py
66
cart/app.py
@@ -15,7 +15,6 @@ from bp import (
|
||||
register_cart_overview,
|
||||
register_page_cart,
|
||||
register_cart_global,
|
||||
register_orders,
|
||||
register_fragments,
|
||||
register_actions,
|
||||
register_data,
|
||||
@@ -121,7 +120,6 @@ def _make_page_config(raw: dict) -> SimpleNamespace:
|
||||
|
||||
|
||||
def create_app() -> "Quart":
|
||||
from shared.services.registry import services
|
||||
from services import register_domain_services
|
||||
|
||||
app = create_base_app(
|
||||
@@ -184,10 +182,7 @@ def create_app() -> "Quart":
|
||||
# --- Blueprint registration ---
|
||||
# Static prefixes first, dynamic (page_slug) last
|
||||
|
||||
# Orders blueprint
|
||||
app.register_blueprint(register_orders(url_prefix="/orders"))
|
||||
|
||||
# Global routes (webhook, return, add — specific paths under /)
|
||||
# Global routes (add, quantity, delete, checkout — specific paths under /)
|
||||
app.register_blueprint(
|
||||
register_cart_global(url_prefix="/"),
|
||||
url_prefix="/",
|
||||
@@ -205,65 +200,6 @@ def create_app() -> "Quart":
|
||||
url_prefix="/<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
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
from .cart.overview_routes import register as register_cart_overview
|
||||
from .cart.page_routes import register as register_page_cart
|
||||
from .cart.global_routes import register as register_cart_global
|
||||
from .order.routes import register as register_order
|
||||
from .orders.routes import register as register_orders
|
||||
from .fragments import register_fragments
|
||||
from .actions import register_actions
|
||||
from .data import register_data
|
||||
|
||||
@@ -47,109 +47,26 @@ def register() -> Blueprint:
|
||||
|
||||
_handlers["adopt-cart-for-user"] = _adopt_cart
|
||||
|
||||
# --- update-page-config ---
|
||||
async def _update_page_config():
|
||||
"""Create or update a PageConfig (page_configs lives in db_cart)."""
|
||||
from shared.models.page_config import PageConfig
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
# --- clear-cart-for-order ---
|
||||
async def _clear_cart_for_order():
|
||||
"""Soft-delete cart items after an order is paid. Called by orders service."""
|
||||
from bp.cart.services.clear_cart_for_order import clear_cart_for_order
|
||||
from shared.models.order import Order
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
container_type = data.get("container_type", "page")
|
||||
container_id = data.get("container_id")
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
data = await request.get_json()
|
||||
user_id = data.get("user_id")
|
||||
session_id = data.get("session_id")
|
||||
page_post_id = data.get("page_post_id")
|
||||
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
# Build a minimal order-like object with the fields clear_cart_for_order needs
|
||||
order = type("_Order", (), {
|
||||
"user_id": user_id,
|
||||
"session_id": session_id,
|
||||
})()
|
||||
|
||||
if pc is None:
|
||||
pc = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features=data.get("features", {}),
|
||||
)
|
||||
g.s.add(pc)
|
||||
await g.s.flush()
|
||||
await clear_cart_for_order(g.s, order, page_post_id=page_post_id)
|
||||
return {"ok": True}
|
||||
|
||||
if "features" in data:
|
||||
features = dict(pc.features or {})
|
||||
for key, val in data["features"].items():
|
||||
if isinstance(val, bool):
|
||||
features[key] = val
|
||||
elif val in ("true", "1", "on"):
|
||||
features[key] = True
|
||||
elif val in ("false", "0", "off", None):
|
||||
features[key] = False
|
||||
pc.features = features
|
||||
flag_modified(pc, "features")
|
||||
|
||||
if "sumup_merchant_code" in data:
|
||||
pc.sumup_merchant_code = data["sumup_merchant_code"] or None
|
||||
if "sumup_checkout_prefix" in data:
|
||||
pc.sumup_checkout_prefix = data["sumup_checkout_prefix"] or None
|
||||
if "sumup_api_key" in data:
|
||||
pc.sumup_api_key = data["sumup_api_key"] or None
|
||||
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
"sumup_configured": bool(pc.sumup_api_key),
|
||||
}
|
||||
|
||||
_handlers["update-page-config"] = _update_page_config
|
||||
|
||||
# --- attach-child ---
|
||||
async def _attach_child():
|
||||
"""Create or revive a ContainerRelation."""
|
||||
from shared.services.relationships import attach_child
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
rel = await attach_child(
|
||||
g.s,
|
||||
parent_type=data["parent_type"],
|
||||
parent_id=data["parent_id"],
|
||||
child_type=data["child_type"],
|
||||
child_id=data["child_id"],
|
||||
label=data.get("label"),
|
||||
sort_order=data.get("sort_order"),
|
||||
)
|
||||
return {
|
||||
"id": rel.id,
|
||||
"parent_type": rel.parent_type,
|
||||
"parent_id": rel.parent_id,
|
||||
"child_type": rel.child_type,
|
||||
"child_id": rel.child_id,
|
||||
"sort_order": rel.sort_order,
|
||||
}
|
||||
|
||||
_handlers["attach-child"] = _attach_child
|
||||
|
||||
# --- detach-child ---
|
||||
async def _detach_child():
|
||||
"""Soft-delete a ContainerRelation."""
|
||||
from shared.services.relationships import detach_child
|
||||
|
||||
data = await request.get_json(force=True)
|
||||
deleted = await detach_child(
|
||||
g.s,
|
||||
parent_type=data["parent_type"],
|
||||
parent_id=data["parent_id"],
|
||||
child_type=data["child_type"],
|
||||
child_id=data["child_id"],
|
||||
)
|
||||
return {"deleted": deleted}
|
||||
|
||||
_handlers["detach-child"] = _detach_child
|
||||
_handlers["clear-cart-for-order"] = _clear_cart_for_order
|
||||
|
||||
return bp
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# bp/cart/global_routes.py — Global cart routes (webhook, return, add)
|
||||
# bp/cart/global_routes.py — Global cart routes (add, quantity, delete, checkout)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,7 +6,6 @@ from quart import Blueprint, g, request, render_template, redirect, url_for, mak
|
||||
from sqlalchemy import select
|
||||
|
||||
from shared.models.market import CartItem
|
||||
from shared.models.order import Order
|
||||
from shared.infrastructure.actions import call_action
|
||||
from .services import (
|
||||
current_cart_identity,
|
||||
@@ -16,20 +15,11 @@ from .services import (
|
||||
calendar_total,
|
||||
get_ticket_cart_entries,
|
||||
ticket_total,
|
||||
check_sumup_status,
|
||||
)
|
||||
from .services.checkout import (
|
||||
find_or_create_cart_item,
|
||||
create_order_from_cart,
|
||||
resolve_page_config,
|
||||
build_sumup_description,
|
||||
build_sumup_reference,
|
||||
build_webhook_url,
|
||||
validate_webhook_secret,
|
||||
get_order_with_details,
|
||||
)
|
||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||
from shared.browser.app.csrf import csrf_exempt
|
||||
|
||||
|
||||
def register(url_prefix: str) -> Blueprint:
|
||||
@@ -141,7 +131,7 @@ def register(url_prefix: str) -> Blueprint:
|
||||
|
||||
@bp.post("/checkout/")
|
||||
async def checkout():
|
||||
"""Legacy global checkout (for orphan items without page scope)."""
|
||||
"""Global checkout — delegates order creation to orders service."""
|
||||
cart = await get_cart(g.s)
|
||||
calendar_entries = await get_calendar_cart_entries(g.s)
|
||||
tickets = await get_ticket_cart_entries(g.s)
|
||||
@@ -168,151 +158,63 @@ def register(url_prefix: str) -> Blueprint:
|
||||
return await make_response(html, 400)
|
||||
|
||||
ident = current_cart_identity()
|
||||
order = await create_order_from_cart(
|
||||
g.s,
|
||||
cart,
|
||||
calendar_entries,
|
||||
ident.get("user_id"),
|
||||
ident.get("session_id"),
|
||||
product_total,
|
||||
calendar_amount,
|
||||
ticket_total=ticket_amount,
|
||||
)
|
||||
|
||||
# Serialize cart items for the orders service
|
||||
cart_items_data = []
|
||||
for ci in cart:
|
||||
cart_items_data.append({
|
||||
"product_id": ci.product_id,
|
||||
"product_title": ci.product_title,
|
||||
"product_slug": ci.product_slug,
|
||||
"product_image": ci.product_image,
|
||||
"product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None,
|
||||
"product_special_price": float(ci.product_special_price) if ci.product_special_price else None,
|
||||
"product_price_currency": ci.product_price_currency,
|
||||
"quantity": ci.quantity,
|
||||
})
|
||||
|
||||
# Serialize calendar entries and tickets
|
||||
cal_data = []
|
||||
for e in calendar_entries:
|
||||
cal_data.append({
|
||||
"id": e.id,
|
||||
"calendar_container_id": getattr(e, "calendar_container_id", None),
|
||||
})
|
||||
ticket_data = []
|
||||
for t in tickets:
|
||||
ticket_data.append({
|
||||
"id": t.id,
|
||||
"calendar_container_id": getattr(t, "calendar_container_id", None),
|
||||
})
|
||||
|
||||
page_post_id = None
|
||||
if page_config:
|
||||
order.page_config_id = page_config.id
|
||||
page_post_id = getattr(page_config, "container_id", None)
|
||||
|
||||
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
||||
description = build_sumup_description(cart, order.id, ticket_count=len(tickets))
|
||||
result = await call_action("orders", "create-order", payload={
|
||||
"cart_items": cart_items_data,
|
||||
"calendar_entries": cal_data,
|
||||
"tickets": ticket_data,
|
||||
"user_id": ident.get("user_id"),
|
||||
"session_id": ident.get("session_id"),
|
||||
"product_total": float(product_total),
|
||||
"calendar_total": float(calendar_amount),
|
||||
"ticket_total": float(ticket_amount),
|
||||
"page_post_id": page_post_id,
|
||||
})
|
||||
|
||||
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
||||
webhook_url = build_webhook_url(webhook_base_url)
|
||||
|
||||
checkout_data = await sumup_create_checkout(
|
||||
order,
|
||||
redirect_url=redirect_url,
|
||||
webhook_url=webhook_url,
|
||||
description=description,
|
||||
page_config=page_config,
|
||||
)
|
||||
order.sumup_checkout_id = checkout_data.get("id")
|
||||
order.sumup_status = checkout_data.get("status")
|
||||
order.description = checkout_data.get("description")
|
||||
|
||||
hosted_cfg = checkout_data.get("hosted_checkout") or {}
|
||||
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
|
||||
order.sumup_hosted_url = hosted_url
|
||||
|
||||
await g.s.flush()
|
||||
# Update redirect/webhook URLs with real order_id
|
||||
order_id = result["order_id"]
|
||||
hosted_url = result.get("sumup_hosted_url")
|
||||
|
||||
if not hosted_url:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=order,
|
||||
order=None,
|
||||
error="No hosted checkout URL returned from SumUp.",
|
||||
)
|
||||
return await make_response(html, 500)
|
||||
|
||||
return redirect(hosted_url)
|
||||
|
||||
@csrf_exempt
|
||||
@bp.post("/checkout/webhook/<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
|
||||
|
||||
@@ -5,8 +5,7 @@ from __future__ import annotations
|
||||
from quart import Blueprint, g, render_template, redirect, make_response, url_for
|
||||
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.browser.app.payments.sumup import create_checkout as sumup_create_checkout
|
||||
from shared.config import config
|
||||
from shared.infrastructure.actions import call_action
|
||||
from .services import (
|
||||
total,
|
||||
calendar_total,
|
||||
@@ -14,12 +13,6 @@ from .services import (
|
||||
)
|
||||
from .services.page_cart import get_cart_for_page, get_calendar_entries_for_page, get_tickets_for_page
|
||||
from .services.ticket_groups import group_tickets
|
||||
from .services.checkout import (
|
||||
create_order_from_cart,
|
||||
build_sumup_description,
|
||||
build_sumup_reference,
|
||||
build_webhook_url,
|
||||
)
|
||||
from .services import current_cart_identity
|
||||
|
||||
|
||||
@@ -56,7 +49,6 @@ def register(url_prefix: str) -> Blueprint:
|
||||
@bp.post("/checkout/")
|
||||
async def page_checkout():
|
||||
post = g.page_post
|
||||
page_config = getattr(g, "page_config", None)
|
||||
|
||||
cart = await get_cart_for_page(g.s, post.id)
|
||||
cal_entries = await get_calendar_entries_for_page(g.s, post.id)
|
||||
@@ -65,61 +57,51 @@ def register(url_prefix: str) -> Blueprint:
|
||||
if not cart and not cal_entries and not page_tickets:
|
||||
return redirect(url_for("page_cart.page_view"))
|
||||
|
||||
product_total = total(cart) or 0
|
||||
product_total_val = total(cart) or 0
|
||||
calendar_amount = calendar_total(cal_entries) or 0
|
||||
ticket_amount = ticket_total(page_tickets) or 0
|
||||
cart_total = product_total + calendar_amount + ticket_amount
|
||||
cart_total = product_total_val + calendar_amount + ticket_amount
|
||||
|
||||
if cart_total <= 0:
|
||||
return redirect(url_for("page_cart.page_view"))
|
||||
|
||||
# Create order scoped to this page
|
||||
ident = current_cart_identity()
|
||||
order = await create_order_from_cart(
|
||||
g.s,
|
||||
cart,
|
||||
cal_entries,
|
||||
ident.get("user_id"),
|
||||
ident.get("session_id"),
|
||||
product_total,
|
||||
calendar_amount,
|
||||
ticket_total=ticket_amount,
|
||||
page_post_id=post.id,
|
||||
)
|
||||
|
||||
# Set page_config on order
|
||||
if page_config:
|
||||
order.page_config_id = page_config.id
|
||||
# Serialize cart items for the orders service
|
||||
cart_items_data = []
|
||||
for ci in cart:
|
||||
cart_items_data.append({
|
||||
"product_id": ci.product_id,
|
||||
"product_title": ci.product_title,
|
||||
"product_slug": ci.product_slug,
|
||||
"product_image": ci.product_image,
|
||||
"product_regular_price": float(ci.product_regular_price) if ci.product_regular_price else None,
|
||||
"product_special_price": float(ci.product_special_price) if ci.product_special_price else None,
|
||||
"product_price_currency": ci.product_price_currency,
|
||||
"quantity": ci.quantity,
|
||||
})
|
||||
|
||||
# Build SumUp checkout details — webhook/return use global routes
|
||||
redirect_url = url_for("cart_global.checkout_return", order_id=order.id, _external=True)
|
||||
order.sumup_reference = build_sumup_reference(order.id, page_config=page_config)
|
||||
description = build_sumup_description(cart, order.id, ticket_count=len(page_tickets))
|
||||
cal_data = [{"id": e.id, "calendar_container_id": getattr(e, "calendar_container_id", None)} for e in cal_entries]
|
||||
ticket_data = [{"id": t.id, "calendar_container_id": getattr(t, "calendar_container_id", None)} for t in page_tickets]
|
||||
|
||||
webhook_base_url = url_for("cart_global.checkout_webhook", order_id=order.id, _external=True)
|
||||
webhook_url = build_webhook_url(webhook_base_url)
|
||||
result = await call_action("orders", "create-order", payload={
|
||||
"cart_items": cart_items_data,
|
||||
"calendar_entries": cal_data,
|
||||
"tickets": ticket_data,
|
||||
"user_id": ident.get("user_id"),
|
||||
"session_id": ident.get("session_id"),
|
||||
"product_total": float(product_total_val),
|
||||
"calendar_total": float(calendar_amount),
|
||||
"ticket_total": float(ticket_amount),
|
||||
"page_post_id": post.id,
|
||||
})
|
||||
|
||||
checkout_data = await sumup_create_checkout(
|
||||
order,
|
||||
redirect_url=redirect_url,
|
||||
webhook_url=webhook_url,
|
||||
description=description,
|
||||
page_config=page_config,
|
||||
)
|
||||
order.sumup_checkout_id = checkout_data.get("id")
|
||||
order.sumup_status = checkout_data.get("status")
|
||||
order.description = checkout_data.get("description")
|
||||
|
||||
hosted_cfg = checkout_data.get("hosted_checkout") or {}
|
||||
hosted_url = hosted_cfg.get("hosted_checkout_url") or checkout_data.get("hosted_checkout_url")
|
||||
order.sumup_hosted_url = hosted_url
|
||||
|
||||
await g.s.flush()
|
||||
hosted_url = result.get("sumup_hosted_url")
|
||||
|
||||
if not hosted_url:
|
||||
html = await render_template(
|
||||
"_types/cart/checkout_error.html",
|
||||
order=order,
|
||||
order=None,
|
||||
error="No hosted checkout URL returned from SumUp.",
|
||||
)
|
||||
return await make_response(html, 500)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
from .get_cart import get_cart
|
||||
from .identity import current_cart_identity
|
||||
from .total import total
|
||||
from .clear_cart_for_order import clear_cart_for_order
|
||||
from .calendar_cart import get_calendar_cart_entries, calendar_total, get_ticket_cart_entries, ticket_total
|
||||
from .check_sumup_status import check_sumup_status
|
||||
from .page_cart import (
|
||||
get_cart_for_page,
|
||||
get_calendar_entries_for_page,
|
||||
|
||||
@@ -178,7 +178,7 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
|
||||
p = dto_from_dict(PostDTO, raw_p)
|
||||
posts_by_id[p.id] = p
|
||||
|
||||
raw_pcs = await fetch_data("cart", "page-configs-batch",
|
||||
raw_pcs = await fetch_data("blog", "page-configs-batch",
|
||||
params={"container_type": "page",
|
||||
"ids": ",".join(str(i) for i in post_ids)},
|
||||
required=False) or []
|
||||
|
||||
@@ -45,41 +45,6 @@ def register() -> Blueprint:
|
||||
|
||||
_handlers["cart-summary"] = _cart_summary
|
||||
|
||||
# --- page-config-ensure ---
|
||||
async def _page_config_ensure():
|
||||
"""Get or create a PageConfig for a container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
container_type = request.args.get("container_type", "page")
|
||||
container_id = request.args.get("container_id", type=int)
|
||||
if container_id is None:
|
||||
return {"error": "container_id required"}, 400
|
||||
|
||||
row = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == container_type,
|
||||
PageConfig.container_id == container_id,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
row = PageConfig(
|
||||
container_type=container_type,
|
||||
container_id=container_id,
|
||||
features={},
|
||||
)
|
||||
g.s.add(row)
|
||||
await g.s.flush()
|
||||
|
||||
return {
|
||||
"id": row.id,
|
||||
"container_type": row.container_type,
|
||||
"container_id": row.container_id,
|
||||
}
|
||||
|
||||
_handlers["page-config-ensure"] = _page_config_ensure
|
||||
|
||||
# --- cart-items (product slugs + quantities for template rendering) ---
|
||||
async def _cart_items():
|
||||
from sqlalchemy import select
|
||||
@@ -111,103 +76,4 @@ def register() -> Blueprint:
|
||||
|
||||
_handlers["cart-items"] = _cart_items
|
||||
|
||||
# --- page-config ---
|
||||
async def _page_config():
|
||||
"""Return a single PageConfig by container_type + container_id."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
cid = request.args.get("container_id", type=int)
|
||||
if cid is None:
|
||||
return None
|
||||
pc = (await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id == cid,
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config"] = _page_config
|
||||
|
||||
# --- page-config-by-id ---
|
||||
async def _page_config_by_id():
|
||||
"""Return a single PageConfig by its primary key."""
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
pc_id = request.args.get("id", type=int)
|
||||
if pc_id is None:
|
||||
return None
|
||||
pc = await g.s.get(PageConfig, pc_id)
|
||||
if not pc:
|
||||
return None
|
||||
return _page_config_dict(pc)
|
||||
|
||||
_handlers["page-config-by-id"] = _page_config_by_id
|
||||
|
||||
# --- page-configs-batch ---
|
||||
async def _page_configs_batch():
|
||||
"""Return PageConfigs for multiple container_ids (comma-separated)."""
|
||||
from sqlalchemy import select
|
||||
from shared.models.page_config import PageConfig
|
||||
|
||||
ct = request.args.get("container_type", "page")
|
||||
ids_raw = request.args.get("ids", "")
|
||||
if not ids_raw:
|
||||
return []
|
||||
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
|
||||
if not ids:
|
||||
return []
|
||||
result = await g.s.execute(
|
||||
select(PageConfig).where(
|
||||
PageConfig.container_type == ct,
|
||||
PageConfig.container_id.in_(ids),
|
||||
)
|
||||
)
|
||||
return [_page_config_dict(pc) for pc in result.scalars().all()]
|
||||
|
||||
_handlers["page-configs-batch"] = _page_configs_batch
|
||||
|
||||
# --- get-children ---
|
||||
async def _get_children():
|
||||
"""Return ContainerRelation children for a parent."""
|
||||
from shared.services.relationships import get_children
|
||||
|
||||
parent_type = request.args.get("parent_type", "")
|
||||
parent_id = request.args.get("parent_id", type=int)
|
||||
child_type = request.args.get("child_type")
|
||||
if not parent_type or parent_id is None:
|
||||
return []
|
||||
rels = await get_children(g.s, parent_type, parent_id, child_type)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"parent_type": r.parent_type,
|
||||
"parent_id": r.parent_id,
|
||||
"child_type": r.child_type,
|
||||
"child_id": r.child_id,
|
||||
"sort_order": r.sort_order,
|
||||
"label": r.label,
|
||||
}
|
||||
for r in rels
|
||||
]
|
||||
|
||||
_handlers["get-children"] = _get_children
|
||||
|
||||
return bp
|
||||
|
||||
|
||||
def _page_config_dict(pc) -> dict:
|
||||
"""Serialize PageConfig to a JSON-safe dict."""
|
||||
return {
|
||||
"id": pc.id,
|
||||
"container_type": pc.container_type,
|
||||
"container_id": pc.container_id,
|
||||
"features": pc.features or {},
|
||||
"sumup_merchant_code": pc.sumup_merchant_code,
|
||||
"sumup_api_key": pc.sumup_api_key,
|
||||
"sumup_checkout_prefix": pc.sumup_checkout_prefix,
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
from .order import Order, OrderItem
|
||||
from .page_config import PageConfig
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
REGISTRY="registry.rose-ash.com:5000"
|
||||
APPS="blog market cart events federation account"
|
||||
APPS="blog market cart events federation account relations likes orders"
|
||||
|
||||
usage() {
|
||||
echo "Usage: deploy.sh [app ...]"
|
||||
|
||||
@@ -25,6 +25,12 @@ x-sibling-models: &sibling-models
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
services:
|
||||
blog:
|
||||
@@ -55,6 +61,12 @@ services:
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
market:
|
||||
ports:
|
||||
@@ -85,6 +97,12 @@ services:
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
cart:
|
||||
ports:
|
||||
@@ -114,6 +132,12 @@ services:
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
events:
|
||||
ports:
|
||||
@@ -143,6 +167,12 @@ services:
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
federation:
|
||||
ports:
|
||||
@@ -172,6 +202,12 @@ services:
|
||||
- ./events/models:/app/events/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
account:
|
||||
ports:
|
||||
@@ -201,6 +237,103 @@ services:
|
||||
- ./events/models:/app/events/models:ro
|
||||
- ./federation/__init__.py:/app/federation/__init__.py:ro
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./relations/__init__.py:/app/relations/__init__.py:ro
|
||||
- ./relations/models:/app/relations/models:ro
|
||||
- ./likes/__init__.py:/app/likes/__init__.py:ro
|
||||
- ./likes/models:/app/likes/models:ro
|
||||
- ./orders/__init__.py:/app/orders/__init__.py:ro
|
||||
- ./orders/models:/app/orders/models:ro
|
||||
|
||||
relations:
|
||||
ports:
|
||||
- "8008:8000"
|
||||
environment:
|
||||
<<: *dev-env
|
||||
volumes:
|
||||
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
|
||||
- ./shared:/app/shared
|
||||
- ./relations/alembic.ini:/app/relations/alembic.ini:ro
|
||||
- ./relations/alembic:/app/relations/alembic:ro
|
||||
- ./relations/app.py:/app/app.py
|
||||
- ./relations/bp:/app/bp
|
||||
- ./relations/services:/app/services
|
||||
- ./relations/models:/app/models
|
||||
- ./relations/path_setup.py:/app/path_setup.py
|
||||
- ./relations/entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||
# sibling models
|
||||
- ./blog/__init__.py:/app/blog/__init__.py:ro
|
||||
- ./blog/models:/app/blog/models:ro
|
||||
- ./market/__init__.py:/app/market/__init__.py:ro
|
||||
- ./market/models:/app/market/models:ro
|
||||
- ./cart/__init__.py:/app/cart/__init__.py:ro
|
||||
- ./cart/models:/app/cart/models:ro
|
||||
- ./events/__init__.py:/app/events/__init__.py:ro
|
||||
- ./events/models:/app/events/models:ro
|
||||
- ./federation/__init__.py:/app/federation/__init__.py:ro
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
likes:
|
||||
ports:
|
||||
- "8009:8000"
|
||||
environment:
|
||||
<<: *dev-env
|
||||
volumes:
|
||||
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
|
||||
- ./shared:/app/shared
|
||||
- ./likes/alembic.ini:/app/likes/alembic.ini:ro
|
||||
- ./likes/alembic:/app/likes/alembic:ro
|
||||
- ./likes/app.py:/app/app.py
|
||||
- ./likes/bp:/app/bp
|
||||
- ./likes/services:/app/services
|
||||
- ./likes/models:/app/models
|
||||
- ./likes/path_setup.py:/app/path_setup.py
|
||||
- ./likes/entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||
# sibling models
|
||||
- ./blog/__init__.py:/app/blog/__init__.py:ro
|
||||
- ./blog/models:/app/blog/models:ro
|
||||
- ./market/__init__.py:/app/market/__init__.py:ro
|
||||
- ./market/models:/app/market/models:ro
|
||||
- ./cart/__init__.py:/app/cart/__init__.py:ro
|
||||
- ./cart/models:/app/cart/models:ro
|
||||
- ./events/__init__.py:/app/events/__init__.py:ro
|
||||
- ./events/models:/app/events/models:ro
|
||||
- ./federation/__init__.py:/app/federation/__init__.py:ro
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
orders:
|
||||
ports:
|
||||
- "8010:8000"
|
||||
environment:
|
||||
<<: *dev-env
|
||||
volumes:
|
||||
- /root/rose-ash/_config/app-config.yaml:/app/config/app-config.yaml:ro
|
||||
- ./shared:/app/shared
|
||||
- ./orders/alembic.ini:/app/orders/alembic.ini:ro
|
||||
- ./orders/alembic:/app/orders/alembic:ro
|
||||
- ./orders/app.py:/app/app.py
|
||||
- ./orders/bp:/app/bp
|
||||
- ./orders/services:/app/services
|
||||
- ./orders/templates:/app/templates
|
||||
- ./orders/models:/app/models
|
||||
- ./orders/path_setup.py:/app/path_setup.py
|
||||
- ./orders/entrypoint.sh:/usr/local/bin/entrypoint.sh
|
||||
# sibling models
|
||||
- ./blog/__init__.py:/app/blog/__init__.py:ro
|
||||
- ./blog/models:/app/blog/models:ro
|
||||
- ./market/__init__.py:/app/market/__init__.py:ro
|
||||
- ./market/models:/app/market/models:ro
|
||||
- ./cart/__init__.py:/app/cart/__init__.py:ro
|
||||
- ./cart/models:/app/cart/models:ro
|
||||
- ./events/__init__.py:/app/events/__init__.py:ro
|
||||
- ./events/models:/app/events/models:ro
|
||||
- ./federation/__init__.py:/app/federation/__init__.py:ro
|
||||
- ./federation/models:/app/federation/models:ro
|
||||
- ./account/__init__.py:/app/account/__init__.py:ro
|
||||
- ./account/models:/app/account/models:ro
|
||||
|
||||
networks:
|
||||
appnet:
|
||||
|
||||
@@ -32,6 +32,9 @@ x-app-env: &app-env
|
||||
APP_URL_EVENTS: https://events.rose-ash.com
|
||||
APP_URL_FEDERATION: https://federation.rose-ash.com
|
||||
APP_URL_ACCOUNT: https://account.rose-ash.com
|
||||
APP_URL_ORDERS: https://orders.rose-ash.com
|
||||
APP_URL_RELATIONS: http://relations:8000
|
||||
APP_URL_LIKES: http://likes:8000
|
||||
APP_URL_ARTDAG: https://celery-artdag.rose-ash.com
|
||||
APP_URL_ARTDAG_L2: https://artdag.rose-ash.com
|
||||
INTERNAL_URL_BLOG: http://blog:8000
|
||||
@@ -40,6 +43,9 @@ x-app-env: &app-env
|
||||
INTERNAL_URL_EVENTS: http://events:8000
|
||||
INTERNAL_URL_FEDERATION: http://federation:8000
|
||||
INTERNAL_URL_ACCOUNT: http://account:8000
|
||||
INTERNAL_URL_ORDERS: http://orders:8000
|
||||
INTERNAL_URL_RELATIONS: http://relations:8000
|
||||
INTERNAL_URL_LIKES: http://likes:8000
|
||||
INTERNAL_URL_ARTDAG: http://l1-server:8100
|
||||
AP_DOMAIN: federation.rose-ash.com
|
||||
AP_DOMAIN_BLOG: blog.rose-ash.com
|
||||
@@ -147,6 +153,54 @@ services:
|
||||
RUN_MIGRATIONS: "true"
|
||||
WORKERS: "1"
|
||||
|
||||
relations:
|
||||
<<: *app-common
|
||||
image: registry.rose-ash.com:5000/relations:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: relations/Dockerfile
|
||||
environment:
|
||||
<<: *app-env
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_relations
|
||||
ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_relations
|
||||
REDIS_URL: redis://redis:6379/6
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: "5432"
|
||||
RUN_MIGRATIONS: "true"
|
||||
WORKERS: "1"
|
||||
|
||||
likes:
|
||||
<<: *app-common
|
||||
image: registry.rose-ash.com:5000/likes:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: likes/Dockerfile
|
||||
environment:
|
||||
<<: *app-env
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_likes
|
||||
ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_likes
|
||||
REDIS_URL: redis://redis:6379/7
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: "5432"
|
||||
RUN_MIGRATIONS: "true"
|
||||
WORKERS: "1"
|
||||
|
||||
orders:
|
||||
<<: *app-common
|
||||
image: registry.rose-ash.com:5000/orders:latest
|
||||
build:
|
||||
context: .
|
||||
dockerfile: orders/Dockerfile
|
||||
environment:
|
||||
<<: *app-env
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:change-me@pgbouncer:5432/db_orders
|
||||
ALEMBIC_DATABASE_URL: postgresql+psycopg://postgres:change-me@db:5432/db_orders
|
||||
REDIS_URL: redis://redis:6379/8
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: "5432"
|
||||
RUN_MIGRATIONS: "true"
|
||||
WORKERS: "1"
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
environment:
|
||||
|
||||
@@ -37,6 +37,12 @@ COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY events/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -71,7 +71,7 @@ async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) ->
|
||||
|
||||
cal.deleted_at = utcnow()
|
||||
await sess.flush()
|
||||
await call_action("cart", "detach-child", payload={
|
||||
await call_action("relations", "detach-child", payload={
|
||||
"parent_type": "page", "parent_id": cal.container_id,
|
||||
"child_type": "calendar", "child_id": cal.id,
|
||||
})
|
||||
@@ -108,7 +108,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
||||
if existing.deleted_at is not None:
|
||||
existing.deleted_at = None # revive
|
||||
await sess.flush()
|
||||
await call_action("cart", "attach-child", payload={
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "calendar", "child_id": existing.id,
|
||||
})
|
||||
@@ -118,7 +118,7 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
|
||||
cal = Calendar(container_type="page", container_id=post_id, name=name, slug=slug)
|
||||
sess.add(cal)
|
||||
await sess.flush()
|
||||
await call_action("cart", "attach-child", payload={
|
||||
await call_action("relations", "attach-child", payload={
|
||||
"parent_type": "page", "parent_id": post_id,
|
||||
"child_type": "calendar", "child_id": cal.id,
|
||||
})
|
||||
|
||||
@@ -38,6 +38,12 @@ COPY events/__init__.py ./events/__init__.py
|
||||
COPY events/models/ ./events/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY federation/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
53
likes/Dockerfile
Normal file
53
likes/Dockerfile
Normal 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
0
likes/__init__.py
Normal file
35
likes/alembic.ini
Normal file
35
likes/alembic.ini
Normal 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
12
likes/alembic/env.py
Normal 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)
|
||||
47
likes/alembic/versions/0001_initial.py
Normal file
47
likes/alembic/versions/0001_initial.py
Normal 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
22
likes/app.py
Normal 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
2
likes/bp/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .data.routes import register as register_data
|
||||
from .actions.routes import register as register_actions
|
||||
0
likes/bp/actions/__init__.py
Normal file
0
likes/bp/actions/__init__.py
Normal file
81
likes/bp/actions/routes.py
Normal file
81
likes/bp/actions/routes.py
Normal 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
|
||||
0
likes/bp/data/__init__.py
Normal file
0
likes/bp/data/__init__.py
Normal file
109
likes/bp/data/routes.py
Normal file
109
likes/bp/data/routes.py
Normal 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
61
likes/entrypoint.sh
Normal 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
1
likes/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .like import Like
|
||||
36
likes/models/like.py
Normal file
36
likes/models/like.py
Normal 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
9
likes/path_setup.py
Normal 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)
|
||||
6
likes/services/__init__.py
Normal file
6
likes/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Likes app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the likes app."""
|
||||
@@ -38,6 +38,12 @@ COPY federation/__init__.py ./federation/__init__.py
|
||||
COPY federation/models/ ./federation/models/
|
||||
COPY account/__init__.py ./account/__init__.py
|
||||
COPY account/models/ ./account/models/
|
||||
COPY relations/__init__.py ./relations/__init__.py
|
||||
COPY relations/models/ ./relations/models/
|
||||
COPY likes/__init__.py ./likes/__init__.py
|
||||
COPY likes/models/ ./likes/models/
|
||||
COPY orders/__init__.py ./orders/__init__.py
|
||||
COPY orders/models/ ./orders/models/
|
||||
|
||||
# ---------- Runtime setup ----------
|
||||
COPY market/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
|
||||
@@ -9,7 +9,7 @@ MODELS = [
|
||||
TABLES = frozenset({
|
||||
"products", "product_images", "product_sections", "product_labels",
|
||||
"product_stickers", "product_attributes", "product_nutrition",
|
||||
"product_allergens", "product_likes",
|
||||
"product_allergens",
|
||||
"market_places", "nav_tops", "nav_subs",
|
||||
"listings", "listing_items",
|
||||
"link_errors", "link_externals", "subcategory_redirects", "product_logs",
|
||||
|
||||
@@ -12,9 +12,9 @@ from models.market import (
|
||||
Listing, ListingItem,
|
||||
NavTop, NavSub,
|
||||
ProductSticker, ProductLabel,
|
||||
ProductAttribute, ProductNutrition, ProductAllergen, ProductLike
|
||||
|
||||
ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
)
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from sqlalchemy import func, case
|
||||
|
||||
|
||||
@@ -72,26 +72,8 @@ async def db_nav(session, market_id=None) -> Dict:
|
||||
|
||||
async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
|
||||
|
||||
liked_product_ids_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
is_liked_case = case(
|
||||
(and_(
|
||||
(Product.slug.in_(liked_product_ids_subq)),
|
||||
Product.deleted_at.is_(None)
|
||||
), True),
|
||||
else_=False
|
||||
).label("is_liked")
|
||||
|
||||
q = (
|
||||
select(Product, is_liked_case)
|
||||
select(Product)
|
||||
.where(Product.slug == slug, Product.deleted_at.is_(None))
|
||||
.options(
|
||||
selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))),
|
||||
@@ -105,11 +87,17 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
|
||||
)
|
||||
result = await session.execute(q)
|
||||
|
||||
row = result.first() if result is not None else None
|
||||
p, is_liked = row if row else (None, None)
|
||||
p = result.scalars().first()
|
||||
if not p:
|
||||
return None
|
||||
|
||||
is_liked = False
|
||||
if user_id:
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": slug,
|
||||
}, required=False)
|
||||
is_liked = (liked_data or {}).get("liked", False)
|
||||
|
||||
gallery = [
|
||||
img.url
|
||||
for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0))
|
||||
@@ -170,26 +158,9 @@ async def db_product_full(session, slug: str, user_id=0) -> Optional[dict]:
|
||||
|
||||
|
||||
async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]:
|
||||
liked_product_ids_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
is_liked_case = case(
|
||||
(
|
||||
(Product.slug.in_(liked_product_ids_subq)),
|
||||
True
|
||||
),
|
||||
else_=False
|
||||
).label("is_liked")
|
||||
|
||||
q = (
|
||||
select(Product, is_liked_case)
|
||||
select(Product)
|
||||
.where(Product.id == id)
|
||||
.options(
|
||||
selectinload(Product.images.and_(ProductImage.deleted_at.is_(None))),
|
||||
@@ -203,11 +174,17 @@ async def db_product_full_id(session, id:int, user_id=0) -> Optional[dict]:
|
||||
)
|
||||
result = await session.execute(q)
|
||||
|
||||
row = result.first() if result is not None else None
|
||||
p, is_liked = row if row else (None, None)
|
||||
p = result.scalars().first()
|
||||
if not p:
|
||||
return None
|
||||
|
||||
is_liked = False
|
||||
if user_id:
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": p.slug,
|
||||
}, required=False)
|
||||
is_liked = (liked_data or {}).get("liked", False)
|
||||
|
||||
gallery = [
|
||||
img.url
|
||||
for img in sorted(p.images, key=lambda i: (i.kind or "gallery", i.position or 0))
|
||||
@@ -367,40 +344,25 @@ async def db_products_nocounts(
|
||||
)
|
||||
if search_q:
|
||||
filter_conditions.append(func.lower(Product.description_short).contains(search_q))
|
||||
# Fetch liked slugs from likes service (once)
|
||||
liked_slugs_set: set[str] = set()
|
||||
if user_id and (liked or True):
|
||||
liked_slugs_list = await fetch_data("likes", "liked-slugs", params={
|
||||
"user_id": user_id, "target_type": "product",
|
||||
}, required=False) or []
|
||||
liked_slugs_set = set(liked_slugs_list)
|
||||
|
||||
if liked:
|
||||
liked_subq = liked_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
filter_conditions.append(Product.slug.in_(liked_subq))
|
||||
if not liked_slugs_set:
|
||||
return {"total_pages": 1, "items": []}
|
||||
filter_conditions.append(Product.slug.in_(liked_slugs_set))
|
||||
|
||||
filtered_count_query = select(func.count(Product.id)).where(Product.id.in_(base_ids), *filter_conditions)
|
||||
total_filtered = (await session.execute(filtered_count_query)).scalars().one()
|
||||
total_pages = max(1, (total_filtered + page_size - 1) // page_size)
|
||||
page = max(1, page)
|
||||
|
||||
|
||||
liked_product_slugs_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(
|
||||
and_(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
)
|
||||
is_liked_case = case(
|
||||
(Product.slug.in_(liked_product_slugs_subq), True),
|
||||
else_=False
|
||||
).label("is_liked")
|
||||
|
||||
q_filtered = select(Product, is_liked_case).where(Product.id.in_(base_ids), *filter_conditions).options(
|
||||
q_filtered = select(Product).where(Product.id.in_(base_ids), *filter_conditions).options(
|
||||
selectinload(Product.images),
|
||||
selectinload(Product.sections),
|
||||
selectinload(Product.labels),
|
||||
@@ -434,10 +396,11 @@ async def db_products_nocounts(
|
||||
|
||||
offset_val = (page - 1) * page_size
|
||||
q_filtered = q_filtered.offset(offset_val).limit(page_size)
|
||||
products_page = (await session.execute(q_filtered)).all()
|
||||
products_page = (await session.execute(q_filtered)).scalars().all()
|
||||
|
||||
items: List[Dict] = []
|
||||
for p, is_liked in products_page:
|
||||
for p in products_page:
|
||||
is_liked = p.slug in liked_slugs_set
|
||||
gallery_imgs = sorted((img for img in p.images), key=lambda i: (i.kind or "gallery", i.position or 0))
|
||||
gallery = [img.url for img in gallery_imgs if (img.kind or "gallery") == "gallery"]
|
||||
embedded = [img.url for img in sorted(p.images, key=lambda i: i.position or 0) if (img.kind or "") == "embedded"]
|
||||
@@ -580,30 +543,14 @@ async def db_products_counts(
|
||||
labels_list: List[Dict] = []
|
||||
liked_count = 0
|
||||
search_count = 0
|
||||
liked_product_slugs_subq = (
|
||||
select(ProductLike.product_slug)
|
||||
.where(ProductLike.user_id == user_id, ProductLike.deleted_at.is_(None))
|
||||
)
|
||||
liked_count = await session.scalar(
|
||||
select(func.count(Product.id))
|
||||
.where(
|
||||
Product.id.in_(base_ids),
|
||||
Product.slug.in_(liked_product_slugs_subq),
|
||||
Product.deleted_at.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
liked_count = (await session.execute(
|
||||
select(func.count())
|
||||
.select_from(ProductLike)
|
||||
.where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug.in_(
|
||||
select(Product.slug).where(Product.id.in_(base_ids))
|
||||
),
|
||||
ProductLike.deleted_at.is_(None)
|
||||
)
|
||||
)).scalar_one() if user_id else 0
|
||||
if user_id:
|
||||
liked_slugs_list = await fetch_data("likes", "liked-slugs", params={
|
||||
"user_id": user_id, "target_type": "product",
|
||||
}, required=False) or []
|
||||
liked_slugs_in_base = set(liked_slugs_list) & set(base_products_slugs)
|
||||
liked_count = len(liked_slugs_in_base)
|
||||
else:
|
||||
liked_count = 0
|
||||
|
||||
# Brand counts
|
||||
brand_count_rows = await session.execute(
|
||||
|
||||
@@ -13,8 +13,7 @@ from .blacklist.product_details import is_blacklisted_heading
|
||||
from shared.utils import host_url
|
||||
|
||||
|
||||
from sqlalchemy import select
|
||||
from models import ProductLike
|
||||
from shared.infrastructure.data_client import fetch_data
|
||||
from ...market.filters.qs import decode
|
||||
|
||||
|
||||
@@ -171,15 +170,9 @@ async def _is_liked(user_id: int | None, slug: str) -> bool:
|
||||
"""
|
||||
if not user_id:
|
||||
return False
|
||||
# because ProductLike has composite PK (user_id, product_slug),
|
||||
# we can fetch it by primary key dict:
|
||||
row = await g.s.execute(
|
||||
select(ProductLike).where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug == slug,
|
||||
)
|
||||
)
|
||||
row.scalar_one_or_none()
|
||||
return row is not None
|
||||
liked_data = await fetch_data("likes", "is-liked", params={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": slug,
|
||||
}, required=False)
|
||||
return (liked_data or {}).get("liked", False)
|
||||
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ from quart import (
|
||||
)
|
||||
from sqlalchemy import select, func, update
|
||||
|
||||
from models.market import Product, ProductLike
|
||||
from models.market import Product
|
||||
from ..browse.services.slugs import canonical_html_slug
|
||||
from ..browse.services.blacklist.product import is_product_blocked
|
||||
from ..browse.services import db_backend as cb
|
||||
@@ -18,7 +18,8 @@ from ..browse.services import _massage_product
|
||||
from shared.utils import host_url
|
||||
from shared.browser.app.redis_cacher import cache_page, clear_cache
|
||||
from ..cart.services import total
|
||||
from .services.product_operations import toggle_product_like, massage_full_product
|
||||
from shared.infrastructure.actions import call_action
|
||||
from .services.product_operations import massage_full_product
|
||||
|
||||
|
||||
def register():
|
||||
@@ -132,11 +133,10 @@ def register():
|
||||
|
||||
user_id = g.user.id
|
||||
|
||||
liked, error = await toggle_product_like(g.s, user_id, product_slug)
|
||||
|
||||
if error:
|
||||
resp = make_response(error, 404)
|
||||
return resp
|
||||
result = await call_action("likes", "toggle", payload={
|
||||
"user_id": user_id, "target_type": "product", "target_slug": product_slug,
|
||||
})
|
||||
liked = result["liked"]
|
||||
|
||||
html = await render_template(
|
||||
"_types/browse/like/button.html",
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from models.market import Product, ProductLike
|
||||
from models.market import Product
|
||||
|
||||
|
||||
def massage_full_product(product: Product) -> dict:
|
||||
@@ -44,52 +39,3 @@ def massage_full_product(product: Product) -> dict:
|
||||
return _massage_product(d)
|
||||
|
||||
|
||||
async def toggle_product_like(
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
product_slug: str,
|
||||
) -> tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Toggle a product like for a given user using soft deletes.
|
||||
Returns (liked_state, error_message).
|
||||
- If error_message is not None, an error occurred.
|
||||
- liked_state indicates whether product is now liked (True) or unliked (False).
|
||||
"""
|
||||
from sqlalchemy import func, update
|
||||
|
||||
# Get product_id from slug
|
||||
product_id = await session.scalar(
|
||||
select(Product.id).where(Product.slug == product_slug, Product.deleted_at.is_(None))
|
||||
)
|
||||
if not product_id:
|
||||
return False, "Product not found"
|
||||
|
||||
# Check if like exists (not deleted)
|
||||
existing = await session.scalar(
|
||||
select(ProductLike).where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug == product_slug,
|
||||
ProductLike.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Unlike: soft delete the like
|
||||
await session.execute(
|
||||
update(ProductLike)
|
||||
.where(
|
||||
ProductLike.user_id == user_id,
|
||||
ProductLike.product_slug == product_slug,
|
||||
ProductLike.deleted_at.is_(None),
|
||||
)
|
||||
.values(deleted_at=func.now())
|
||||
)
|
||||
return False, None
|
||||
else:
|
||||
# Like: add a new like
|
||||
new_like = ProductLike(
|
||||
user_id=user_id,
|
||||
product_slug=product_slug,
|
||||
)
|
||||
session.add(new_like)
|
||||
return True, None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from .market import (
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
Product, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from shared.models.market import ( # noqa: F401
|
||||
Product, ProductLike, ProductImage, ProductSection,
|
||||
Product, ProductImage, ProductSection,
|
||||
NavTop, NavSub, Listing, ListingItem,
|
||||
LinkError, LinkExternal, SubcategoryRedirect, ProductLog,
|
||||
ProductLabel, ProductSticker, ProductAttribute, ProductNutrition, ProductAllergen,
|
||||
|
||||
56
orders/Dockerfile
Normal file
56
orders/Dockerfile
Normal 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
0
orders/__init__.py
Normal file
35
orders/alembic.ini
Normal file
35
orders/alembic.ini
Normal 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
12
orders/alembic/env.py
Normal 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)
|
||||
67
orders/alembic/versions/0001_initial.py
Normal file
67
orders/alembic/versions/0001_initial.py
Normal 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
129
orders/app.py
Normal 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
6
orders/bp/__init__.py
Normal 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
|
||||
0
orders/bp/actions/__init__.py
Normal file
0
orders/bp/actions/__init__.py
Normal file
108
orders/bp/actions/routes.py
Normal file
108
orders/bp/actions/routes.py
Normal 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
|
||||
0
orders/bp/checkout/__init__.py
Normal file
0
orders/bp/checkout/__init__.py
Normal file
99
orders/bp/checkout/routes.py
Normal file
99
orders/bp/checkout/routes.py
Normal 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
|
||||
0
orders/bp/data/__init__.py
Normal file
0
orders/bp/data/__init__.py
Normal file
30
orders/bp/data/routes.py
Normal file
30
orders/bp/data/routes.py
Normal 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
|
||||
0
orders/bp/fragments/__init__.py
Normal file
0
orders/bp/fragments/__init__.py
Normal file
42
orders/bp/fragments/routes.py
Normal file
42
orders/bp/fragments/routes.py
Normal 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
|
||||
0
orders/bp/order/__init__.py
Normal file
0
orders/bp/order/__init__.py
Normal file
0
orders/bp/order/filters/__init__.py
Normal file
0
orders/bp/order/filters/__init__.py
Normal file
74
orders/bp/order/filters/qs.py
Normal file
74
orders/bp/order/filters/qs.py
Normal 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
124
orders/bp/order/routes.py
Normal 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
|
||||
0
orders/bp/orders/__init__.py
Normal file
0
orders/bp/orders/__init__.py
Normal file
0
orders/bp/orders/filters/__init__.py
Normal file
0
orders/bp/orders/filters/__init__.py
Normal file
77
orders/bp/orders/filters/qs.py
Normal file
77
orders/bp/orders/filters/qs.py
Normal 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
138
orders/bp/orders/routes.py
Normal 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
61
orders/entrypoint.sh
Normal 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}
|
||||
1
orders/models/__init__.py
Normal file
1
orders/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from shared.models.order import Order, OrderItem
|
||||
9
orders/path_setup.py
Normal file
9
orders/path_setup.py
Normal 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)
|
||||
6
orders/services/__init__.py
Normal file
6
orders/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Orders app service registration."""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def register_domain_services() -> None:
|
||||
"""Register services for the orders app."""
|
||||
63
orders/services/check_sumup_status.py
Normal file
63
orders/services/check_sumup_status.py
Normal 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
147
orders/services/checkout.py
Normal 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()
|
||||
38
orders/templates/_types/cart/checkout_error.html
Normal file
38
orders/templates/_types/cart/checkout_error.html
Normal 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 %}
|
||||
68
orders/templates/_types/cart/checkout_return.html
Normal file
68
orders/templates/_types/cart/checkout_return.html
Normal 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 %}
|
||||
We’re 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 couldn’t 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>We’ll start processing your order shortly.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
43
orders/templates/_types/order/_calendar_items.html
Normal file
43
orders/templates/_types/order/_calendar_items.html
Normal 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 %}
|
||||
51
orders/templates/_types/order/_items.html
Normal file
51
orders/templates/_types/order/_items.html
Normal 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 %}
|
||||
7
orders/templates/_types/order/_main_panel.html
Normal file
7
orders/templates/_types/order/_main_panel.html
Normal 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>
|
||||
2
orders/templates/_types/order/_nav.html
Normal file
2
orders/templates/_types/order/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
30
orders/templates/_types/order/_oob_elements.html
Normal file
30
orders/templates/_types/order/_oob_elements.html
Normal 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 %}
|
||||
|
||||
|
||||
52
orders/templates/_types/order/_summary.html
Normal file
52
orders/templates/_types/order/_summary.html
Normal 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>
|
||||
|
||||
|
||||
49
orders/templates/_types/order/_ticket_items.html
Normal file
49
orders/templates/_types/order/_ticket_items.html
Normal 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 %}
|
||||
17
orders/templates/_types/order/header/_header.html
Normal file
17
orders/templates/_types/order/header/_header.html
Normal 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 %}
|
||||
68
orders/templates/_types/order/index.html
Normal file
68
orders/templates/_types/order/index.html
Normal 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 %} · 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 %}
|
||||
26
orders/templates/_types/orders/_main_panel.html
Normal file
26
orders/templates/_types/orders/_main_panel.html
Normal 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>
|
||||
2
orders/templates/_types/orders/_nav.html
Normal file
2
orders/templates/_types/orders/_nav.html
Normal file
@@ -0,0 +1,2 @@
|
||||
{% from 'macros/admin_nav.html' import placeholder_nav %}
|
||||
{{ placeholder_nav() }}
|
||||
38
orders/templates/_types/orders/_oob_elements.html
Normal file
38
orders/templates/_types/orders/_oob_elements.html
Normal 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
Reference in New Issue
Block a user