Files
rose-ash/shared/contracts/dtos.py
giles 7ccb463a8b Wire sx_content through full read/write pipeline
Model: add sx_content column to Post. Writer: accept sx_content in
create_post, create_page, update_post. Routes: read sx_content from form
data in new post, new page, and edit routes. Read pipeline: ghost_db
includes sx_content in public dict, detail/home views prefer sx_content
over html when available, PostDTO includes sx_content.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 23:22:30 +00:00

321 lines
8.8 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
sx_content: 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
app_domain: str | None = None