Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete. Co-authored-by: Cursor <cursoragent@cursor.com>
342 lines
13 KiB
Python
342 lines
13 KiB
Python
"""Theme & layout engine repositories — Phase 11.3."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Sequence
|
|
from uuid import UUID
|
|
|
|
from sqlalchemy import func, select
|
|
|
|
from app.models.themes import (
|
|
ExperienceLayout,
|
|
ExperienceLayoutVersion,
|
|
ExperienceTheme,
|
|
ExperienceThemeVersion,
|
|
PageLayoutBinding,
|
|
SiteThemeBinding,
|
|
)
|
|
from app.repositories.base import TenantBaseRepository
|
|
from app.specifications.themes import (
|
|
LayoutListSpec,
|
|
LayoutVersionListSpec,
|
|
PageLayoutBindingListSpec,
|
|
SiteThemeBindingListSpec,
|
|
ThemeListSpec,
|
|
ThemeVersionListSpec,
|
|
)
|
|
|
|
|
|
class ExperienceThemeRepository(TenantBaseRepository[ExperienceTheme]):
|
|
model = ExperienceTheme
|
|
|
|
async def get_by_code(
|
|
self, tenant_id: UUID, workspace_id: UUID, code: str
|
|
) -> ExperienceTheme | None:
|
|
stmt = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.workspace_id == workspace_id,
|
|
self.model.code == code,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def list_filtered(
|
|
self,
|
|
tenant_id: UUID,
|
|
*,
|
|
offset: int,
|
|
limit: int,
|
|
spec: ThemeListSpec | None = None,
|
|
) -> tuple[Sequence[ExperienceTheme], int]:
|
|
base = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
count_base = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
if spec is not None:
|
|
base = spec.apply(base)
|
|
for clause in spec.filter_clauses():
|
|
count_base = count_base.where(clause)
|
|
else:
|
|
base = base.order_by(self.model.created_at.desc())
|
|
total = int((await self.session.execute(count_base)).scalar_one())
|
|
result = await self.session.execute(base.offset(offset).limit(limit))
|
|
return result.scalars().all(), total
|
|
|
|
|
|
class ExperienceThemeVersionRepository(TenantBaseRepository[ExperienceThemeVersion]):
|
|
model = ExperienceThemeVersion
|
|
|
|
async def get_by_number(
|
|
self, tenant_id: UUID, theme_id: UUID, version_number: int
|
|
) -> ExperienceThemeVersion | None:
|
|
stmt = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.theme_id == theme_id,
|
|
self.model.version_number == version_number,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def max_version_number(self, tenant_id: UUID, theme_id: UUID) -> int:
|
|
stmt = select(func.max(self.model.version_number)).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.theme_id == theme_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
value = result.scalar_one()
|
|
return int(value or 0)
|
|
|
|
async def list_filtered(
|
|
self,
|
|
tenant_id: UUID,
|
|
*,
|
|
offset: int,
|
|
limit: int,
|
|
spec: ThemeVersionListSpec | None = None,
|
|
) -> tuple[Sequence[ExperienceThemeVersion], int]:
|
|
base = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
count_base = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
if spec is not None:
|
|
base = spec.apply(base)
|
|
for clause in spec.filter_clauses():
|
|
count_base = count_base.where(clause)
|
|
else:
|
|
base = base.order_by(self.model.version_number.desc())
|
|
total = int((await self.session.execute(count_base)).scalar_one())
|
|
result = await self.session.execute(base.offset(offset).limit(limit))
|
|
return result.scalars().all(), total
|
|
|
|
|
|
class ExperienceLayoutRepository(TenantBaseRepository[ExperienceLayout]):
|
|
model = ExperienceLayout
|
|
|
|
async def get_by_code(
|
|
self, tenant_id: UUID, workspace_id: UUID, code: str
|
|
) -> ExperienceLayout | None:
|
|
stmt = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.workspace_id == workspace_id,
|
|
self.model.code == code,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def list_filtered(
|
|
self,
|
|
tenant_id: UUID,
|
|
*,
|
|
offset: int,
|
|
limit: int,
|
|
spec: LayoutListSpec | None = None,
|
|
) -> tuple[Sequence[ExperienceLayout], int]:
|
|
base = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
count_base = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
if spec is not None:
|
|
base = spec.apply(base)
|
|
for clause in spec.filter_clauses():
|
|
count_base = count_base.where(clause)
|
|
else:
|
|
base = base.order_by(self.model.created_at.desc())
|
|
total = int((await self.session.execute(count_base)).scalar_one())
|
|
result = await self.session.execute(base.offset(offset).limit(limit))
|
|
return result.scalars().all(), total
|
|
|
|
|
|
class ExperienceLayoutVersionRepository(TenantBaseRepository[ExperienceLayoutVersion]):
|
|
model = ExperienceLayoutVersion
|
|
|
|
async def get_by_number(
|
|
self, tenant_id: UUID, layout_id: UUID, version_number: int
|
|
) -> ExperienceLayoutVersion | None:
|
|
stmt = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.layout_id == layout_id,
|
|
self.model.version_number == version_number,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def max_version_number(self, tenant_id: UUID, layout_id: UUID) -> int:
|
|
stmt = select(func.max(self.model.version_number)).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.layout_id == layout_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
value = result.scalar_one()
|
|
return int(value or 0)
|
|
|
|
async def list_filtered(
|
|
self,
|
|
tenant_id: UUID,
|
|
*,
|
|
offset: int,
|
|
limit: int,
|
|
spec: LayoutVersionListSpec | None = None,
|
|
) -> tuple[Sequence[ExperienceLayoutVersion], int]:
|
|
base = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
count_base = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
if spec is not None:
|
|
base = spec.apply(base)
|
|
for clause in spec.filter_clauses():
|
|
count_base = count_base.where(clause)
|
|
else:
|
|
base = base.order_by(self.model.version_number.desc())
|
|
total = int((await self.session.execute(count_base)).scalar_one())
|
|
result = await self.session.execute(base.offset(offset).limit(limit))
|
|
return result.scalars().all(), total
|
|
|
|
|
|
class SiteThemeBindingRepository(TenantBaseRepository[SiteThemeBinding]):
|
|
model = SiteThemeBinding
|
|
|
|
async def get_active_for_site(
|
|
self, tenant_id: UUID, site_id: UUID
|
|
) -> SiteThemeBinding | None:
|
|
stmt = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.site_id == site_id,
|
|
self.model.is_active.is_(True),
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def count_active_by_theme(self, tenant_id: UUID, theme_id: UUID) -> int:
|
|
stmt = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.theme_id == theme_id,
|
|
self.model.is_active.is_(True),
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return int(result.scalar_one())
|
|
|
|
async def count_active_by_version(
|
|
self, tenant_id: UUID, theme_version_id: UUID
|
|
) -> int:
|
|
stmt = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.theme_version_id == theme_version_id,
|
|
self.model.is_active.is_(True),
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return int(result.scalar_one())
|
|
|
|
async def list_filtered(
|
|
self,
|
|
tenant_id: UUID,
|
|
*,
|
|
offset: int,
|
|
limit: int,
|
|
spec: SiteThemeBindingListSpec | None = None,
|
|
) -> tuple[Sequence[SiteThemeBinding], int]:
|
|
base = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
count_base = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
if spec is not None:
|
|
base = spec.apply(base)
|
|
for clause in spec.filter_clauses():
|
|
count_base = count_base.where(clause)
|
|
else:
|
|
base = base.order_by(self.model.created_at.desc())
|
|
total = int((await self.session.execute(count_base)).scalar_one())
|
|
result = await self.session.execute(base.offset(offset).limit(limit))
|
|
return result.scalars().all(), total
|
|
|
|
|
|
class PageLayoutBindingRepository(TenantBaseRepository[PageLayoutBinding]):
|
|
model = PageLayoutBinding
|
|
|
|
async def get_active_for_page(
|
|
self, tenant_id: UUID, page_id: UUID
|
|
) -> PageLayoutBinding | None:
|
|
stmt = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.page_id == page_id,
|
|
self.model.is_active.is_(True),
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return result.scalar_one_or_none()
|
|
|
|
async def count_active_by_layout(self, tenant_id: UUID, layout_id: UUID) -> int:
|
|
stmt = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.layout_id == layout_id,
|
|
self.model.is_active.is_(True),
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return int(result.scalar_one())
|
|
|
|
async def count_active_by_version(
|
|
self, tenant_id: UUID, layout_version_id: UUID
|
|
) -> int:
|
|
stmt = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.layout_version_id == layout_version_id,
|
|
self.model.is_active.is_(True),
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
result = await self.session.execute(stmt)
|
|
return int(result.scalar_one())
|
|
|
|
async def list_filtered(
|
|
self,
|
|
tenant_id: UUID,
|
|
*,
|
|
offset: int,
|
|
limit: int,
|
|
spec: PageLayoutBindingListSpec | None = None,
|
|
) -> tuple[Sequence[PageLayoutBinding], int]:
|
|
base = select(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
count_base = select(func.count()).select_from(self.model).where(
|
|
self.model.tenant_id == tenant_id,
|
|
self.model.is_deleted.is_(False),
|
|
)
|
|
if spec is not None:
|
|
base = spec.apply(base)
|
|
for clause in spec.filter_clauses():
|
|
count_base = count_base.where(clause)
|
|
else:
|
|
base = base.order_by(self.model.created_at.desc())
|
|
total = int((await self.session.execute(count_base)).scalar_one())
|
|
result = await self.session.execute(base.offset(offset).limit(limit))
|
|
return result.scalars().all(), total
|