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>
319 lines
8.7 KiB
Python
319 lines
8.7 KiB
Python
"""Frozen dataclasses for cross-domain data transfer.
|
|
|
|
These are the *only* shapes that cross domain boundaries. Consumers never
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class PostDTO:
|
|
id: int
|
|
slug: str
|
|
title: str
|
|
status: str
|
|
visibility: str
|
|
is_page: bool = False
|
|
feature_image: str | None = None
|
|
html: str | None = None
|
|
excerpt: str | None = None
|
|
custom_excerpt: str | None = None
|
|
published_at: datetime | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Calendar / Events domain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CalendarDTO:
|
|
id: int
|
|
container_type: str
|
|
container_id: int
|
|
name: str
|
|
slug: str
|
|
description: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TicketDTO:
|
|
id: int
|
|
code: str
|
|
state: str
|
|
entry_name: str
|
|
entry_start_at: datetime
|
|
entry_end_at: datetime | None = None
|
|
ticket_type_name: str | None = None
|
|
calendar_name: str | None = None
|
|
created_at: datetime | None = None
|
|
checked_in_at: datetime | None = None
|
|
entry_id: int | None = None
|
|
ticket_type_id: int | None = None
|
|
price: Decimal | None = None
|
|
order_id: int | None = None
|
|
calendar_container_id: int | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CalendarEntryDTO:
|
|
id: int
|
|
calendar_id: int
|
|
name: str
|
|
start_at: datetime
|
|
state: str
|
|
cost: Decimal
|
|
end_at: datetime | None = None
|
|
user_id: int | None = None
|
|
session_id: str | None = None
|
|
order_id: int | None = None
|
|
slot_id: int | None = None
|
|
ticket_price: Decimal | None = None
|
|
ticket_count: int | None = None
|
|
calendar_name: str | None = None
|
|
calendar_slug: str | None = None
|
|
calendar_container_id: int | None = None
|
|
calendar_container_type: str | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Market domain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class MarketPlaceDTO:
|
|
id: int
|
|
container_type: str
|
|
container_id: int
|
|
name: str
|
|
slug: str
|
|
description: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ProductDTO:
|
|
id: int
|
|
slug: str
|
|
title: str | None = None
|
|
image: str | None = None
|
|
description_short: str | None = None
|
|
rrp: Decimal | None = None
|
|
regular_price: Decimal | None = None
|
|
special_price: Decimal | None = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cart domain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CartItemDTO:
|
|
id: int
|
|
product_id: int
|
|
quantity: int
|
|
product_title: str | None = None
|
|
product_slug: str | None = None
|
|
product_image: str | None = None
|
|
unit_price: Decimal | None = None
|
|
market_place_id: int | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CartSummaryDTO:
|
|
count: int = 0
|
|
total: Decimal = Decimal("0")
|
|
calendar_count: int = 0
|
|
calendar_total: Decimal = Decimal("0")
|
|
items: list[CartItemDTO] = field(default_factory=list)
|
|
ticket_count: int = 0
|
|
ticket_total: Decimal = Decimal("0")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Federation / ActivityPub domain
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ActorProfileDTO:
|
|
id: int
|
|
user_id: int
|
|
preferred_username: str
|
|
public_key_pem: str
|
|
display_name: str | None = None
|
|
summary: str | None = None
|
|
inbox_url: str | None = None
|
|
outbox_url: str | None = None
|
|
created_at: datetime | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class APActivityDTO:
|
|
id: int
|
|
activity_id: str
|
|
activity_type: str
|
|
actor_profile_id: int
|
|
object_type: str | None = None
|
|
object_data: dict | None = None
|
|
published: datetime | None = None
|
|
is_local: bool = True
|
|
source_type: str | None = None
|
|
source_id: int | None = None
|
|
ipfs_cid: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class APFollowerDTO:
|
|
id: int
|
|
actor_profile_id: int
|
|
follower_acct: str
|
|
follower_inbox: str
|
|
follower_actor_url: str
|
|
created_at: datetime | None = None
|
|
app_domain: str = "federation"
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class APAnchorDTO:
|
|
id: int
|
|
merkle_root: str
|
|
activity_count: int = 0
|
|
tree_ipfs_cid: str | None = None
|
|
ots_proof_cid: str | None = None
|
|
confirmed_at: datetime | None = None
|
|
bitcoin_txid: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RemoteActorDTO:
|
|
id: int
|
|
actor_url: str
|
|
inbox_url: str
|
|
preferred_username: str
|
|
domain: str
|
|
display_name: str | None = None
|
|
summary: str | None = None
|
|
icon_url: str | None = None
|
|
shared_inbox_url: str | None = None
|
|
public_key_pem: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class RemotePostDTO:
|
|
id: int
|
|
remote_actor_id: int
|
|
object_id: str
|
|
content: str
|
|
summary: str | None = None
|
|
url: str | None = None
|
|
attachments: list[dict] = field(default_factory=list)
|
|
tags: list[dict] = field(default_factory=list)
|
|
published: datetime | None = None
|
|
actor: RemoteActorDTO | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TimelineItemDTO:
|
|
id: str # composite key for cursor pagination
|
|
post_type: str # "local" | "remote" | "boost"
|
|
content: str # HTML
|
|
published: datetime
|
|
actor_name: str
|
|
actor_username: str
|
|
object_id: str | None = None
|
|
summary: str | None = None
|
|
url: str | None = None
|
|
attachments: list[dict] = field(default_factory=list)
|
|
tags: list[dict] = field(default_factory=list)
|
|
actor_domain: str | None = None # None = local
|
|
actor_icon: str | None = None
|
|
actor_url: str | None = None
|
|
boosted_by: str | None = None
|
|
like_count: int = 0
|
|
boost_count: int = 0
|
|
liked_by_me: bool = False
|
|
boosted_by_me: bool = False
|
|
author_inbox: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class NotificationDTO:
|
|
id: int
|
|
notification_type: str # follow/like/boost/mention/reply
|
|
from_actor_name: str
|
|
from_actor_username: str
|
|
created_at: datetime
|
|
read: bool
|
|
from_actor_domain: str | None = None
|
|
from_actor_icon: str | None = None
|
|
target_content_preview: str | None = None
|