TorbatYar/backend/services/experience/app/specifications/themes.py
Mortezakoohjani 203671a7bf feat(experience): ship Experience Platform phases 11.0-11.10
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>
2026-07-27 11:43:10 +03:30

290 lines
11 KiB
Python

"""Theme & layout query specifications — Phase 11.3 (versioned)."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy import Select, asc, desc, or_
from sqlalchemy.sql import ColumnElement
from app.models.themes import (
ExperienceLayout,
ExperienceLayoutVersion,
ExperienceTheme,
ExperienceThemeVersion,
PageLayoutBinding,
SiteThemeBinding,
)
from app.models.types import (
DirectionMode,
LayoutKind,
LayoutStatus,
LayoutVersionStatus,
ThemeStatus,
ThemeVersionStatus,
)
THEME_SORT_FIELDS = frozenset(
{"created_at", "updated_at", "code", "name", "status", "direction_mode"}
)
THEME_VERSION_SORT_FIELDS = frozenset(
{"created_at", "updated_at", "version_number", "status"}
)
LAYOUT_SORT_FIELDS = frozenset(
{"created_at", "updated_at", "code", "name", "status", "direction_mode", "kind"}
)
LAYOUT_VERSION_SORT_FIELDS = frozenset(
{"created_at", "updated_at", "version_number", "status"}
)
SITE_THEME_BINDING_SORT_FIELDS = frozenset({"created_at", "updated_at"})
PAGE_LAYOUT_BINDING_SORT_FIELDS = frozenset({"created_at", "updated_at"})
@dataclass(frozen=True, slots=True)
class ThemeListSpec:
workspace_id: UUID | None = None
status: ThemeStatus | str | None = None
direction_mode: DirectionMode | str | None = None
q: str | None = None
sort_by: str = "created_at"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.workspace_id is not None:
clauses.append(ExperienceTheme.workspace_id == self.workspace_id)
if self.status is not None:
status = (
self.status
if isinstance(self.status, ThemeStatus)
else ThemeStatus(str(self.status))
)
clauses.append(ExperienceTheme.status == status)
if self.direction_mode is not None:
mode = (
self.direction_mode
if isinstance(self.direction_mode, DirectionMode)
else DirectionMode(str(self.direction_mode))
)
clauses.append(ExperienceTheme.direction_mode == mode)
if self.q:
term = f"%{self.q.strip()}%"
clauses.append(
or_(
ExperienceTheme.code.ilike(term),
ExperienceTheme.name.ilike(term),
ExperienceTheme.description.ilike(term),
)
)
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = self.sort_by if self.sort_by in THEME_SORT_FIELDS else "created_at"
column = getattr(ExperienceTheme, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)
@dataclass(frozen=True, slots=True)
class ThemeVersionListSpec:
theme_id: UUID | None = None
status: ThemeVersionStatus | str | None = None
sort_by: str = "version_number"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.theme_id is not None:
clauses.append(ExperienceThemeVersion.theme_id == self.theme_id)
if self.status is not None:
status = (
self.status
if isinstance(self.status, ThemeVersionStatus)
else ThemeVersionStatus(str(self.status))
)
clauses.append(ExperienceThemeVersion.status == status)
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = (
self.sort_by if self.sort_by in THEME_VERSION_SORT_FIELDS else "version_number"
)
column = getattr(ExperienceThemeVersion, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)
@dataclass(frozen=True, slots=True)
class LayoutListSpec:
workspace_id: UUID | None = None
status: LayoutStatus | str | None = None
direction_mode: DirectionMode | str | None = None
kind: LayoutKind | str | None = None
q: str | None = None
sort_by: str = "created_at"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.workspace_id is not None:
clauses.append(ExperienceLayout.workspace_id == self.workspace_id)
if self.status is not None:
status = (
self.status
if isinstance(self.status, LayoutStatus)
else LayoutStatus(str(self.status))
)
clauses.append(ExperienceLayout.status == status)
if self.direction_mode is not None:
mode = (
self.direction_mode
if isinstance(self.direction_mode, DirectionMode)
else DirectionMode(str(self.direction_mode))
)
clauses.append(ExperienceLayout.direction_mode == mode)
if self.kind is not None:
kind = (
self.kind if isinstance(self.kind, LayoutKind) else LayoutKind(str(self.kind))
)
clauses.append(ExperienceLayout.kind == kind)
if self.q:
term = f"%{self.q.strip()}%"
clauses.append(
or_(
ExperienceLayout.code.ilike(term),
ExperienceLayout.name.ilike(term),
ExperienceLayout.description.ilike(term),
)
)
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = (
self.sort_by if self.sort_by in LAYOUT_SORT_FIELDS else "created_at"
)
column = getattr(ExperienceLayout, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)
@dataclass(frozen=True, slots=True)
class LayoutVersionListSpec:
layout_id: UUID | None = None
status: LayoutVersionStatus | str | None = None
sort_by: str = "version_number"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.layout_id is not None:
clauses.append(ExperienceLayoutVersion.layout_id == self.layout_id)
if self.status is not None:
status = (
self.status
if isinstance(self.status, LayoutVersionStatus)
else LayoutVersionStatus(str(self.status))
)
clauses.append(ExperienceLayoutVersion.status == status)
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = (
self.sort_by
if self.sort_by in LAYOUT_VERSION_SORT_FIELDS
else "version_number"
)
column = getattr(ExperienceLayoutVersion, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)
@dataclass(frozen=True, slots=True)
class SiteThemeBindingListSpec:
site_id: UUID | None = None
theme_id: UUID | None = None
theme_version_id: UUID | None = None
workspace_id: UUID | None = None
is_active: bool | None = None
sort_by: str = "created_at"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.site_id is not None:
clauses.append(SiteThemeBinding.site_id == self.site_id)
if self.theme_id is not None:
clauses.append(SiteThemeBinding.theme_id == self.theme_id)
if self.theme_version_id is not None:
clauses.append(SiteThemeBinding.theme_version_id == self.theme_version_id)
if self.workspace_id is not None:
clauses.append(SiteThemeBinding.workspace_id == self.workspace_id)
if self.is_active is not None:
clauses.append(SiteThemeBinding.is_active.is_(self.is_active))
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = (
self.sort_by
if self.sort_by in SITE_THEME_BINDING_SORT_FIELDS
else "created_at"
)
column = getattr(SiteThemeBinding, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)
@dataclass(frozen=True, slots=True)
class PageLayoutBindingListSpec:
page_id: UUID | None = None
layout_id: UUID | None = None
layout_version_id: UUID | None = None
site_id: UUID | None = None
workspace_id: UUID | None = None
is_active: bool | None = None
sort_by: str = "created_at"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.page_id is not None:
clauses.append(PageLayoutBinding.page_id == self.page_id)
if self.layout_id is not None:
clauses.append(PageLayoutBinding.layout_id == self.layout_id)
if self.layout_version_id is not None:
clauses.append(PageLayoutBinding.layout_version_id == self.layout_version_id)
if self.site_id is not None:
clauses.append(PageLayoutBinding.site_id == self.site_id)
if self.workspace_id is not None:
clauses.append(PageLayoutBinding.workspace_id == self.workspace_id)
if self.is_active is not None:
clauses.append(PageLayoutBinding.is_active.is_(self.is_active))
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = (
self.sort_by
if self.sort_by in PAGE_LAYOUT_BINDING_SORT_FIELDS
else "created_at"
)
column = getattr(PageLayoutBinding, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)