Monorepo: consolidate 7 repos into one
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m5s
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:
5
shared/services/__init__.py
Normal file
5
shared/services/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Domain service implementations and registry."""
|
||||
|
||||
from .registry import services
|
||||
|
||||
__all__ = ["services"]
|
||||
65
shared/services/blog_impl.py
Normal file
65
shared/services/blog_impl.py
Normal 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
|
||||
669
shared/services/calendar_impl.py
Normal file
669
shared/services/calendar_impl.py
Normal 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
|
||||
162
shared/services/cart_impl.py
Normal file
162
shared/services/cart_impl.py
Normal 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
|
||||
1654
shared/services/federation_impl.py
Normal file
1654
shared/services/federation_impl.py
Normal file
File diff suppressed because it is too large
Load Diff
92
shared/services/federation_publish.py
Normal file
92
shared/services/federation_publish.py
Normal 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)
|
||||
128
shared/services/market_impl.py
Normal file
128
shared/services/market_impl.py
Normal 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
|
||||
32
shared/services/navigation.py
Normal file
32
shared/services/navigation.py
Normal 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
105
shared/services/registry.py
Normal 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()
|
||||
161
shared/services/relationships.py
Normal file
161
shared/services/relationships.py
Normal 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
314
shared/services/stubs.py
Normal 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}
|
||||
90
shared/services/widget_registry.py
Normal file
90
shared/services/widget_registry.py
Normal 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()
|
||||
22
shared/services/widgets/__init__.py
Normal file
22
shared/services/widgets/__init__.py
Normal 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()
|
||||
10
shared/services/widgets/calendar_widgets.py
Normal file
10
shared/services/widgets/calendar_widgets.py
Normal 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
|
||||
10
shared/services/widgets/cart_widgets.py
Normal file
10
shared/services/widgets/cart_widgets.py
Normal 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
|
||||
10
shared/services/widgets/market_widgets.py
Normal file
10
shared/services/widgets/market_widgets.py
Normal 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
|
||||
Reference in New Issue
Block a user