Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s

Combines shared, blog, market, cart, events, federation, and account
into a single repository. Eliminates submodule sync, sibling model
copying at build time, and per-app CI orchestration.

Changes:
- Remove per-app .git, .gitmodules, .gitea, submodule shared/ dirs
- Remove stale sibling model copies from each app
- Update all 6 Dockerfiles for monorepo build context (root = .)
- Add build directives to docker-compose.yml
- Add single .gitea/workflows/ci.yml with change detection
- Add .dockerignore for monorepo build context
- Create __init__.py for federation and account (cross-app imports)
This commit is contained in:
giles
2026-02-24 19:44:17 +00:00
commit f42042ccb7
895 changed files with 61147 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""Domain service implementations and registry."""
from .registry import services
__all__ = ["services"]

View File

@@ -0,0 +1,65 @@
"""SQL-backed BlogService implementation.
Queries ``shared.models.ghost_content.Post`` — only this module may read
blog-domain tables on behalf of other domains.
"""
from __future__ import annotations
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.ghost_content import Post
from shared.contracts.dtos import PostDTO
def _post_to_dto(post: Post) -> PostDTO:
return PostDTO(
id=post.id,
slug=post.slug,
title=post.title,
status=post.status,
visibility=post.visibility,
is_page=post.is_page,
feature_image=post.feature_image,
html=post.html,
excerpt=post.excerpt,
custom_excerpt=post.custom_excerpt,
published_at=post.published_at,
)
class SqlBlogService:
async def get_post_by_slug(self, session: AsyncSession, slug: str) -> PostDTO | None:
post = (
await session.execute(select(Post).where(Post.slug == slug))
).scalar_one_or_none()
return _post_to_dto(post) if post else None
async def get_post_by_id(self, session: AsyncSession, id: int) -> PostDTO | None:
post = (
await session.execute(select(Post).where(Post.id == id))
).scalar_one_or_none()
return _post_to_dto(post) if post else None
async def get_posts_by_ids(self, session: AsyncSession, ids: list[int]) -> list[PostDTO]:
if not ids:
return []
result = await session.execute(select(Post).where(Post.id.in_(ids)))
return [_post_to_dto(p) for p in result.scalars().all()]
async def search_posts(
self, session: AsyncSession, query: str, page: int = 1, per_page: int = 10,
) -> tuple[list[PostDTO], int]:
"""Search posts by title with pagination. Not part of the Protocol
(admin-only use in events), but provided for convenience."""
if query:
count_stmt = select(func.count(Post.id)).where(Post.title.ilike(f"%{query}%"))
posts_stmt = select(Post).where(Post.title.ilike(f"%{query}%")).order_by(Post.title)
else:
count_stmt = select(func.count(Post.id))
posts_stmt = select(Post).order_by(Post.published_at.desc().nullslast())
total = (await session.execute(count_stmt)).scalar() or 0
offset = (page - 1) * per_page
result = await session.execute(posts_stmt.limit(per_page).offset(offset))
return [_post_to_dto(p) for p in result.scalars().all()], total

View File

@@ -0,0 +1,669 @@
"""SQL-backed CalendarService implementation.
Queries ``shared.models.calendars.*`` — only this module may write to
calendar-domain tables on behalf of other domains.
"""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from shared.models.calendars import Calendar, CalendarEntry, CalendarEntryPost, Ticket
from shared.contracts.dtos import CalendarDTO, CalendarEntryDTO, TicketDTO
def _cal_to_dto(cal: Calendar) -> CalendarDTO:
return CalendarDTO(
id=cal.id,
container_type=cal.container_type,
container_id=cal.container_id,
name=cal.name,
slug=cal.slug,
description=cal.description,
)
def _entry_to_dto(entry: CalendarEntry) -> CalendarEntryDTO:
cal = getattr(entry, "calendar", None)
return CalendarEntryDTO(
id=entry.id,
calendar_id=entry.calendar_id,
name=entry.name,
start_at=entry.start_at,
state=entry.state,
cost=entry.cost,
end_at=entry.end_at,
user_id=entry.user_id,
session_id=entry.session_id,
order_id=entry.order_id,
slot_id=entry.slot_id,
ticket_price=entry.ticket_price,
ticket_count=entry.ticket_count,
calendar_name=cal.name if cal else None,
calendar_slug=cal.slug if cal else None,
calendar_container_id=cal.container_id if cal else None,
calendar_container_type=cal.container_type if cal else None,
)
def _ticket_to_dto(ticket: Ticket) -> TicketDTO:
entry = getattr(ticket, "entry", None)
tt = getattr(ticket, "ticket_type", None)
cal = getattr(entry, "calendar", None) if entry else None
# Price: ticket type cost if available, else entry ticket_price
price = None
if tt and tt.cost is not None:
price = tt.cost
elif entry and entry.ticket_price is not None:
price = entry.ticket_price
return TicketDTO(
id=ticket.id,
code=ticket.code,
state=ticket.state,
entry_name=entry.name if entry else "",
entry_start_at=entry.start_at if entry else ticket.created_at,
entry_end_at=entry.end_at if entry else None,
ticket_type_name=tt.name if tt else None,
calendar_name=cal.name if cal else None,
created_at=ticket.created_at,
checked_in_at=ticket.checked_in_at,
entry_id=entry.id if entry else None,
ticket_type_id=ticket.ticket_type_id,
price=price,
order_id=ticket.order_id,
calendar_container_id=cal.container_id if cal else None,
)
class SqlCalendarService:
# -- reads ----------------------------------------------------------------
async def calendars_for_container(
self, session: AsyncSession, container_type: str, container_id: int,
) -> list[CalendarDTO]:
result = await session.execute(
select(Calendar).where(
Calendar.container_type == container_type,
Calendar.container_id == container_id,
Calendar.deleted_at.is_(None),
).order_by(Calendar.name.asc())
)
return [_cal_to_dto(c) for c in result.scalars().all()]
async def pending_entries(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[CalendarEntryDTO]:
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id is not None:
filters.append(CalendarEntry.session_id == session_id)
else:
return []
result = await session.execute(
select(CalendarEntry)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(selectinload(CalendarEntry.calendar))
)
return [_entry_to_dto(e) for e in result.scalars().all()]
async def entries_for_page(
self, session: AsyncSession, page_id: int, *,
user_id: int | None, session_id: str | None,
) -> list[CalendarEntryDTO]:
cal_ids = select(Calendar.id).where(
Calendar.container_type == "page",
Calendar.container_id == page_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
CalendarEntry.calendar_id.in_(cal_ids),
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id is not None:
filters.append(CalendarEntry.session_id == session_id)
else:
return []
result = await session.execute(
select(CalendarEntry)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.options(selectinload(CalendarEntry.calendar))
)
return [_entry_to_dto(e) for e in result.scalars().all()]
async def entry_by_id(self, session: AsyncSession, entry_id: int) -> CalendarEntryDTO | None:
entry = (
await session.execute(
select(CalendarEntry)
.where(CalendarEntry.id == entry_id, CalendarEntry.deleted_at.is_(None))
.options(selectinload(CalendarEntry.calendar))
)
).scalar_one_or_none()
return _entry_to_dto(entry) if entry else None
async def entry_ids_for_content(
self, session: AsyncSession, content_type: str, content_id: int,
) -> set[int]:
"""Get entry IDs associated with a content item (e.g. post)."""
result = await session.execute(
select(CalendarEntryPost.entry_id).where(
CalendarEntryPost.content_type == content_type,
CalendarEntryPost.content_id == content_id,
CalendarEntryPost.deleted_at.is_(None),
)
)
return set(result.scalars().all())
async def visible_entries_for_period(
self, session: AsyncSession, calendar_id: int,
period_start: datetime, period_end: datetime,
*, user_id: int | None, is_admin: bool, session_id: str | None,
) -> list[CalendarEntryDTO]:
"""Return visible entries for a calendar in a date range.
Visibility rules:
- Everyone sees confirmed entries.
- Current user/session sees their own entries (any state).
- Admins also see ordered + provisional entries for all users.
"""
# User/session entries (any state)
user_entries: list[CalendarEntry] = []
if user_id or session_id:
conditions = [
CalendarEntry.calendar_id == calendar_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= period_start,
CalendarEntry.start_at < period_end,
]
if user_id:
conditions.append(CalendarEntry.user_id == user_id)
elif session_id:
conditions.append(CalendarEntry.session_id == session_id)
result = await session.execute(
select(CalendarEntry).where(*conditions)
.options(selectinload(CalendarEntry.calendar))
)
user_entries = list(result.scalars().all())
# Confirmed entries for everyone
result = await session.execute(
select(CalendarEntry).where(
CalendarEntry.calendar_id == calendar_id,
CalendarEntry.state == "confirmed",
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= period_start,
CalendarEntry.start_at < period_end,
).options(selectinload(CalendarEntry.calendar))
)
confirmed_entries = list(result.scalars().all())
# Admin: ordered + provisional for everyone
admin_entries: list[CalendarEntry] = []
if is_admin:
result = await session.execute(
select(CalendarEntry).where(
CalendarEntry.calendar_id == calendar_id,
CalendarEntry.state.in_(("ordered", "provisional")),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= period_start,
CalendarEntry.start_at < period_end,
).options(selectinload(CalendarEntry.calendar))
)
admin_entries = list(result.scalars().all())
# Merge, deduplicate, sort
entries_by_id: dict[int, CalendarEntry] = {}
for e in confirmed_entries:
entries_by_id[e.id] = e
for e in admin_entries:
entries_by_id[e.id] = e
for e in user_entries:
entries_by_id[e.id] = e
merged = sorted(entries_by_id.values(), key=lambda e: e.start_at or period_start)
return [_entry_to_dto(e) for e in merged]
async def upcoming_entries_for_container(
self, session: AsyncSession,
container_type: str | None = None, container_id: int | None = None,
*, page: int = 1, per_page: int = 20,
) -> tuple[list[CalendarEntryDTO], bool]:
"""Upcoming confirmed entries. Optionally scoped to a container."""
filters = [
CalendarEntry.state == "confirmed",
CalendarEntry.deleted_at.is_(None),
CalendarEntry.start_at >= func.now(),
]
if container_type is not None and container_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.container_type == container_type,
Calendar.container_id == container_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
filters.append(CalendarEntry.calendar_id.in_(cal_ids))
else:
# Still exclude entries from deleted calendars
cal_ids = select(Calendar.id).where(
Calendar.deleted_at.is_(None),
).scalar_subquery()
filters.append(CalendarEntry.calendar_id.in_(cal_ids))
offset = (page - 1) * per_page
result = await session.execute(
select(CalendarEntry)
.where(*filters)
.order_by(CalendarEntry.start_at.asc())
.limit(per_page)
.offset(offset)
.options(selectinload(CalendarEntry.calendar))
)
entries = result.scalars().all()
has_more = len(entries) == per_page
return [_entry_to_dto(e) for e in entries], has_more
async def associated_entries(
self, session: AsyncSession, content_type: str, content_id: int, page: int,
) -> tuple[list[CalendarEntryDTO], bool]:
"""Get paginated confirmed entries associated with a content item."""
per_page = 10
entry_ids_result = await session.execute(
select(CalendarEntryPost.entry_id).where(
CalendarEntryPost.content_type == content_type,
CalendarEntryPost.content_id == content_id,
CalendarEntryPost.deleted_at.is_(None),
)
)
entry_ids = set(entry_ids_result.scalars().all())
if not entry_ids:
return [], False
offset = (page - 1) * per_page
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.id.in_(entry_ids),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed",
)
.order_by(CalendarEntry.start_at.desc())
.limit(per_page)
.offset(offset)
.options(selectinload(CalendarEntry.calendar))
)
entries = result.scalars().all()
has_more = len(entries) == per_page
return [_entry_to_dto(e) for e in entries], has_more
async def toggle_entry_post(
self, session: AsyncSession, entry_id: int, content_type: str, content_id: int,
) -> bool:
"""Toggle association; returns True if now associated, False if removed."""
existing = await session.scalar(
select(CalendarEntryPost).where(
CalendarEntryPost.entry_id == entry_id,
CalendarEntryPost.content_type == content_type,
CalendarEntryPost.content_id == content_id,
CalendarEntryPost.deleted_at.is_(None),
)
)
if existing:
existing.deleted_at = func.now()
await session.flush()
return False
else:
assoc = CalendarEntryPost(
entry_id=entry_id,
content_type=content_type,
content_id=content_id,
)
session.add(assoc)
await session.flush()
return True
async def get_entries_for_order(
self, session: AsyncSession, order_id: int,
) -> list[CalendarEntryDTO]:
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.order_id == order_id,
CalendarEntry.deleted_at.is_(None),
)
.options(selectinload(CalendarEntry.calendar))
)
return [_entry_to_dto(e) for e in result.scalars().all()]
async def user_tickets(
self, session: AsyncSession, *, user_id: int,
) -> list[TicketDTO]:
result = await session.execute(
select(Ticket)
.where(
Ticket.user_id == user_id,
Ticket.state != "cancelled",
)
.order_by(Ticket.created_at.desc())
.options(
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
)
)
return [_ticket_to_dto(t) for t in result.scalars().all()]
async def user_bookings(
self, session: AsyncSession, *, user_id: int,
) -> list[CalendarEntryDTO]:
result = await session.execute(
select(CalendarEntry)
.where(
CalendarEntry.user_id == user_id,
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state.in_(("ordered", "provisional", "confirmed")),
)
.order_by(CalendarEntry.start_at.desc())
.options(selectinload(CalendarEntry.calendar))
)
return [_entry_to_dto(e) for e in result.scalars().all()]
# -- batch reads (not in protocol — convenience for blog service) ---------
async def confirmed_entries_for_posts(
self, session: AsyncSession, post_ids: list[int],
) -> dict[int, list[CalendarEntryDTO]]:
"""Return confirmed entries grouped by post_id for a batch of posts."""
if not post_ids:
return {}
result = await session.execute(
select(CalendarEntry, CalendarEntryPost.content_id)
.join(CalendarEntryPost, CalendarEntry.id == CalendarEntryPost.entry_id)
.options(selectinload(CalendarEntry.calendar))
.where(
CalendarEntryPost.content_type == "post",
CalendarEntryPost.content_id.in_(post_ids),
CalendarEntryPost.deleted_at.is_(None),
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "confirmed",
)
.order_by(CalendarEntry.start_at.asc())
)
entries_by_post: dict[int, list[CalendarEntryDTO]] = {}
for entry, post_id in result:
entries_by_post.setdefault(post_id, []).append(_entry_to_dto(entry))
return entries_by_post
# -- writes ---------------------------------------------------------------
async def adopt_entries_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
"""Adopt anonymous calendar entries for a logged-in user.
Only deletes stale *pending* entries for the user — confirmed/ordered
entries must be preserved.
"""
await session.execute(
update(CalendarEntry)
.where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.user_id == user_id,
CalendarEntry.state == "pending",
)
.values(deleted_at=func.now())
)
cal_result = await session.execute(
select(CalendarEntry).where(
CalendarEntry.deleted_at.is_(None),
CalendarEntry.session_id == session_id,
)
)
for entry in cal_result.scalars().all():
entry.user_id = user_id
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:
"""Mark pending CalendarEntries as 'ordered' and set order_id."""
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "pending",
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id is not None:
filters.append(CalendarEntry.session_id == session_id)
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.container_type == "page",
Calendar.container_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
filters.append(CalendarEntry.calendar_id.in_(cal_ids))
await session.execute(
update(CalendarEntry)
.where(*filters)
.values(state="ordered", order_id=order_id)
)
async def confirm_entries_for_order(
self, session: AsyncSession, order_id: int, user_id: int | None,
session_id: str | None,
) -> None:
"""Mark ordered CalendarEntries as 'provisional'."""
filters = [
CalendarEntry.deleted_at.is_(None),
CalendarEntry.state == "ordered",
CalendarEntry.order_id == order_id,
]
if user_id is not None:
filters.append(CalendarEntry.user_id == user_id)
elif session_id is not None:
filters.append(CalendarEntry.session_id == session_id)
await session.execute(
update(CalendarEntry)
.where(*filters)
.values(state="provisional")
)
# -- ticket methods -------------------------------------------------------
def _ticket_query_options(self):
return [
selectinload(Ticket.entry).selectinload(CalendarEntry.calendar),
selectinload(Ticket.ticket_type),
]
async def pending_tickets(
self, session: AsyncSession, *, user_id: int | None, session_id: str | None,
) -> list[TicketDTO]:
"""Reserved tickets for the given identity (cart line items)."""
filters = [Ticket.state == "reserved"]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return []
result = await session.execute(
select(Ticket)
.where(*filters)
.order_by(Ticket.created_at.asc())
.options(*self._ticket_query_options())
)
return [_ticket_to_dto(t) for t in result.scalars().all()]
async def tickets_for_page(
self, session: AsyncSession, page_id: int, *,
user_id: int | None, session_id: str | None,
) -> list[TicketDTO]:
"""Reserved tickets scoped to a page (via entry → calendar → container_id)."""
cal_ids = select(Calendar.id).where(
Calendar.container_type == "page",
Calendar.container_id == page_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
entry_ids = select(CalendarEntry.id).where(
CalendarEntry.calendar_id.in_(cal_ids),
CalendarEntry.deleted_at.is_(None),
).scalar_subquery()
filters = [
Ticket.state == "reserved",
Ticket.entry_id.in_(entry_ids),
]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return []
result = await session.execute(
select(Ticket)
.where(*filters)
.order_by(Ticket.created_at.asc())
.options(*self._ticket_query_options())
)
return [_ticket_to_dto(t) for t in result.scalars().all()]
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:
"""Set order_id on reserved tickets at checkout."""
filters = [Ticket.state == "reserved"]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
if page_post_id is not None:
cal_ids = select(Calendar.id).where(
Calendar.container_type == "page",
Calendar.container_id == page_post_id,
Calendar.deleted_at.is_(None),
).scalar_subquery()
entry_ids = select(CalendarEntry.id).where(
CalendarEntry.calendar_id.in_(cal_ids),
CalendarEntry.deleted_at.is_(None),
).scalar_subquery()
filters.append(Ticket.entry_id.in_(entry_ids))
await session.execute(
update(Ticket).where(*filters).values(order_id=order_id)
)
async def confirm_tickets_for_order(
self, session: AsyncSession, order_id: int,
) -> None:
"""Reserved → confirmed on payment."""
await session.execute(
update(Ticket)
.where(Ticket.order_id == order_id, Ticket.state == "reserved")
.values(state="confirmed")
)
async def get_tickets_for_order(
self, session: AsyncSession, order_id: int,
) -> list[TicketDTO]:
"""Tickets for a given order (checkout return display)."""
result = await session.execute(
select(Ticket)
.where(Ticket.order_id == order_id)
.order_by(Ticket.created_at.asc())
.options(*self._ticket_query_options())
)
return [_ticket_to_dto(t) for t in result.scalars().all()]
async def adopt_tickets_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
"""Migrate anonymous reserved tickets to user on login."""
result = await session.execute(
select(Ticket).where(
Ticket.session_id == session_id,
Ticket.state == "reserved",
)
)
for ticket in result.scalars().all():
ticket.user_id = user_id
async def adjust_ticket_quantity(
self, session: AsyncSession, entry_id: int, count: int, *,
user_id: int | None, session_id: str | None,
ticket_type_id: int | None = None,
) -> int:
"""Adjust reserved ticket count to target. Returns new count."""
import uuid
count = max(count, 0)
# Current reserved count
filters = [
Ticket.entry_id == entry_id,
Ticket.state == "reserved",
]
if user_id is not None:
filters.append(Ticket.user_id == user_id)
elif session_id is not None:
filters.append(Ticket.session_id == session_id)
else:
return 0
if ticket_type_id is not None:
filters.append(Ticket.ticket_type_id == ticket_type_id)
current = await session.scalar(
select(func.count(Ticket.id)).where(*filters)
) or 0
if count > current:
# Create tickets
for _ in range(count - current):
ticket = Ticket(
entry_id=entry_id,
ticket_type_id=ticket_type_id,
user_id=user_id,
session_id=session_id,
code=uuid.uuid4().hex,
state="reserved",
)
session.add(ticket)
await session.flush()
elif count < current:
# Cancel newest tickets
to_cancel = current - count
result = await session.execute(
select(Ticket)
.where(*filters)
.order_by(Ticket.created_at.desc())
.limit(to_cancel)
)
for ticket in result.scalars().all():
ticket.state = "cancelled"
await session.flush()
return count

View File

@@ -0,0 +1,162 @@
"""SQL-backed CartService implementation.
Queries ``shared.models.market.CartItem`` — only this module may write
to cart-domain tables on behalf of other domains.
"""
from __future__ import annotations
from decimal import Decimal
from sqlalchemy import select, update, func
from sqlalchemy.ext.asyncio import AsyncSession
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
def _item_to_dto(ci: CartItem) -> CartItemDTO:
product = ci.product
return CartItemDTO(
id=ci.id,
product_id=ci.product_id,
quantity=ci.quantity,
product_title=product.title if product else None,
product_slug=product.slug if product else None,
product_image=product.image if product else None,
unit_price=Decimal(str(product.special_price or product.regular_price or 0)) if product else None,
market_place_id=ci.market_place_id,
)
class SqlCartService:
async def cart_summary(
self, session: AsyncSession, *,
user_id: int | None, session_id: str | None,
page_slug: str | None = None,
) -> CartSummaryDTO:
"""Build a lightweight cart summary for the current identity."""
# Resolve page filter
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
# --- product cart ---
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
if user_id is not None:
cart_q = cart_q.where(CartItem.user_id == user_id)
elif session_id is not None:
cart_q = cart_q.where(CartItem.session_id == session_id)
else:
return CartSummaryDTO()
if page_post_id is not None:
mp_ids = select(MarketPlace.id).where(
MarketPlace.container_type == "page",
MarketPlace.container_id == page_post_id,
MarketPlace.deleted_at.is_(None),
).scalar_subquery()
cart_q = cart_q.where(CartItem.market_place_id.in_(mp_ids))
cart_q = cart_q.options(selectinload(CartItem.product))
result = await session.execute(cart_q)
cart_items = result.scalars().all()
count = sum(ci.quantity for ci in cart_items)
total = sum(
Decimal(str(ci.product.special_price or ci.product.regular_price or 0)) * ci.quantity
for ci in cart_items
if ci.product and (ci.product.special_price or ci.product.regular_price)
)
# --- calendar entries ---
from shared.services.registry import services
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,
)
else:
cal_entries = await services.calendar.pending_entries(
session,
user_id=user_id,
session_id=session_id,
)
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 ---
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,
)
else:
tickets = await services.calendar.pending_tickets(
session,
user_id=user_id,
session_id=session_id,
)
ticket_count = len(tickets)
ticket_total = sum(Decimal(str(t.price or 0)) for t in tickets)
items = [_item_to_dto(ci) for ci in cart_items]
return CartSummaryDTO(
count=count,
total=total,
calendar_count=calendar_count,
calendar_total=calendar_total,
items=items,
ticket_count=ticket_count,
ticket_total=ticket_total,
)
async def cart_items(
self, session: AsyncSession, *,
user_id: int | None, session_id: str | None,
) -> list[CartItemDTO]:
cart_q = select(CartItem).where(CartItem.deleted_at.is_(None))
if user_id is not None:
cart_q = cart_q.where(CartItem.user_id == user_id)
elif session_id is not None:
cart_q = cart_q.where(CartItem.session_id == session_id)
else:
return []
cart_q = cart_q.options(selectinload(CartItem.product)).order_by(CartItem.created_at.desc())
result = await session.execute(cart_q)
return [_item_to_dto(ci) for ci in result.scalars().all()]
async def adopt_cart_for_user(
self, session: AsyncSession, user_id: int, session_id: str,
) -> None:
"""Adopt anonymous cart items for a logged-in user."""
anon_result = await session.execute(
select(CartItem).where(
CartItem.deleted_at.is_(None),
CartItem.user_id.is_(None),
CartItem.session_id == session_id,
)
)
anon_items = anon_result.scalars().all()
if anon_items:
# Soft-delete existing user cart
await session.execute(
update(CartItem)
.where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id)
.values(deleted_at=func.now())
)
for ci in anon_items:
ci.user_id = user_id

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
"""Inline federation publication — called at write time, not via async handler.
The originating service calls try_publish() directly, which creates the
APActivity (with process_state='pending') in the same DB transaction.
The EventProcessor picks it up and the delivery wildcard handler POSTs
to follower inboxes.
"""
from __future__ import annotations
import logging
import os
from sqlalchemy.ext.asyncio import AsyncSession
from shared.services.registry import services
log = logging.getLogger(__name__)
async def try_publish(
session: AsyncSession,
*,
user_id: int | None,
activity_type: str,
object_type: str,
object_data: dict,
source_type: str,
source_id: int,
) -> None:
"""Publish an AP activity if federation is available and user has a profile.
Safe to call from any app — returns silently if federation isn't wired
or the user has no actor profile.
"""
if not services.has("federation"):
return
if not user_id:
return
actor = await services.federation.get_actor_by_user_id(session, user_id)
if not actor:
return
# Dedup: don't re-Create if already published, don't re-Delete if already deleted
existing = await services.federation.get_activity_for_source(
session, source_type, source_id,
)
if existing:
if activity_type == "Create" and existing.activity_type != "Delete":
return # already published (allow re-Create after Delete/unpublish)
if activity_type == "Delete" and existing.activity_type == "Delete":
return # already deleted
elif activity_type in ("Delete", "Update"):
return # never published, nothing to delete/update
# Stable object ID within a publish cycle. After Delete + re-Create
# we append a version suffix so remote servers (Mastodon) treat it as
# a brand-new post rather than ignoring the tombstoned ID.
domain = os.getenv("AP_DOMAIN", "federation.rose-ash.com")
base_object_id = (
f"https://{domain}/users/{actor.preferred_username}"
f"/objects/{source_type.lower()}/{source_id}"
)
if activity_type == "Create" and existing and existing.activity_type == "Delete":
# Count prior Creates to derive a version number
create_count = await services.federation.count_activities_for_source(
session, source_type, source_id, activity_type="Create",
)
object_data["id"] = f"{base_object_id}/v{create_count + 1}"
elif activity_type in ("Update", "Delete") and existing and existing.object_data:
# Use the same object ID as the most recent activity
object_data["id"] = existing.object_data.get("id", base_object_id)
else:
object_data["id"] = base_object_id
try:
await services.federation.publish_activity(
session,
actor_user_id=user_id,
activity_type=activity_type,
object_type=object_type,
object_data=object_data,
source_type=source_type,
source_id=source_id,
)
log.info(
"Published %s/%s for %s#%d by user %d",
activity_type, object_type, source_type, source_id, user_id,
)
except Exception:
log.exception("Failed to publish activity for %s#%d", source_type, source_id)

View File

@@ -0,0 +1,128 @@
"""SQL-backed MarketService implementation.
Queries ``shared.models.market.*`` and ``shared.models.market_place.*`` —
only this module may read market-domain tables on behalf of other domains.
"""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.market import Product
from shared.models.market_place import MarketPlace
from shared.browser.app.utils import utcnow
from shared.contracts.dtos import MarketPlaceDTO, ProductDTO
from shared.services.relationships import attach_child, detach_child
def _mp_to_dto(mp: MarketPlace) -> MarketPlaceDTO:
return MarketPlaceDTO(
id=mp.id,
container_type=mp.container_type,
container_id=mp.container_id,
name=mp.name,
slug=mp.slug,
description=mp.description,
)
def _product_to_dto(p: Product) -> ProductDTO:
return ProductDTO(
id=p.id,
slug=p.slug,
title=p.title,
image=p.image,
description_short=p.description_short,
rrp=p.rrp,
regular_price=p.regular_price,
special_price=p.special_price,
)
class SqlMarketService:
async def marketplaces_for_container(
self, session: AsyncSession, container_type: str, container_id: int,
) -> list[MarketPlaceDTO]:
result = await session.execute(
select(MarketPlace).where(
MarketPlace.container_type == container_type,
MarketPlace.container_id == container_id,
MarketPlace.deleted_at.is_(None),
).order_by(MarketPlace.name.asc())
)
return [_mp_to_dto(mp) for mp in result.scalars().all()]
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]:
stmt = select(MarketPlace).where(MarketPlace.deleted_at.is_(None))
if container_type is not None and container_id is not None:
stmt = stmt.where(
MarketPlace.container_type == container_type,
MarketPlace.container_id == container_id,
)
stmt = stmt.order_by(MarketPlace.name.asc())
stmt = stmt.offset((page - 1) * per_page).limit(per_page + 1)
rows = (await session.execute(stmt)).scalars().all()
has_more = len(rows) > per_page
return [_mp_to_dto(mp) for mp in rows[:per_page]], has_more
async def product_by_id(self, session: AsyncSession, product_id: int) -> ProductDTO | None:
product = (
await session.execute(select(Product).where(Product.id == product_id))
).scalar_one_or_none()
return _product_to_dto(product) if product else None
async def create_marketplace(
self, session: AsyncSession, container_type: str, container_id: int,
name: str, slug: str,
) -> MarketPlaceDTO:
# Look for existing (including soft-deleted)
existing = (await session.execute(
select(MarketPlace).where(
MarketPlace.container_type == container_type,
MarketPlace.container_id == container_id,
MarketPlace.slug == slug,
)
)).scalar_one_or_none()
if existing:
if existing.deleted_at is not None:
existing.deleted_at = None # revive
existing.name = name
await session.flush()
await attach_child(session, container_type, container_id, "market", existing.id)
return _mp_to_dto(existing)
raise ValueError(f'Market with slug "{slug}" already exists for this container.')
market = MarketPlace(
container_type=container_type, container_id=container_id,
name=name, slug=slug,
)
session.add(market)
await session.flush()
await attach_child(session, container_type, container_id, "market", market.id)
return _mp_to_dto(market)
async def soft_delete_marketplace(
self, session: AsyncSession, container_type: str, container_id: int,
slug: str,
) -> bool:
market = (await session.execute(
select(MarketPlace).where(
MarketPlace.container_type == container_type,
MarketPlace.container_id == container_id,
MarketPlace.slug == slug,
MarketPlace.deleted_at.is_(None),
)
)).scalar_one_or_none()
if not market:
return False
market.deleted_at = utcnow()
await session.flush()
await detach_child(session, container_type, container_id, "market", market.id)
return True

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from shared.models.menu_node import MenuNode
async def get_navigation_tree(session: AsyncSession) -> list[MenuNode]:
"""
Return top-level menu nodes ordered by sort_order.
All apps call this directly (shared DB) — no more HTTP API.
"""
result = await session.execute(
select(MenuNode)
.where(MenuNode.deleted_at.is_(None), MenuNode.depth == 0)
.order_by(MenuNode.sort_order.asc(), MenuNode.id.asc())
)
return list(result.scalars().all())
async def rebuild_navigation(session: AsyncSession) -> None:
"""
Rebuild menu_nodes from container_relations.
Called by event handlers when relationships change.
Currently a no-op placeholder — menu nodes are managed directly
by the admin UI. When the full relationship-driven nav is needed,
this will sync ContainerRelation -> MenuNode.
"""
pass

105
shared/services/registry.py Normal file
View File

@@ -0,0 +1,105 @@
"""Typed singleton registry for domain services.
Usage::
from shared.services.registry import services
# Register at app startup
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, ...)
"""
from __future__ import annotations
from shared.contracts.protocols import (
BlogService,
CalendarService,
MarketService,
CartService,
FederationService,
)
class _ServiceRegistry:
"""Central registry holding one implementation per domain.
Properties return the registered implementation or raise
``RuntimeError`` if nothing is registered. Use ``has(name)``
to check before access when the domain might be absent.
"""
def __init__(self) -> None:
self._blog: BlogService | None = None
self._calendar: CalendarService | None = None
self._market: MarketService | None = None
self._cart: CartService | None = None
self._federation: FederationService | None = None
# -- blog -----------------------------------------------------------------
@property
def blog(self) -> BlogService:
if self._blog is None:
raise RuntimeError("BlogService not registered")
return self._blog
@blog.setter
def blog(self, impl: BlogService) -> None:
self._blog = impl
# -- calendar -------------------------------------------------------------
@property
def calendar(self) -> CalendarService:
if self._calendar is None:
raise RuntimeError("CalendarService not registered")
return self._calendar
@calendar.setter
def calendar(self, impl: CalendarService) -> None:
self._calendar = impl
# -- market ---------------------------------------------------------------
@property
def market(self) -> MarketService:
if self._market is None:
raise RuntimeError("MarketService not registered")
return self._market
@market.setter
def market(self, impl: MarketService) -> None:
self._market = impl
# -- cart -----------------------------------------------------------------
@property
def cart(self) -> CartService:
if self._cart is None:
raise RuntimeError("CartService not registered")
return self._cart
@cart.setter
def cart(self, impl: CartService) -> None:
self._cart = impl
# -- federation -----------------------------------------------------------
@property
def federation(self) -> FederationService:
if self._federation is None:
raise RuntimeError("FederationService not registered")
return self._federation
@federation.setter
def federation(self, impl: FederationService) -> None:
self._federation = impl
# -- introspection --------------------------------------------------------
def has(self, name: str) -> bool:
"""Check whether a domain service is registered."""
return getattr(self, f"_{name}", None) is not None
# Module-level singleton — import this everywhere.
services = _ServiceRegistry()

View File

@@ -0,0 +1,161 @@
from __future__ import annotations
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import emit_activity
from shared.models.container_relation import ContainerRelation
async def attach_child(
session: AsyncSession,
parent_type: str,
parent_id: int,
child_type: str,
child_id: int,
label: str | None = None,
sort_order: int | None = None,
) -> ContainerRelation:
"""
Create a ContainerRelation and emit container.child_attached event.
Upsert behaviour: if a relation already exists (including soft-deleted),
revive it instead of inserting a duplicate.
"""
# Check for existing (including soft-deleted)
existing = await session.scalar(
select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
ContainerRelation.child_type == child_type,
ContainerRelation.child_id == child_id,
)
)
if existing:
if existing.deleted_at is not None:
# Revive soft-deleted relation
existing.deleted_at = None
if sort_order is not None:
existing.sort_order = sort_order
if label is not None:
existing.label = label
await session.flush()
await emit_activity(
session,
activity_type="Add",
actor_uri="internal:system",
object_type="rose:ContainerRelation",
object_data={
"parent_type": parent_type,
"parent_id": parent_id,
"child_type": child_type,
"child_id": child_id,
},
source_type="container_relation",
source_id=existing.id,
)
return existing
# Already attached and active — no-op
return existing
if sort_order is None:
max_order = await session.scalar(
select(func.max(ContainerRelation.sort_order)).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
ContainerRelation.deleted_at.is_(None),
)
)
sort_order = (max_order or 0) + 1
rel = ContainerRelation(
parent_type=parent_type,
parent_id=parent_id,
child_type=child_type,
child_id=child_id,
label=label,
sort_order=sort_order,
)
session.add(rel)
await session.flush()
await emit_activity(
session,
activity_type="Add",
actor_uri="internal:system",
object_type="rose:ContainerRelation",
object_data={
"parent_type": parent_type,
"parent_id": parent_id,
"child_type": child_type,
"child_id": child_id,
},
source_type="container_relation",
source_id=rel.id,
)
return rel
async def get_children(
session: AsyncSession,
parent_type: str,
parent_id: int,
child_type: str | None = None,
) -> list[ContainerRelation]:
"""Query children of a container, optionally filtered by child_type."""
stmt = select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
ContainerRelation.deleted_at.is_(None),
)
if child_type is not None:
stmt = stmt.where(ContainerRelation.child_type == child_type)
stmt = stmt.order_by(
ContainerRelation.sort_order.asc(), ContainerRelation.id.asc()
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def detach_child(
session: AsyncSession,
parent_type: str,
parent_id: int,
child_type: str,
child_id: int,
) -> bool:
"""Soft-delete a ContainerRelation and emit container.child_detached event."""
result = await session.execute(
select(ContainerRelation).where(
ContainerRelation.parent_type == parent_type,
ContainerRelation.parent_id == parent_id,
ContainerRelation.child_type == child_type,
ContainerRelation.child_id == child_id,
ContainerRelation.deleted_at.is_(None),
)
)
rel = result.scalar_one_or_none()
if not rel:
return False
rel.deleted_at = func.now()
await session.flush()
await emit_activity(
session,
activity_type="Remove",
actor_uri="internal:system",
object_type="rose:ContainerRelation",
object_data={
"parent_type": parent_type,
"parent_id": parent_id,
"child_type": child_type,
"child_id": child_id,
},
source_type="container_relation",
source_id=rel.id,
)
return True

314
shared/services/stubs.py Normal file
View File

@@ -0,0 +1,314 @@
"""No-op stub services for absent domains.
When an app starts without a particular domain, it registers the stub
so that ``services.X.method()`` returns empty/None rather than crashing.
"""
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."""
async def get_actor_by_username(self, session, username):
return None
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}

View File

@@ -0,0 +1,90 @@
"""Singleton widget registry for cross-domain UI composition.
Usage::
from shared.services.widget_registry import widgets
# Register at app startup (after domain services)
widgets.add_container_nav(NavWidget(...))
# Query in templates / context processors
for w in widgets.container_nav:
ctx = await w.context_fn(session, container_type="page", ...)
"""
from __future__ import annotations
from shared.contracts.widgets import (
NavWidget,
CardWidget,
AccountPageWidget,
AccountNavLink,
)
class _WidgetRegistry:
"""Central registry holding all widget descriptors.
Widgets are added at startup and read at request time.
Properties return sorted-by-order copies.
"""
def __init__(self) -> None:
self._container_nav: list[NavWidget] = []
self._container_card: list[CardWidget] = []
self._account_pages: list[AccountPageWidget] = []
self._account_nav: list[AccountNavLink] = []
# -- registration ---------------------------------------------------------
def add_container_nav(self, w: NavWidget) -> None:
self._container_nav.append(w)
def add_container_card(self, w: CardWidget) -> None:
self._container_card.append(w)
def add_account_page(self, w: AccountPageWidget) -> None:
self._account_pages.append(w)
# Auto-create a matching internal nav link
slug = w.slug
def _href(s=slug):
from shared.infrastructure.urls import account_url
return account_url(f"/{s}/")
self._account_nav.append(AccountNavLink(
label=w.label,
order=w.order,
href_fn=_href,
external=False,
))
def add_account_link(self, link: AccountNavLink) -> None:
self._account_nav.append(link)
# -- read access (sorted copies) ------------------------------------------
@property
def container_nav(self) -> list[NavWidget]:
return sorted(self._container_nav, key=lambda w: w.order)
@property
def container_cards(self) -> list[CardWidget]:
return sorted(self._container_card, key=lambda w: w.order)
@property
def account_pages(self) -> list[AccountPageWidget]:
return sorted(self._account_pages, key=lambda w: w.order)
@property
def account_nav(self) -> list[AccountNavLink]:
return sorted(self._account_nav, key=lambda w: w.order)
def account_page_by_slug(self, slug: str) -> AccountPageWidget | None:
for w in self._account_pages:
if w.slug == slug:
return w
return None
# Module-level singleton — import this everywhere.
widgets = _WidgetRegistry()

View File

@@ -0,0 +1,22 @@
"""Per-domain widget registration.
Called once at startup after domain services are registered.
Only registers widgets for domains that are actually available.
"""
from __future__ import annotations
def register_all_widgets() -> None:
from shared.services.registry import services
if services.has("calendar"):
from .calendar_widgets import register_calendar_widgets
register_calendar_widgets()
if services.has("market"):
from .market_widgets import register_market_widgets
register_market_widgets()
if services.has("cart"):
from .cart_widgets import register_cart_widgets
register_cart_widgets()

View File

@@ -0,0 +1,10 @@
"""Calendar-domain widgets.
All calendar widgets have been replaced by fragments
(events app serves them at /internal/fragments/).
"""
from __future__ import annotations
def register_calendar_widgets() -> None:
pass

View File

@@ -0,0 +1,10 @@
"""Cart-domain widgets.
Account nav link has been replaced by fragments
(cart app serves account-nav-item at /internal/fragments/).
"""
from __future__ import annotations
def register_cart_widgets() -> None:
pass

View File

@@ -0,0 +1,10 @@
"""Market-domain widgets.
Container nav widgets have been replaced by fragments
(market app serves them at /internal/fragments/).
"""
from __future__ import annotations
def register_market_widgets() -> None:
pass