Decouple all cross-app service calls to HTTP endpoints

Replace every direct cross-app services.* call with HTTP-based
communication: call_action() for writes, fetch_data() for reads.
Each app now registers only its own domain service.

Infrastructure:
- shared/infrastructure/actions.py — POST client for /internal/actions/
- shared/infrastructure/data_client.py — GET client for /internal/data/
- shared/contracts/dtos.py — dto_to_dict/dto_from_dict serialization

Action endpoints (writes):
- events: 8 handlers (ticket adjust, claim/confirm, toggle, adopt)
- market: 2 handlers (create/soft-delete marketplace)
- cart: 1 handler (adopt cart for user)

Data endpoints (reads):
- blog: 4 (post-by-slug/id, posts-by-ids, search-posts)
- events: 10 (pending entries/tickets, entries/tickets for page/order,
  entry-ids, associated-entries, calendars, visible-entries-for-period)
- market: 1 (marketplaces-for-container)
- cart: 1 (cart-summary)

Service registration cleanup:
- blog→blog+federation, events→calendar+federation,
  market→market+federation, cart→cart only,
  federation→federation only, account→nothing
- Stubs reduced to minimal StubFederationService

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
giles
2026-02-25 03:01:38 +00:00
parent 5dafbdbda9
commit 3b707ec8a0
55 changed files with 1210 additions and 581 deletions

View File

@@ -6,7 +6,6 @@ from quart import g, request
from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from shared.services.registry import services
from bp import register_account_bp, register_auth_bp, register_fragments
@@ -17,17 +16,23 @@ async def account_context() -> dict:
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data (consistent with all other apps)
# Cart data via internal data endpoint
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)

View File

@@ -5,23 +5,7 @@ from __future__ import annotations
def register_domain_services() -> None:
"""Register services for the account app.
Account needs all domain services since widgets (tickets, bookings)
pull data from blog, calendar, market, cart, and federation.
Account is a consumer-only dashboard app. It has no own domain.
All cross-app data comes via fragments and HTTP data endpoints.
"""
from shared.services.registry import services
from shared.services.federation_impl import SqlFederationService
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
if not services.has("federation"):
services.federation = SqlFederationService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
pass

View File

@@ -16,6 +16,7 @@ from bp import (
register_menu_items,
register_snippets,
register_fragments,
register_data,
)
@@ -28,20 +29,25 @@ async def blog_context() -> dict:
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data via service (replaces cross-app HTTP API)
# Cart data via internal data endpoint
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
@@ -98,6 +104,7 @@ def create_app() -> "Quart":
app.register_blueprint(register_menu_items())
app.register_blueprint(register_snippets())
app.register_blueprint(register_fragments())
app.register_blueprint(register_data())
# --- KV admin endpoints ---
@app.get("/settings/kv/<key>")

View File

@@ -3,3 +3,4 @@ from .admin.routes import register as register_admin
from .menu_items.routes import register as register_menu_items
from .snippets.routes import register as register_snippets
from .fragments import register_fragments
from .data import register_data

1
blog/bp/data/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_data

74
blog/bp/data/routes.py Normal file
View File

@@ -0,0 +1,74 @@
"""Blog app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
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
_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)
# --- post-by-slug ---
async def _post_by_slug():
slug = request.args.get("slug", "")
post = await services.blog.get_post_by_slug(g.s, slug)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-slug"] = _post_by_slug
# --- post-by-id ---
async def _post_by_id():
post_id = int(request.args.get("id", 0))
post = await services.blog.get_post_by_id(g.s, post_id)
if not post:
return None
return dto_to_dict(post)
_handlers["post-by-id"] = _post_by_id
# --- posts-by-ids ---
async def _posts_by_ids():
ids_raw = request.args.get("ids", "")
if not ids_raw:
return []
ids = [int(x.strip()) for x in ids_raw.split(",") if x.strip()]
posts = await services.blog.get_posts_by_ids(g.s, ids)
return [dto_to_dict(p) for p in posts]
_handlers["posts-by-ids"] = _posts_by_ids
# --- search-posts ---
async def _search_posts():
query = request.args.get("query", "")
page = int(request.args.get("page", 1))
per_page = int(request.args.get("per_page", 10))
posts, total = await services.blog.search_posts(g.s, query, page, per_page)
return {"posts": [dto_to_dict(p) for p in posts], "total": total}
_handlers["search-posts"] = _search_posts
return bp

View File

@@ -193,7 +193,8 @@ def register():
"""Show calendar month view for browsing entries"""
from shared.models.calendars import Calendar
from shared.utils.calendar_helpers import parse_int_arg, add_months, build_calendar_weeks
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
from sqlalchemy import select
from datetime import datetime, timezone
import calendar as pycalendar
@@ -228,7 +229,7 @@ def register():
month_name = pycalendar.month_name[month]
weekday_names = [pycalendar.day_abbr[i] for i in range(7)]
# Get entries for this month
# Get entries for this month via events data endpoint
period_start = datetime(year, month, 1, tzinfo=timezone.utc)
next_y, next_m = add_months(year, month, +1)
period_end = datetime(next_y, next_m, 1, tzinfo=timezone.utc)
@@ -238,10 +239,15 @@ def register():
is_admin = bool(user and getattr(user, "is_admin", False))
session_id = qsession.get("calendar_sid")
month_entries = await services.calendar.visible_entries_for_period(
g.s, calendar_obj.id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
raw_entries = await fetch_data("events", "visible-entries-for-period", params={
"calendar_id": calendar_obj.id,
"period_start": period_start.isoformat(),
"period_end": period_end.isoformat(),
"user_id": user_id,
"is_admin": str(is_admin).lower(),
"session_id": session_id,
}, required=False) or []
month_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
# Get associated entry IDs for this post
post_id = g.post_data["post"]["id"]
@@ -609,18 +615,24 @@ def register():
return redirect(redirect_url)
async def _fetch_page_markets(post_id):
"""Fetch marketplaces for a page via market data endpoint."""
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import MarketPlaceDTO, dto_from_dict
raw = await fetch_data("market", "marketplaces-for-container",
params={"type": "page", "id": post_id}, required=False) or []
return [dto_from_dict(MarketPlaceDTO, m) for m in raw]
@bp.get("/markets/")
@require_admin
async def markets(slug: str):
"""List markets for this page."""
from shared.services.registry import services
post = (g.post_data or {}).get("post", {})
post_id = post.get("id")
if not post_id:
return await make_response("Post not found", 404)
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
@@ -634,7 +646,6 @@ def register():
async def create_market(slug: str):
"""Create a new market for this page."""
from ..services.markets import create_market as _create_market, MarketError
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
@@ -651,7 +662,7 @@ def register():
return jsonify({"error": str(e)}), 400
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",
@@ -665,7 +676,6 @@ def register():
async def delete_market(slug: str, market_slug: str):
"""Soft-delete a market."""
from ..services.markets import soft_delete_market
from shared.services.registry import services
from quart import jsonify
post = (g.post_data or {}).get("post", {})
@@ -676,7 +686,7 @@ def register():
return jsonify({"error": "Market not found"}), 404
# Return updated markets list
page_markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
page_markets = await _fetch_page_markets(post_id)
html = await render_template(
"_types/post/admin/_markets_panel.html",

View File

@@ -12,7 +12,8 @@ from quart import (
)
from .services.post_data import post_data
from .services.post_operations import toggle_post_like
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.infrastructure.fragments import fetch_fragment, fetch_fragments
from shared.browser.app.redis_cacher import cache_page, clear_cache
@@ -92,14 +93,17 @@ def register():
"container_nav_html": container_nav_html,
}
# Page cart badge via service
# Page cart badge via HTTP
post_dict = p_data.get("post") or {}
if post_dict.get("is_page"):
ident = current_cart_identity()
page_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
page_slug=post_dict["slug"],
)
summary_params = {"page_slug": post_dict["slug"]}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
page_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
ctx["page_cart_count"] = page_summary.count + page_summary.calendar_count + page_summary.ticket_count
ctx["page_cart_total"] = float(page_summary.total + page_summary.calendar_total + page_summary.ticket_total)

View File

@@ -2,6 +2,9 @@ from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from shared.infrastructure.actions import call_action, ActionError
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, dto_from_dict
from shared.services.registry import services
@@ -18,10 +21,13 @@ async def toggle_entry_association(
if not post:
return False, "Post not found"
is_associated = await services.calendar.toggle_entry_post(
session, entry_id, "post", post_id,
)
return is_associated, None
try:
result = await call_action("events", "toggle-entry-post", payload={
"entry_id": entry_id, "content_type": "post", "content_id": post_id,
})
return result.get("is_associated", False), None
except ActionError as e:
return False, str(e)
async def get_post_entry_ids(
@@ -32,7 +38,10 @@ async def get_post_entry_ids(
Get all entry IDs associated with this post.
Returns a set of entry IDs.
"""
return await services.calendar.entry_ids_for_content(session, "post", post_id)
raw = await fetch_data("events", "entry-ids-for-content",
params={"content_type": "post", "content_id": post_id},
required=False) or []
return set(raw)
async def get_associated_entries(
@@ -45,12 +54,14 @@ async def get_associated_entries(
Get paginated associated entries for this post.
Returns dict with entries (CalendarEntryDTOs), total_count, and has_more.
"""
entries, has_more = await services.calendar.associated_entries(
session, "post", post_id, page,
)
raw = await fetch_data("events", "associated-entries",
params={"content_type": "post", "content_id": post_id, "page": page},
required=False) or {"entries": [], "has_more": False}
entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw.get("entries", [])]
has_more = raw.get("has_more", False)
total_count = len(entries) + (page - 1) * per_page
if has_more:
total_count += 1 # at least one more
total_count += 1
return {
"entries": entries,

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.page_config import PageConfig
from shared.contracts.dtos import MarketPlaceDTO
from shared.infrastructure.actions import call_action, ActionError
from shared.services.registry import services
@@ -48,8 +49,12 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Market feature is not enabled for this page. Enable it in page settings first.")
try:
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
except ValueError as e:
result = await call_action("market", "create-marketplace", payload={
"container_type": "page", "container_id": post_id,
"name": name, "slug": slug,
})
return MarketPlaceDTO(**result)
except ActionError as e:
raise MarketError(str(e)) from e
@@ -58,4 +63,10 @@ async def soft_delete_market(sess: AsyncSession, post_slug: str, market_slug: st
if not post:
return False
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
try:
result = await call_action("market", "soft-delete-marketplace", payload={
"container_type": "page", "container_id": post.id, "slug": market_slug,
})
return result.get("deleted", False)
except ActionError:
return False

View File

@@ -6,23 +6,14 @@ def register_domain_services() -> None:
"""Register services for the blog app.
Blog owns: Post, Tag, Author, PostAuthor, PostTag, PostLike.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -16,6 +16,8 @@ from bp import (
register_cart_global,
register_orders,
register_fragments,
register_actions,
register_data,
)
from bp.cart.services import (
get_cart,
@@ -135,6 +137,8 @@ def create_app() -> "Quart":
app.jinja_env.globals["cart_delete_url"] = lambda product_id: f"/delete/{product_id}/"
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# --- Page slug hydration (follows events/market app pattern) ---
@@ -152,10 +156,15 @@ def create_app() -> "Quart":
@app.before_request
async def hydrate_page():
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
slug = getattr(g, "page_slug", None)
if not slug:
return
post = await services.blog.get_post_by_slug(g.s, slug)
raw = await fetch_data("blog", "post-by-slug", params={"slug": slug})
if not raw:
abort(404)
post = dto_from_dict(PostDTO, raw)
if not post or not post.is_page:
abort(404)
g.page_post = post

View File

@@ -4,3 +4,5 @@ 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

View File

@@ -0,0 +1 @@
from .routes import register as register_actions

42
cart/bp/actions/routes.py Normal file
View File

@@ -0,0 +1,42 @@
"""Cart app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers (login handler) via the internal action client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.actions import ACTION_HEADER
from shared.services.registry import services
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
_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
result = await handler()
return jsonify(result)
# --- adopt-cart-for-user ---
async def _adopt_cart():
data = await request.get_json()
await services.cart.adopt_cart_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-cart-for-user"] = _adopt_cart
return bp

View File

@@ -8,7 +8,7 @@ from sqlalchemy import select
from shared.models.market import CartItem
from shared.models.order import Order
from shared.models.market_place import MarketPlace
from shared.services.registry import services
from shared.infrastructure.actions import call_action
from .services import (
current_cart_identity,
get_cart,
@@ -91,13 +91,12 @@ def register(url_prefix: str) -> Blueprint:
tt_raw = (form.get("ticket_type_id") or "").strip()
ticket_type_id = int(tt_raw) if tt_raw else None
await services.calendar.adjust_ticket_quantity(
g.s, entry_id, count,
user_id=ident["user_id"],
session_id=ident["session_id"],
ticket_type_id=ticket_type_id,
)
await g.s.flush()
await call_action("events", "adjust-ticket-quantity", payload={
"entry_id": entry_id, "count": count,
"user_id": ident["user_id"],
"session_id": ident["session_id"],
"ticket_type_id": ticket_type_id,
})
resp = await make_response("", 200)
resp.headers["HX-Refresh"] = "true"
@@ -256,13 +255,17 @@ def register(url_prefix: str) -> Blueprint:
# Resolve page/market slugs so product links render correctly
if order.page_config:
post = await services.blog.get_post_by_id(g.s, order.page_config.container_id)
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
post = await fetch_data("blog", "post-by-id",
params={"id": order.page_config.container_id},
required=False)
if post:
g.page_slug = post.slug
g.page_slug = post["slug"]
result = await g.s.execute(
select(MarketPlace).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == post.id,
MarketPlace.container_id == post["id"],
MarketPlace.deleted_at.is_(None),
).limit(1)
)
@@ -278,8 +281,14 @@ def register(url_prefix: str) -> Blueprint:
status = (order.status or "pending").lower()
calendar_entries = await services.calendar.get_entries_for_order(g.s, order.id)
order_tickets = await services.calendar.get_tickets_for_order(g.s, order.id)
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(

View File

@@ -2,7 +2,8 @@ from __future__ import annotations
from decimal import Decimal
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
from .identity import current_cart_identity
@@ -12,11 +13,13 @@ async def get_calendar_cart_entries(session):
current cart identity (user or anonymous session).
"""
ident = current_cart_identity()
return await services.calendar.pending_entries(
session,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
params = {}
if ident["user_id"] is not None:
params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
params["session_id"] = ident["session_id"]
raw = await fetch_data("events", "pending-entries", params=params, required=False) or []
return [dto_from_dict(CalendarEntryDTO, e) for e in raw]
def calendar_total(entries) -> Decimal:
@@ -33,11 +36,13 @@ def calendar_total(entries) -> Decimal:
async def get_ticket_cart_entries(session):
"""Return all reserved tickets (as TicketDTOs) for the current identity."""
ident = current_cart_identity()
return await services.calendar.pending_tickets(
session,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
params = {}
if ident["user_id"] is not None:
params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
params["session_id"] = ident["session_id"]
raw = await fetch_data("events", "pending-tickets", params=params, required=False) or []
return [dto_from_dict(TicketDTO, t) for t in raw]
def ticket_total(tickets) -> Decimal:

View File

@@ -1,6 +1,6 @@
from shared.browser.app.payments.sumup import get_checkout as sumup_get_checkout
from shared.events import emit_activity
from shared.services.registry import services
from shared.infrastructure.actions import call_action
from .clear_cart_for_order import clear_cart_for_order
@@ -14,10 +14,13 @@ async def check_sumup_status(session, order):
if sumup_status == "PAID":
if order.status != "paid":
order.status = "paid"
await services.calendar.confirm_entries_for_order(
session, order.id, order.user_id, order.session_id
)
await services.calendar.confirm_tickets_for_order(session, order.id)
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,
})
# Clear cart only after payment is confirmed
page_post_id = page_config.container_id if page_config else None

View File

@@ -14,7 +14,7 @@ from shared.models.market_place import MarketPlace
from shared.config import config
from shared.contracts.dtos import CalendarEntryDTO
from shared.events import emit_activity
from shared.services.registry import services
from shared.infrastructure.actions import call_action
async def find_or_create_cart_item(
@@ -156,15 +156,17 @@ async def create_order_from_cart(
)
session.add(oi)
# Mark pending calendar entries as "ordered" via calendar service
await services.calendar.claim_entries_for_order(
session, order.id, user_id, session_id, page_post_id
)
# Mark pending calendar entries as "ordered" via events action endpoint
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,
})
# Claim reserved tickets for this order
await services.calendar.claim_tickets_for_order(
session, order.id, user_id, session_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,

View File

@@ -15,7 +15,8 @@ from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.page_config import PageConfig
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, PostDTO, dto_from_dict
from .identity import current_cart_identity
@@ -50,21 +51,25 @@ async def get_cart_for_page(session, post_id: int) -> list[CartItem]:
async def get_calendar_entries_for_page(session, post_id: int):
"""Return pending calendar entries (DTOs) scoped to a specific page."""
ident = current_cart_identity()
return await services.calendar.entries_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
params = {"page_id": post_id}
if ident["user_id"] is not None:
params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
params["session_id"] = ident["session_id"]
raw = await fetch_data("events", "entries-for-page", params=params, required=False) or []
return [dto_from_dict(CalendarEntryDTO, e) for e in raw]
async def get_tickets_for_page(session, post_id: int):
"""Return reserved tickets (DTOs) scoped to a specific page."""
ident = current_cart_identity()
return await services.calendar.tickets_for_page(
session, post_id,
user_id=ident["user_id"],
session_id=ident["session_id"],
)
params = {"page_id": post_id}
if ident["user_id"] is not None:
params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
params["session_id"] = ident["session_id"]
raw = await fetch_data("events", "tickets-for-page", params=params, required=False) or []
return [dto_from_dict(TicketDTO, t) for t in raw]
async def get_cart_grouped_by_page(session) -> list[dict]:
@@ -167,7 +172,11 @@ async def get_cart_grouped_by_page(session) -> list[dict]:
configs_by_post: dict[int, PageConfig] = {}
if post_ids:
for p in await services.blog.get_posts_by_ids(session, post_ids):
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
posts_by_id[p.id] = p
pc_result = await session.execute(

1
cart/bp/data/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .routes import register as register_data

45
cart/bp/data/routes.py Normal file
View File

@@ -0,0 +1,45 @@
"""Cart app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
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
_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)
# --- cart-summary ---
async def _cart_summary():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
page_slug = request.args.get("page_slug")
summary = await services.cart.cart_summary(
g.s, user_id=user_id, session_id=session_id, page_slug=page_slug,
)
return dto_to_dict(summary)
_handlers["cart-summary"] = _cart_summary
return bp

View File

@@ -6,23 +6,9 @@ def register_domain_services() -> None:
"""Register services for the cart app.
Cart owns: Order, OrderItem.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.cart = SqlCartService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -8,7 +8,7 @@ from jinja2 import FileSystemLoader, ChoiceLoader
from shared.infrastructure.factory import create_base_app
from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments
from bp import register_all_events, register_calendars, register_markets, register_payments, register_page, register_fragments, register_actions, register_data
async def events_context() -> dict:
@@ -20,20 +20,25 @@ async def events_context() -> dict:
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data via service (replaces cross-app HTTP API)
# Cart data via internal data endpoint
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)
@@ -58,7 +63,6 @@ async def events_context() -> dict:
def create_app() -> "Quart":
from shared.services.registry import services
from services import register_domain_services
app = create_base_app(
@@ -105,6 +109,8 @@ def create_app() -> "Quart":
)
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# --- Auto-inject slug into url_for() calls ---
@app.url_value_preprocessor
@@ -122,31 +128,39 @@ def create_app() -> "Quart":
# --- Load post data for slug ---
@app.before_request
async def hydrate_post():
from shared.infrastructure.data_client import fetch_data
slug = getattr(g, "post_slug", None)
if not slug:
return
post = await services.blog.get_post_by_slug(g.s, slug)
post = await fetch_data("blog", "post-by-slug", params={"slug": slug})
if not post:
abort(404)
g.post_data = {
"post": {
"id": post.id,
"title": post.title,
"slug": post.slug,
"feature_image": post.feature_image,
"status": post.status,
"visibility": post.visibility,
"id": post["id"],
"title": post["title"],
"slug": post["slug"],
"feature_image": post.get("feature_image"),
"status": post["status"],
"visibility": post["visibility"],
},
}
@app.context_processor
async def inject_post():
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarDTO, MarketPlaceDTO, dto_from_dict
from shared.services.registry import services
post_data = getattr(g, "post_data", None)
if not post_data:
return {}
post_id = post_data["post"]["id"]
# Calendar data is local (events owns it)
calendars = await services.calendar.calendars_for_container(g.s, "page", post_id)
markets = await services.market.marketplaces_for_container(g.s, "page", post_id)
# Market data is cross-app
raw_markets = await fetch_data("market", "marketplaces-for-container",
params={"type": "page", "id": post_id}, required=False) or []
markets = [dto_from_dict(MarketPlaceDTO, m) for m in raw_markets]
return {
**post_data,
"calendars": calendars,
@@ -168,6 +182,7 @@ def create_app() -> "Quart":
from quart import jsonify
from shared.infrastructure.urls import events_url
from shared.infrastructure.oembed import build_oembed_response
from shared.infrastructure.data_client import fetch_data
url = request.args.get("url", "")
if not url:
@@ -178,15 +193,15 @@ def create_app() -> "Quart":
if not slug:
return jsonify({"error": "could not extract slug"}), 404
post = await services.blog.get_post_by_slug(g.s, slug)
post = await fetch_data("blog", "post-by-slug", params={"slug": slug})
if not post:
return jsonify({"error": "not found"}), 404
resp = build_oembed_response(
title=post.title,
title=post["title"],
oembed_type="link",
thumbnail_url=post.feature_image,
url=events_url(f"/{post.slug}"),
thumbnail_url=post.get("feature_image"),
url=events_url(f"/{post['slug']}"),
)
return jsonify(resp)

View File

@@ -4,3 +4,5 @@ from .markets.routes import register as register_markets
from .payments.routes import register as register_payments
from .page.routes import register as register_page
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -0,0 +1 @@
from .routes import register as register_actions

131
events/bp/actions/routes.py Normal file
View File

@@ -0,0 +1,131 @@
"""Events app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers (cart, blog) via the internal action client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.actions import ACTION_HEADER
from shared.services.registry import services
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
_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
result = await handler()
return jsonify(result)
# --- adjust-ticket-quantity ---
async def _adjust_ticket_quantity():
data = await request.get_json()
await services.calendar.adjust_ticket_quantity(
g.s,
data["entry_id"],
data["count"],
user_id=data.get("user_id"),
session_id=data.get("session_id"),
ticket_type_id=data.get("ticket_type_id"),
)
return {"ok": True}
_handlers["adjust-ticket-quantity"] = _adjust_ticket_quantity
# --- claim-entries-for-order ---
async def _claim_entries():
data = await request.get_json()
await services.calendar.claim_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-entries-for-order"] = _claim_entries
# --- claim-tickets-for-order ---
async def _claim_tickets():
data = await request.get_json()
await services.calendar.claim_tickets_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
data.get("page_post_id"),
)
return {"ok": True}
_handlers["claim-tickets-for-order"] = _claim_tickets
# --- confirm-entries-for-order ---
async def _confirm_entries():
data = await request.get_json()
await services.calendar.confirm_entries_for_order(
g.s,
data["order_id"],
data.get("user_id"),
data.get("session_id"),
)
return {"ok": True}
_handlers["confirm-entries-for-order"] = _confirm_entries
# --- confirm-tickets-for-order ---
async def _confirm_tickets():
data = await request.get_json()
await services.calendar.confirm_tickets_for_order(
g.s, data["order_id"],
)
return {"ok": True}
_handlers["confirm-tickets-for-order"] = _confirm_tickets
# --- toggle-entry-post ---
async def _toggle_entry_post():
data = await request.get_json()
is_associated = await services.calendar.toggle_entry_post(
g.s,
data["entry_id"],
data["content_type"],
data["content_id"],
)
return {"is_associated": is_associated}
_handlers["toggle-entry-post"] = _toggle_entry_post
# --- adopt-entries-for-user ---
async def _adopt_entries():
data = await request.get_json()
await services.calendar.adopt_entries_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-entries-for-user"] = _adopt_entries
# --- adopt-tickets-for-user ---
async def _adopt_tickets():
data = await request.get_json()
await services.calendar.adopt_tickets_for_user(
g.s, data["user_id"], data["session_id"],
)
return {"ok": True}
_handlers["adopt-tickets-for-user"] = _adopt_tickets
return bp

View File

@@ -15,6 +15,8 @@ from quart import Blueprint, g, request, render_template, render_template_string
from shared.browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, PostDTO, dto_from_dict
from shared.services.registry import services
@@ -47,8 +49,11 @@ def register() -> Blueprint:
if e.calendar_container_type == "page" and e.calendar_container_id
})
if post_ids:
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
for p in posts:
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
page_info[p.id] = {"title": p.title, "slug": p.slug}
return entries, has_more, pending_tickets, page_info
@@ -121,9 +126,13 @@ def register() -> Blueprint:
entry = await services.calendar.entry_by_id(g.s, entry_id)
# Updated cart count for OOB mini-cart
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
cart_count = summary.count + summary.calendar_count + summary.ticket_count
# Render widget + OOB cart-mini

View File

@@ -219,13 +219,18 @@ def register():
select(sa_func.count()).select_from(CalendarEntry).where(*cal_filters)
) or 0
# Get product cart count via service (same DB, no HTTP needed)
# Get product cart count via HTTP
from shared.infrastructure.cart_identity import current_cart_identity
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ident = current_cart_identity()
cart_summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
cart_summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
product_count = cart_summary.count
total_count = product_count + cal_count

View File

@@ -5,7 +5,8 @@ from sqlalchemy import select
from sqlalchemy.sql import func
from models.calendars import CalendarEntry, CalendarEntryPost
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
async def add_post_to_entry(
@@ -28,8 +29,8 @@ async def add_post_to_entry(
return False, "Calendar entry not found"
# Check if post exists
post = await services.blog.get_post_by_id(session, post_id)
if not post:
raw = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False)
if not raw:
return False, "Post not found"
# Check if association already exists
@@ -103,7 +104,10 @@ async def get_entry_posts(
post_ids = list(result.scalars().all())
if not post_ids:
return []
posts = await services.blog.get_posts_by_ids(session, post_ids)
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
posts = [dto_from_dict(PostDTO, p) for p in raw_posts]
return sorted(posts, key=lambda p: (p.title or ""))
@@ -118,4 +122,8 @@ async def search_posts(
If query is empty, returns all posts in published order.
Returns (post_dtos, total_count).
"""
return await services.blog.search_posts(session, query, page, per_page)
raw = await fetch_data("blog", "search-posts",
params={"query": query, "page": page, "per_page": per_page},
required=False) or {"posts": [], "total": 0}
posts = [dto_from_dict(PostDTO, p) for p in raw.get("posts", [])]
return posts, raw.get("total", 0)

View File

@@ -4,7 +4,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from models.calendars import Calendar
from shared.services.registry import services
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.relationships import attach_child, detach_child
import unicodedata
import re
@@ -49,7 +50,8 @@ def slugify(value: str, max_len: int = 255) -> str:
async def soft_delete(sess: AsyncSession, post_slug: str, calendar_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
raw = await fetch_data("blog", "post-by-slug", params={"slug": post_slug}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
return False
@@ -84,7 +86,8 @@ async def create_calendar(sess: AsyncSession, post_id: int, name: str) -> Calend
slug=slugify(name)
# Ensure post exists (avoid silent FK errors in some DBs)
post = await services.blog.get_post_by_id(sess, post_id)
raw = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
raise CalendarError(f"Post {post_id} does not exist.")

View File

@@ -0,0 +1 @@
from .routes import register as register_data

144
events/bp/data/routes.py Normal file
View File

@@ -0,0 +1,144 @@
"""Events app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
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
_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)
# --- pending-entries ---
async def _pending_entries():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
entries = await services.calendar.pending_entries(
g.s, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["pending-entries"] = _pending_entries
# --- pending-tickets ---
async def _pending_tickets():
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
tickets = await services.calendar.pending_tickets(
g.s, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(t) for t in tickets]
_handlers["pending-tickets"] = _pending_tickets
# --- entries-for-page ---
async def _entries_for_page():
page_id = request.args.get("page_id", type=int)
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
entries = await services.calendar.entries_for_page(
g.s, page_id, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["entries-for-page"] = _entries_for_page
# --- tickets-for-page ---
async def _tickets_for_page():
page_id = request.args.get("page_id", type=int)
user_id = request.args.get("user_id", type=int)
session_id = request.args.get("session_id")
tickets = await services.calendar.tickets_for_page(
g.s, page_id, user_id=user_id, session_id=session_id,
)
return [dto_to_dict(t) for t in tickets]
_handlers["tickets-for-page"] = _tickets_for_page
# --- entries-for-order ---
async def _entries_for_order():
order_id = request.args.get("order_id", type=int)
entries = await services.calendar.get_entries_for_order(g.s, order_id)
return [dto_to_dict(e) for e in entries]
_handlers["entries-for-order"] = _entries_for_order
# --- tickets-for-order ---
async def _tickets_for_order():
order_id = request.args.get("order_id", type=int)
tickets = await services.calendar.get_tickets_for_order(g.s, order_id)
return [dto_to_dict(t) for t in tickets]
_handlers["tickets-for-order"] = _tickets_for_order
# --- entry-ids-for-content ---
async def _entry_ids_for_content():
content_type = request.args.get("content_type", "")
content_id = request.args.get("content_id", type=int)
ids = await services.calendar.entry_ids_for_content(g.s, content_type, content_id)
return list(ids)
_handlers["entry-ids-for-content"] = _entry_ids_for_content
# --- associated-entries ---
async def _associated_entries():
content_type = request.args.get("content_type", "")
content_id = request.args.get("content_id", type=int)
page = request.args.get("page", 1, type=int)
entries, has_more = await services.calendar.associated_entries(
g.s, content_type, content_id, page,
)
return {"entries": [dto_to_dict(e) for e in entries], "has_more": has_more}
_handlers["associated-entries"] = _associated_entries
# --- calendars-for-container ---
async def _calendars_for_container():
container_type = request.args.get("type", "")
container_id = request.args.get("id", type=int)
calendars = await services.calendar.calendars_for_container(
g.s, container_type, container_id,
)
return [dto_to_dict(c) for c in calendars]
_handlers["calendars-for-container"] = _calendars_for_container
# --- visible-entries-for-period ---
async def _visible_entries_for_period():
from datetime import datetime
calendar_id = request.args.get("calendar_id", type=int)
period_start = datetime.fromisoformat(request.args.get("period_start", ""))
period_end = datetime.fromisoformat(request.args.get("period_end", ""))
user_id = request.args.get("user_id", type=int)
is_admin = request.args.get("is_admin", "false").lower() == "true"
session_id = request.args.get("session_id")
entries = await services.calendar.visible_entries_for_period(
g.s, calendar_id, period_start, period_end,
user_id=user_id, is_admin=is_admin, session_id=session_id,
)
return [dto_to_dict(e) for e in entries]
_handlers["visible-entries-for-period"] = _visible_entries_for_period
return bp

View File

@@ -9,6 +9,8 @@ from __future__ import annotations
from quart import Blueprint, Response, g, render_template, request
from shared.infrastructure.fragments import FRAGMENT_HEADER
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.registry import services
@@ -139,7 +141,8 @@ def register():
parts = []
for s in slugs:
parts.append(f"<!-- fragment:{s} -->")
post = await services.blog.get_post_by_slug(g.s, s)
raw = await fetch_data("blog", "post-by-slug", params={"slug": s}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if post:
calendars = await services.calendar.calendars_for_container(
g.s, "page", post.id,
@@ -157,7 +160,8 @@ def register():
# Single mode
if not slug:
return ""
post = await services.blog.get_post_by_slug(g.s, slug)
raw = await fetch_data("blog", "post-by-slug", params={"slug": slug}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
return ""
calendars = await services.calendar.calendars_for_container(

View File

@@ -5,8 +5,9 @@ import unicodedata
from sqlalchemy.ext.asyncio import AsyncSession
from shared.contracts.dtos import MarketPlaceDTO
from shared.services.registry import services
from shared.contracts.dtos import MarketPlaceDTO, PostDTO, dto_from_dict
from shared.infrastructure.actions import call_action, ActionError
from shared.infrastructure.data_client import fetch_data
class MarketError(ValueError):
@@ -37,21 +38,33 @@ async def create_market(sess: AsyncSession, post_id: int, name: str) -> MarketPl
raise MarketError("Market name must not be empty.")
slug = slugify(name)
post = await services.blog.get_post_by_id(sess, post_id)
raw = await fetch_data("blog", "post-by-id", params={"id": post_id}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
raise MarketError(f"Post {post_id} does not exist.")
if not post.is_page:
raise MarketError("Markets can only be created on pages, not posts.")
try:
return await services.market.create_marketplace(sess, "page", post_id, name, slug)
except ValueError as e:
result = await call_action("market", "create-marketplace", payload={
"container_type": "page", "container_id": post_id,
"name": name, "slug": slug,
})
return MarketPlaceDTO(**result)
except ActionError as e:
raise MarketError(str(e)) from e
async def soft_delete(sess: AsyncSession, post_slug: str, market_slug: str) -> bool:
post = await services.blog.get_post_by_slug(sess, post_slug)
raw = await fetch_data("blog", "post-by-slug", params={"slug": post_slug}, required=False)
post = dto_from_dict(PostDTO, raw) if raw else None
if not post:
return False
return await services.market.soft_delete_marketplace(sess, "page", post.id, market_slug)
try:
result = await call_action("market", "soft-delete-marketplace", payload={
"container_type": "page", "container_id": post.id, "slug": market_slug,
})
return result.get("deleted", False)
except ActionError:
return False

View File

@@ -12,6 +12,8 @@ from quart import Blueprint, g, request, render_template, render_template_string
from shared.browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.services.registry import services
@@ -107,9 +109,13 @@ def register() -> Blueprint:
entry = await services.calendar.entry_by_id(g.s, entry_id)
# Updated cart count for OOB mini-cart
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
cart_count = summary.count + summary.calendar_count + summary.ticket_count
# Render widget + OOB cart-mini

View File

@@ -287,10 +287,15 @@ def register() -> Blueprint:
)
# Compute cart count for OOB mini-cart update
from shared.services.registry import services
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw_summary = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw_summary) if raw_summary else CartSummaryDTO()
cart_count = summary.count + summary.calendar_count + summary.ticket_count
html = await render_template(

View File

@@ -7,23 +7,14 @@ def register_domain_services() -> None:
Events owns: Calendar, CalendarEntry, CalendarSlot, TicketType,
Ticket, CalendarEntryPost.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.calendar = SqlCalendarService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -21,17 +21,23 @@ async def federation_context() -> dict:
from shared.services.navigation import get_navigation_tree
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
ctx = await base_context()
# Fallback for _nav.html when nav-tree fragment fetch fails
ctx["menu_items"] = await get_navigation_tree(g.s)
# Cart data (consistent with all other apps)
# Cart data via internal data endpoint
ident = current_cart_identity()
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count + summary.ticket_count
ctx["cart_total"] = float(summary.total + summary.calendar_total + summary.ticket_total)

View File

@@ -7,21 +7,9 @@ def register_domain_services() -> None:
Federation owns: ActorProfile, APActivity, APFollower, APInboxItem,
APAnchor, IPFSPin.
Standard deployment registers all services as real DB impls (shared DB).
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.federation_impl import SqlFederationService
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.federation = SqlFederationService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("market"):
services.market = SqlMarketService()
if not services.has("cart"):
services.cart = SqlCartService()

View File

@@ -10,7 +10,7 @@ from sqlalchemy import select
from shared.infrastructure.factory import create_base_app
from shared.config import config
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments
from bp import register_market_bp, register_all_markets, register_page_markets, register_fragments, register_actions, register_data
async def market_context() -> dict:
@@ -23,9 +23,10 @@ async def market_context() -> dict:
"""
from shared.infrastructure.context import base_context
from shared.services.navigation import get_navigation_tree
from shared.services.registry import services
from shared.infrastructure.cart_identity import current_cart_identity
from shared.infrastructure.fragments import fetch_fragments
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CartSummaryDTO, dto_from_dict
from shared.models.market import CartItem
from sqlalchemy.orm import selectinload
@@ -36,10 +37,14 @@ async def market_context() -> dict:
ident = current_cart_identity()
# cart_count/cart_total via service (consistent with blog/events apps)
summary = await services.cart.cart_summary(
g.s, user_id=ident["user_id"], session_id=ident["session_id"],
)
# cart_count/cart_total via internal data endpoint
summary_params = {}
if ident["user_id"] is not None:
summary_params["user_id"] = ident["user_id"]
if ident["session_id"] is not None:
summary_params["session_id"] = ident["session_id"]
raw = await fetch_data("cart", "cart-summary", params=summary_params, required=False)
summary = dto_from_dict(CartSummaryDTO, raw) if raw else CartSummaryDTO()
ctx["cart_count"] = summary.count + summary.calendar_count
ctx["cart_total"] = float(summary.total + summary.calendar_total)
@@ -80,7 +85,6 @@ async def market_context() -> dict:
def create_app() -> "Quart":
from models.market_place import MarketPlace
from shared.services.registry import services
from services import register_domain_services
app = create_base_app(
@@ -118,6 +122,8 @@ def create_app() -> "Quart":
)
app.register_blueprint(register_fragments())
app.register_blueprint(register_actions())
app.register_blueprint(register_data())
# --- Auto-inject slugs into url_for() calls ---
@app.url_value_preprocessor
@@ -147,26 +153,27 @@ def create_app() -> "Quart":
# --- Load post and market data ---
@app.before_request
async def hydrate_market():
from shared.infrastructure.data_client import fetch_data
post_slug = getattr(g, "post_slug", None)
market_slug = getattr(g, "market_slug", None)
if not post_slug:
return
# Load post by slug via blog service
post = await services.blog.get_post_by_slug(g.s, post_slug)
# Load post by slug via blog data endpoint
post = await fetch_data("blog", "post-by-slug", params={"slug": post_slug})
if not post:
abort(404)
g.post_data = {
"post": {
"id": post.id,
"title": post.title,
"slug": post.slug,
"feature_image": post.feature_image,
"html": post.html,
"status": post.status,
"visibility": post.visibility,
"is_page": post.is_page,
"id": post["id"],
"title": post["title"],
"slug": post["slug"],
"feature_image": post.get("feature_image"),
"html": post.get("html"),
"status": post["status"],
"visibility": post["visibility"],
"is_page": post.get("is_page", False),
},
}

View File

@@ -3,3 +3,5 @@ from .product.routes import register as register_product
from .all_markets.routes import register as register_all_markets
from .page_markets.routes import register as register_page_markets
from .fragments import register_fragments
from .actions import register_actions
from .data import register_data

View File

@@ -0,0 +1 @@
from .routes import register as register_actions

View File

@@ -0,0 +1,66 @@
"""Market app action endpoints.
Exposes write operations at ``/internal/actions/<action_name>`` for
cross-app callers (blog, events) via the internal action client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.actions import ACTION_HEADER
from shared.services.registry import services
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
_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
result = await handler()
return jsonify(result)
# --- create-marketplace ---
async def _create_marketplace():
data = await request.get_json()
mp = await services.market.create_marketplace(
g.s,
data["container_type"],
data["container_id"],
data["name"],
data["slug"],
)
return {
"id": mp.id,
"container_type": mp.container_type,
"container_id": mp.container_id,
"name": mp.name,
"slug": mp.slug,
"description": mp.description,
}
_handlers["create-marketplace"] = _create_marketplace
# --- soft-delete-marketplace ---
async def _soft_delete_marketplace():
data = await request.get_json()
deleted = await services.market.soft_delete_marketplace(
g.s,
data["container_type"],
data["container_id"],
data["slug"],
)
return {"deleted": deleted}
_handlers["soft-delete-marketplace"] = _soft_delete_marketplace
return bp

View File

@@ -12,6 +12,8 @@ from __future__ import annotations
from quart import Blueprint, g, request, render_template, make_response
from shared.browser.app.utils.htmx import is_htmx_request
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import PostDTO, dto_from_dict
from shared.services.registry import services
@@ -32,8 +34,11 @@ def register() -> Blueprint:
if m.container_type == "page"
})
if post_ids:
posts = await services.blog.get_posts_by_ids(g.s, post_ids)
for p in posts:
raw_posts = await fetch_data("blog", "posts-by-ids",
params={"ids": ",".join(str(i) for i in post_ids)},
required=False) or []
for raw_p in raw_posts:
p = dto_from_dict(PostDTO, raw_p)
page_info[p.id] = {"title": p.title, "slug": p.slug}
return markets, has_more, page_info

View File

@@ -0,0 +1 @@
from .routes import register as register_data

44
market/bp/data/routes.py Normal file
View File

@@ -0,0 +1,44 @@
"""Market app data endpoints.
Exposes read-only JSON queries at ``/internal/data/<query_name>`` for
cross-app callers via the internal data client.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from shared.infrastructure.data_client import DATA_HEADER
from shared.contracts.dtos import dto_to_dict
from shared.services.registry import services
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
_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)
# --- marketplaces-for-container ---
async def _marketplaces_for_container():
container_type = request.args.get("type", "")
container_id = request.args.get("id", type=int)
markets = await services.market.marketplaces_for_container(
g.s, container_type, container_id,
)
return [dto_to_dict(m) for m in markets]
_handlers["marketplaces-for-container"] = _marketplaces_for_container
return bp

View File

@@ -7,23 +7,14 @@ def register_domain_services() -> None:
Market owns: Product, CartItem, MarketPlace, NavTop, NavSub,
Listing, ProductImage.
Standard deployment registers all 4 services as real DB impls
(shared DB). For composable deployments, swap non-owned services
with stubs from shared.services.stubs.
Cross-app calls go over HTTP via call_action() / fetch_data().
"""
from shared.services.registry import services
from shared.services.blog_impl import SqlBlogService
from shared.services.calendar_impl import SqlCalendarService
from shared.services.market_impl import SqlMarketService
from shared.services.cart_impl import SqlCartService
services.market = SqlMarketService()
if not services.has("blog"):
services.blog = SqlBlogService()
if not services.has("calendar"):
services.calendar = SqlCalendarService()
if not services.has("cart"):
services.cart = SqlCartService()
# Federation needed for AP shared infrastructure (activitypub blueprint)
if not services.has("federation"):
from shared.services.federation_impl import SqlFederationService
services.federation = SqlFederationService()

View File

@@ -5,11 +5,74 @@ see ORM model instances from another domain — only these DTOs.
"""
from __future__ import annotations
import dataclasses
import typing
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
# ---------------------------------------------------------------------------
# Serialization helpers for JSON transport over internal data endpoints
# ---------------------------------------------------------------------------
def _serialize_value(v):
"""Convert a single value to a JSON-safe type."""
if isinstance(v, datetime):
return v.isoformat()
if isinstance(v, Decimal):
return str(v)
if isinstance(v, set):
return list(v)
if dataclasses.is_dataclass(v) and not isinstance(v, type):
return dto_to_dict(v)
if isinstance(v, list):
return [_serialize_value(item) for item in v]
return v
def dto_to_dict(obj) -> dict:
"""Convert a frozen DTO dataclass to a JSON-serialisable dict."""
return {k: _serialize_value(v) for k, v in dataclasses.asdict(obj).items()}
def _unwrap_optional(hint):
"""Unwrap Optional[X] / X | None to return X."""
args = getattr(hint, "__args__", ())
if args:
real = [a for a in args if a is not type(None)]
if real:
return real[0]
return hint
def dto_from_dict(cls, data: dict):
"""Construct a DTO from a dict, coercing dates and Decimals.
Uses ``typing.get_type_hints()`` to resolve forward-ref annotations
(from ``from __future__ import annotations``).
"""
if not data:
return None
try:
hints = typing.get_type_hints(cls)
except Exception:
hints = {}
kwargs = {}
for f in dataclasses.fields(cls):
if f.name not in data:
continue
val = data[f.name]
if val is not None and f.name in hints:
hint = _unwrap_optional(hints[f.name])
if hint is datetime and isinstance(val, str):
val = datetime.fromisoformat(val)
elif hint is Decimal:
val = Decimal(str(val))
kwargs[f.name] = val
return cls(**kwargs)
# ---------------------------------------------------------------------------
# Blog domain
# ---------------------------------------------------------------------------

View File

@@ -1,23 +1,31 @@
from __future__ import annotations
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import register_activity_handler
from shared.infrastructure.actions import call_action, ActionError
from shared.models.federation import APActivity
from shared.services.registry import services
log = logging.getLogger(__name__)
async def on_user_logged_in(activity: APActivity, session: AsyncSession) -> None:
data = activity.object_data
user_id = data["user_id"]
session_id = data["session_id"]
payload = {"user_id": user_id, "session_id": session_id}
if services.has("cart"):
await services.cart.adopt_cart_for_user(session, user_id, session_id)
if services.has("calendar"):
await services.calendar.adopt_entries_for_user(session, user_id, session_id)
await services.calendar.adopt_tickets_for_user(session, user_id, session_id)
for app, action in [
("cart", "adopt-cart-for-user"),
("events", "adopt-entries-for-user"),
("events", "adopt-tickets-for-user"),
]:
try:
await call_action(app, action, payload=payload)
except ActionError:
log.warning("Failed: %s/%s for user %s", app, action, user_id)
register_activity_handler("rose:Login", on_user_logged_in)

View File

@@ -0,0 +1,89 @@
"""Internal action client for cross-app write operations.
Each coop app exposes JSON action endpoints at ``/internal/actions/{name}``.
This module provides helpers to call those endpoints so that callers don't
need direct access to another app's DB session or service layer.
Failures raise ``ActionError`` so callers can handle or propagate them.
"""
from __future__ import annotations
import logging
import os
import httpx
log = logging.getLogger(__name__)
# Re-usable async client (created lazily, one per process)
_client: httpx.AsyncClient | None = None
# Default request timeout (seconds) — longer than fragments since these are writes
_DEFAULT_TIMEOUT = 5.0
# Header sent on every action request so providers can gate access.
ACTION_HEADER = "X-Internal-Action"
class ActionError(Exception):
"""Raised when an internal action call fails."""
def __init__(self, message: str, status_code: int = 500, detail: dict | None = None):
super().__init__(message)
self.status_code = status_code
self.detail = detail
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(_DEFAULT_TIMEOUT),
follow_redirects=False,
)
return _client
def _internal_url(app_name: str) -> str:
"""Resolve the Docker-internal base URL for *app_name*."""
env_key = f"INTERNAL_URL_{app_name.upper()}"
return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
async def call_action(
app_name: str,
action_name: str,
*,
payload: dict | None = None,
timeout: float = _DEFAULT_TIMEOUT,
) -> dict:
"""POST JSON to ``{INTERNAL_URL_APP}/internal/actions/{action_name}``.
Returns the parsed JSON response on 2xx.
Raises ``ActionError`` on network errors or non-2xx responses.
"""
base = _internal_url(app_name)
url = f"{base}/internal/actions/{action_name}"
try:
resp = await _get_client().post(
url,
json=payload or {},
headers={ACTION_HEADER: "1"},
timeout=timeout,
)
if 200 <= resp.status_code < 300:
return resp.json()
msg = f"Action {app_name}/{action_name} returned {resp.status_code}"
detail = None
try:
detail = resp.json()
except Exception:
pass
log.error(msg)
raise ActionError(msg, status_code=resp.status_code, detail=detail)
except ActionError:
raise
except Exception as exc:
msg = f"Action {app_name}/{action_name} failed: {exc}"
log.error(msg)
raise ActionError(msg) from exc

View File

@@ -0,0 +1,91 @@
"""Internal data client for cross-app read operations.
Each coop app exposes JSON data endpoints at ``/internal/data/{query}``.
This module provides helpers to fetch that data so that callers don't
need direct access to another app's DB session or service layer.
Same pattern as the fragment client but returns parsed JSON instead of HTML.
"""
from __future__ import annotations
import logging
import os
import httpx
log = logging.getLogger(__name__)
# Re-usable async client (created lazily, one per process)
_client: httpx.AsyncClient | None = None
# Default request timeout (seconds)
_DEFAULT_TIMEOUT = 3.0
# Header sent on every data request so providers can gate access.
DATA_HEADER = "X-Internal-Data"
class DataError(Exception):
"""Raised when an internal data fetch fails."""
def __init__(self, message: str, status_code: int = 500):
super().__init__(message)
self.status_code = status_code
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(_DEFAULT_TIMEOUT),
follow_redirects=False,
)
return _client
def _internal_url(app_name: str) -> str:
"""Resolve the Docker-internal base URL for *app_name*."""
env_key = f"INTERNAL_URL_{app_name.upper()}"
return os.getenv(env_key, f"http://{app_name}:8000").rstrip("/")
async def fetch_data(
app_name: str,
query_name: str,
*,
params: dict | None = None,
timeout: float = _DEFAULT_TIMEOUT,
required: bool = True,
) -> dict | list | None:
"""GET JSON from ``{INTERNAL_URL_APP}/internal/data/{query_name}``.
Returns parsed JSON (dict or list) on success.
When *required* is True (default), raises ``DataError`` on failure.
When *required* is False, returns None on failure.
"""
base = _internal_url(app_name)
url = f"{base}/internal/data/{query_name}"
try:
resp = await _get_client().get(
url,
params=params,
headers={DATA_HEADER: "1"},
timeout=timeout,
)
if resp.status_code == 200:
return resp.json()
msg = f"Data {app_name}/{query_name} returned {resp.status_code}"
if required:
log.error(msg)
raise DataError(msg, status_code=resp.status_code)
log.warning(msg)
return None
except DataError:
raise
except Exception as exc:
msg = f"Data {app_name}/{query_name} failed: {exc}"
if required:
log.error(msg)
raise DataError(msg) from exc
log.warning(msg)
return None

View File

@@ -13,7 +13,6 @@ from sqlalchemy.orm import selectinload
from shared.models.market import CartItem
from shared.models.market_place import MarketPlace
from shared.models.calendars import CalendarEntry, Calendar
from shared.contracts.dtos import CartItemDTO, CartSummaryDTO
@@ -39,13 +38,16 @@ class SqlCartService:
page_slug: str | None = None,
) -> CartSummaryDTO:
"""Build a lightweight cart summary for the current identity."""
# Resolve page filter
from shared.infrastructure.data_client import fetch_data
from shared.contracts.dtos import CalendarEntryDTO, TicketDTO, dto_from_dict
# Resolve page filter via blog data endpoint
page_post_id: int | None = None
if page_slug:
from shared.services.registry import services
post = await services.blog.get_post_by_slug(session, page_slug)
if post and post.is_page:
page_post_id = post.id
post = await fetch_data("blog", "post-by-slug",
params={"slug": page_slug}, required=False)
if post and post.get("is_page"):
page_post_id = post["id"]
# --- product cart ---
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
@@ -75,37 +77,34 @@ class SqlCartService:
if ci.product and (ci.product.special_price or ci.product.regular_price)
)
# --- calendar entries ---
from shared.services.registry import services
# --- calendar entries via events data endpoint ---
cal_params: dict = {}
if user_id is not None:
cal_params["user_id"] = user_id
if session_id is not None:
cal_params["session_id"] = session_id
if page_post_id is not None:
cal_entries = await services.calendar.entries_for_page(
session, page_post_id,
user_id=user_id,
session_id=session_id,
)
cal_params["page_id"] = page_post_id
raw_entries = await fetch_data("events", "entries-for-page",
params=cal_params, required=False) or []
else:
cal_entries = await services.calendar.pending_entries(
session,
user_id=user_id,
session_id=session_id,
)
raw_entries = await fetch_data("events", "pending-entries",
params=cal_params, required=False) or []
cal_entries = [dto_from_dict(CalendarEntryDTO, e) for e in raw_entries]
calendar_count = len(cal_entries)
calendar_total = sum(Decimal(str(e.cost or 0)) for e in cal_entries if e.cost is not None)
# --- tickets ---
# --- tickets via events data endpoint ---
if page_post_id is not None:
tickets = await services.calendar.tickets_for_page(
session, page_post_id,
user_id=user_id,
session_id=session_id,
)
raw_tickets = await fetch_data("events", "tickets-for-page",
params=cal_params, required=False) or []
else:
tickets = await services.calendar.pending_tickets(
session,
user_id=user_id,
session_id=session_id,
)
tk_params = {k: v for k, v in cal_params.items() if k != "page_id"}
raw_tickets = await fetch_data("events", "pending-tickets",
params=tk_params, required=False) or []
tickets = [dto_from_dict(TicketDTO, t) for t in raw_tickets]
ticket_count = len(tickets)
ticket_total = sum(Decimal(str(t.price or 0)) for t in tickets)

View File

@@ -1,18 +1,17 @@
"""Typed singleton registry for domain services.
Each app registers ONLY its own domain service. Cross-app calls go
over HTTP via ``call_action()`` (writes) and ``fetch_data()`` (reads).
Usage::
from shared.services.registry import services
# Register at app startup
# Register at app startup (own domain only)
services.blog = SqlBlogService()
# Query anywhere
if services.has("calendar"):
entries = await services.calendar.pending_entries(session, ...)
# Or use stubs for absent domains
summary = await services.cart.cart_summary(session, ...)
# Use locally within the owning app
post = await services.blog.get_post_by_slug(session, slug)
"""
from __future__ import annotations

View File

@@ -1,204 +1,15 @@
"""No-op stub services for absent domains.
"""No-op stub services.
When an app starts without a particular domain, it registers the stub
so that ``services.X.method()`` returns empty/None rather than crashing.
Cross-app calls now go over HTTP via call_action() / fetch_data().
Stubs are no longer needed for the 4 main domains (blog, calendar,
market, cart). Only StubFederationService remains as a safety net
for apps that conditionally load AP infrastructure.
"""
from __future__ import annotations
from decimal import Decimal
from sqlalchemy.ext.asyncio import AsyncSession
from shared.contracts.dtos import (
PostDTO,
CalendarDTO,
CalendarEntryDTO,
TicketDTO,
MarketPlaceDTO,
ProductDTO,
CartItemDTO,
CartSummaryDTO,
ActorProfileDTO,
APActivityDTO,
APFollowerDTO,
)
class StubBlogService:
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
return None
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
return None
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
return []
async def search_posts(self, session, query, page=1, per_page=10):
return [], 0
class StubCalendarService:
async def calendars_for_container(
self, session: AsyncSession, container_type: str, container_id: int,
) -> list[CalendarDTO]:
return []
async def pending_entries(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[CalendarEntryDTO]:
return []
async def entries_for_page(
self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None,
) -> list[CalendarEntryDTO]:
return []
async def entry_by_id(self, session: AsyncSession, entry_id: int) -> CalendarEntryDTO | None:
return None
async def associated_entries(
self, session: AsyncSession, content_type: str, content_id: int, page: int,
) -> tuple[list[CalendarEntryDTO], bool]:
return [], False
async def toggle_entry_post(
self, session: AsyncSession, entry_id: int, content_type: str, content_id: int,
) -> bool:
return False
async def adopt_entries_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
pass
async def claim_entries_for_order(
self, session: AsyncSession, order_id: int, user_id: int | None,
session_id: str | None, page_post_id: int | None,
) -> None:
pass
async def confirm_entries_for_order(
self, session: AsyncSession, order_id: int, user_id: int | None,
session_id: str | None,
) -> None:
pass
async def get_entries_for_order(
self, session: AsyncSession, order_id: int,
) -> list[CalendarEntryDTO]:
return []
async def user_tickets(
self, session: AsyncSession, *, user_id: int,
) -> list[TicketDTO]:
return []
async def user_bookings(
self, session: AsyncSession, *, user_id: int,
) -> list[CalendarEntryDTO]:
return []
async def confirmed_entries_for_posts(
self, session: AsyncSession, post_ids: list[int],
) -> dict[int, list[CalendarEntryDTO]]:
return {}
async def pending_tickets(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[TicketDTO]:
return []
async def tickets_for_page(
self, session: AsyncSession, page_id: int, *, user_id: int | None, session_id: str | None,
) -> list[TicketDTO]:
return []
async def claim_tickets_for_order(
self, session: AsyncSession, order_id: int, user_id: int | None,
session_id: str | None, page_post_id: int | None,
) -> None:
pass
async def confirm_tickets_for_order(
self, session: AsyncSession, order_id: int,
) -> None:
pass
async def get_tickets_for_order(
self, session: AsyncSession, order_id: int,
) -> list[TicketDTO]:
return []
async def adopt_tickets_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
pass
async def adjust_ticket_quantity(
self, session, entry_id, count, *, user_id, session_id, ticket_type_id=None,
) -> int:
return 0
async def upcoming_entries_for_container(self, session, container_type, container_id, *, page=1, per_page=20):
return [], False
async def entry_ids_for_content(self, session, content_type, content_id):
return set()
async def visible_entries_for_period(self, session, calendar_id, period_start, period_end, *, user_id, is_admin, session_id):
return []
class StubMarketService:
async def marketplaces_for_container(
self, session: AsyncSession, container_type: str, container_id: int,
) -> list[MarketPlaceDTO]:
return []
async def list_marketplaces(
self, session: AsyncSession,
container_type: str | None = None, container_id: int | None = None,
*, page: int = 1, per_page: int = 20,
) -> tuple[list[MarketPlaceDTO], bool]:
return [], False
async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None:
return None
async def create_marketplace(
self, session: AsyncSession, container_type: str, container_id: int,
name: str, slug: str,
) -> MarketPlaceDTO:
raise RuntimeError("MarketService not available")
async def soft_delete_marketplace(
self, session: AsyncSession, container_type: str, container_id: int,
slug: str,
) -> bool:
return False
class StubCartService:
async def cart_summary(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
page_slug: str | None = None,
) -> CartSummaryDTO:
return CartSummaryDTO()
async def cart_items(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[CartItemDTO]:
return []
async def adopt_cart_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
pass
class StubFederationService:
"""No-op federation stub for apps that don't own federation."""
"""No-op federation stub for apps that don't load AP."""
async def get_actor_by_username(self, session, username):
return None
@@ -206,109 +17,16 @@ class StubFederationService:
async def get_actor_by_user_id(self, session, user_id):
return None
async def create_actor(self, session, user_id, preferred_username,
display_name=None, summary=None):
raise RuntimeError("FederationService not available")
async def username_available(self, session, username):
return False
async def publish_activity(self, session, *, actor_user_id, activity_type,
object_type, object_data, source_type=None,
source_id=None):
return None
async def get_activity(self, session, activity_id):
return None
async def get_outbox(self, session, username, page=1, per_page=20, origin_app=None):
return [], 0
async def get_activity_for_source(self, session, source_type, source_id):
return None
async def count_activities_for_source(self, session, source_type, source_id, *, activity_type):
return 0
async def get_followers(self, session, username, app_domain=None):
return []
async def add_follower(self, session, username, follower_acct, follower_inbox,
follower_actor_url, follower_public_key=None,
app_domain="federation"):
raise RuntimeError("FederationService not available")
async def remove_follower(self, session, username, follower_acct, app_domain="federation"):
return False
async def get_or_fetch_remote_actor(self, session, actor_url):
return None
async def search_remote_actor(self, session, acct):
return None
async def search_actors(self, session, query, page=1, limit=20):
return [], 0
async def send_follow(self, session, local_username, remote_actor_url):
raise RuntimeError("FederationService not available")
async def get_following(self, session, username, page=1, per_page=20):
return [], 0
async def get_followers_paginated(self, session, username, page=1, per_page=20):
return [], 0
async def accept_follow_response(self, session, local_username, remote_actor_url):
pass
async def unfollow(self, session, local_username, remote_actor_url):
pass
async def ingest_remote_post(self, session, remote_actor_id, activity_json, object_json):
pass
async def delete_remote_post(self, session, object_id):
pass
async def get_remote_post(self, session, object_id):
return None
async def get_home_timeline(self, session, actor_profile_id, before=None, limit=20):
return []
async def get_public_timeline(self, session, before=None, limit=20):
return []
async def get_actor_timeline(self, session, remote_actor_id, before=None, limit=20):
return []
async def create_local_post(self, session, actor_profile_id, content, visibility="public", in_reply_to=None):
raise RuntimeError("FederationService not available")
async def delete_local_post(self, session, actor_profile_id, post_id):
raise RuntimeError("FederationService not available")
async def like_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def unlike_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def boost_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def unboost_post(self, session, actor_profile_id, object_id, author_inbox):
pass
async def get_notifications(self, session, actor_profile_id, before=None, limit=20):
return []
async def unread_notification_count(self, session, actor_profile_id):
return 0
async def mark_notifications_read(self, session, actor_profile_id):
pass
async def get_stats(self, session):
return {"actors": 0, "activities": 0, "followers": 0}