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>
This commit is contained in:
Mortezakoohjani 2026-07-27 11:43:10 +03:30
parent 625275e55b
commit 203671a7bf
249 changed files with 33903 additions and 16 deletions

View File

@ -0,0 +1,22 @@
# Dev image: dependencies only — code mounted with uvicorn --reload
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/shared-lib/ /shared-lib/
COPY backend/services/experience/requirements.txt /app/requirements.txt
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
&& pip install --upgrade pip \
&& pip install -r requirements.txt
EXPOSE 8008

View File

@ -0,0 +1,42 @@
# Experience Platform (Torbat Pages)
Independent multi-tenant Experience Platform service.
| Field | Value |
| --- | --- |
| Service | `experience` |
| Commercial product | Torbat Pages |
| Database | `experience_db` |
| Port | 8008 |
| Phase | 11.10 Analytics, AI Content & Enterprise Validation (track complete) |
| Version | 0.11.10.0 |
| Permissions | `experience.*` |
## Endpoints
- `GET /health` · `/capabilities` · `/metrics`
- Foundation / sites / components / themes / templates / locales / media (prior phases)
- `/api/v1/forms` · `/api/v1/form-submissions`
- `/api/v1/surveys` · `/api/v1/survey-responses`
- `/api/v1/appointment-pages` · `/api/v1/appointment-booking-refs`
- `/api/v1/publish-releases` · `/api/v1/domain-edge-bindings`
- `/api/v1/seo-metadata` · `/api/v1/sitemap-shells` · `/api/v1/robots-shells`
- `/api/v1/pwa-manifests`
- `/api/v1/bundle-definitions` · `/api/v1/tenant-bundles`
- `/api/v1/license-bindings` · `/api/v1/feature-toggles`
- `/api/v1/consumer-connector-registrations` · `/api/v1/consumer-connector-dispatches`
- `/api/v1/widget-definitions` · `/api/v1/widget-instances`
- `/api/v1/notify-dispatches`
- `/api/v1/analytics-report-definitions` · `/api/v1/analytics-snapshots` (+ `POST /refresh`)
- `/api/v1/ai-content-hook-registrations` · `/api/v1/ai-content-dispatches` (+ `POST /{id}/generate`)
## Architecture
API → Commands/Queries → Service → Repository → Model. Tenant via `X-Tenant-ID`. Integrations via API/Events only ([ADR-016](../../../docs/architecture/adr/ADR-016.md)). Analytics uses local COUNT on `experience_db` only. AI content uses `AIProvider` contracts with mock adapter; no model inference ownership.
## Docs
- [Experience Phase 11.10](../../../docs/experience-phase-11-10.md)
- [Phase handover 11.10](../../../docs/phase-handover/phase-11-10.md)
- [Enterprise audit 11.10](../../../docs/experience-phase-11-10-audit.md)
- [Experience roadmap](../../../docs/experience-roadmap.md)

View File

@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,42 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.core.database import Base
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(
url=settings.database_url_sync,
target_metadata=target_metadata,
literal_binds=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,19 @@
"""Initial Experience schema — Phase 11.0 foundation."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)

View File

@ -0,0 +1,25 @@
"""Phase 11.1 — Sites & Page Resources schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0002_phase_111_sites_pages"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"experience_site_domains",
"experience_pages",
"experience_sites",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,25 @@
"""Phase 11.2 — Component Library & Versioning schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0003_phase_112_components"
down_revision = "0002_phase_111_sites_pages"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"experience_page_component_placements",
"experience_component_versions",
"experience_components",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 11.3 — Theme & Layout Engine schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0004_phase_113_themes_layouts"
down_revision = "0003_phase_112_components"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"experience_page_layout_bindings",
"experience_site_theme_bindings",
"experience_layout_versions",
"experience_theme_versions",
"experience_layouts",
"experience_themes",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,25 @@
"""Phase 11.4 — Template System schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0005_phase_114_templates"
down_revision = "0004_phase_113_themes_layouts"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"experience_template_instantiations",
"experience_template_versions",
"experience_templates",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,25 @@
"""Phase 11.5 — Localization, RTL/LTR & Media schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0006_phase_115_localization_media"
down_revision = "0005_phase_114_templates"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"experience_media_refs",
"experience_localizations",
"experience_site_locale_bindings",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 11.6 — Forms, Surveys & Appointments schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0007_phase_116_forms_surveys_appointments"
down_revision = "0006_phase_115_localization_media"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"experience_appointment_booking_refs",
"experience_appointment_pages",
"experience_survey_responses",
"experience_surveys",
"experience_form_submissions",
"experience_forms",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 11.7 — Publishing, SEO and PWA schema."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0008_phase_117_publishing_seo_pwa"
down_revision = "0007_phase_116_forms_surveys_appointments"
branch_labels = None
depends_on = None
def upgrade():
Base.metadata.create_all(bind=op.get_bind())
def downgrade():
bind = op.get_bind()
for table in (
"experience_pwa_manifests",
"experience_robots_shells",
"experience_sitemap_shells",
"experience_seo_metadata",
"experience_domain_edge_bindings",
"experience_publish_releases",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,26 @@
"""Phase 11.8 — bundles, licensing refs and feature toggles."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0009_phase_118_bundles_toggles"
down_revision = "0008_phase_117_publishing_seo_pwa"
branch_labels = None
depends_on = None
def upgrade():
Base.metadata.create_all(bind=op.get_bind())
def downgrade():
bind = op.get_bind()
for table in (
"experience_feature_toggles",
"experience_license_bindings",
"experience_tenant_bundles",
"experience_bundle_definitions",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,27 @@
"""Phase 11.9 — consumer connectors, widgets and notify dispatches."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0010_phase_119_consumer_widgets"
down_revision = "0009_phase_118_bundles_toggles"
branch_labels = None
depends_on = None
def upgrade():
Base.metadata.create_all(bind=op.get_bind())
def downgrade():
bind = op.get_bind()
for table in (
"experience_notify_dispatches",
"experience_widget_instances",
"experience_widget_definitions",
"experience_consumer_connector_dispatches",
"experience_consumer_connector_registrations",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,26 @@
"""Phase 11.10 — analytics and AI content hooks."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0011_phase_1110_analytics_ai_hooks"
down_revision = "0010_phase_119_consumer_widgets"
branch_labels = None
depends_on = None
def upgrade():
Base.metadata.create_all(bind=op.get_bind())
def downgrade():
bind = op.get_bind()
for table in (
"experience_ai_content_dispatches",
"experience_ai_content_hook_registrations",
"experience_analytics_snapshots",
"experience_analytics_report_definitions",
):
if table in Base.metadata.tables:
Base.metadata.tables[table].drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1 @@
__version__ = "0.11.10.0"

View File

@ -0,0 +1,39 @@
"""Common API dependencies."""
from __future__ import annotations
from uuid import UUID
from fastapi import Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from shared.exceptions import TenantNotResolvedError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
from shared.tenant import STATE_TENANT_ID
__all__ = [
"get_db",
"get_pagination",
"require_tenant",
"get_current_user",
"AsyncSession",
"CurrentUser",
]
def get_pagination(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=500),
) -> PaginationParams:
return PaginationParams(page=page, page_size=page_size)
def require_tenant(request: Request) -> UUID:
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
if tenant_id is None:
raise TenantNotResolvedError(
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
)
return tenant_id

View File

@ -0,0 +1,48 @@
"""Permission enforcement helpers for Experience APIs."""
from __future__ import annotations
from collections.abc import Callable
from fastapi import Depends
from app.core.config import settings
from app.core.security import get_current_user
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"})
def user_has_permission(user: CurrentUser, permission: str) -> bool:
if any(role in _ADMIN_ROLES for role in user.roles):
return True
roles = set(user.roles)
if "experience.manage" in roles or permission in roles:
return True
if "experience.view" in roles and permission.endswith(".view"):
return True
parts = permission.split(".")
if len(parts) >= 3:
manage = f"{parts[0]}.{parts[1]}.manage"
if manage in roles:
return True
return False
def require_permissions(*permissions: str) -> Callable:
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
async def _dependency(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if not settings.auth_required:
return user
if any(user_has_permission(user, perm) for perm in permissions):
return user
raise ForbiddenError(
"دسترسی مجاز نیست",
error_code="permission_denied",
details={"required": list(permissions)},
)
return _dependency

View File

@ -0,0 +1,194 @@
from fastapi import APIRouter
from app.api.v1 import (
ai_content_dispatches,
ai_content_hook_registrations,
analytics_report_definitions,
analytics_snapshots,
appointment_booking_refs,
appointment_pages,
audit,
bundle_definitions,
component_versions,
components,
configurations,
consumer_connector_dispatches,
consumer_connector_registrations,
domain_edge_bindings,
external_providers,
feature_toggles,
form_submissions,
forms,
layout_versions,
layouts,
license_bindings,
locale_profiles,
localizations,
media_refs,
notify_dispatches,
page_component_placements,
page_layout_bindings,
pages,
publish_releases,
pwa_manifests,
render_engines,
settings,
seo_metadata,
site_domains,
site_locale_bindings,
site_theme_bindings,
sites,
sitemap_shells,
robots_shells,
survey_responses,
surveys,
template_instantiations,
template_versions,
templates,
tenant_bundles,
theme_versions,
themes,
widget_definitions,
widget_instances,
workspaces,
)
api_router = APIRouter()
api_router.include_router(
workspaces.router, prefix="/workspaces", tags=["workspaces"]
)
api_router.include_router(
locale_profiles.router, prefix="/locale-profiles", tags=["locale_profiles"]
)
api_router.include_router(
external_providers.router,
prefix="/external-providers",
tags=["external-providers"],
)
api_router.include_router(
render_engines.router, prefix="/render-engines", tags=["render-engines"]
)
api_router.include_router(
configurations.router, prefix="/configurations", tags=["configurations"]
)
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
api_router.include_router(sites.router, prefix="/sites", tags=["sites"])
api_router.include_router(pages.router, prefix="/pages", tags=["pages"])
api_router.include_router(
site_domains.router, prefix="/site-domains", tags=["site-domains"]
)
api_router.include_router(
components.router, prefix="/components", tags=["components"]
)
api_router.include_router(
component_versions.router,
prefix="/component-versions",
tags=["component-versions"],
)
api_router.include_router(
page_component_placements.router,
prefix="/page-component-placements",
tags=["page-component-placements"],
)
api_router.include_router(themes.router, prefix="/themes", tags=["themes"])
api_router.include_router(
theme_versions.router, prefix="/theme-versions", tags=["theme-versions"]
)
api_router.include_router(layouts.router, prefix="/layouts", tags=["layouts"])
api_router.include_router(
layout_versions.router, prefix="/layout-versions", tags=["layout-versions"]
)
api_router.include_router(
site_theme_bindings.router,
prefix="/site-theme-bindings",
tags=["site-theme-bindings"],
)
api_router.include_router(
page_layout_bindings.router,
prefix="/page-layout-bindings",
tags=["page-layout-bindings"],
)
api_router.include_router(templates.router, prefix="/templates", tags=["templates"])
api_router.include_router(
template_versions.router,
prefix="/template-versions",
tags=["template-versions"],
)
api_router.include_router(
template_instantiations.router,
prefix="/template-instantiations",
tags=["template-instantiations"],
)
api_router.include_router(
site_locale_bindings.router,
prefix="/site-locale-bindings",
tags=["site-locale-bindings"],
)
api_router.include_router(
localizations.router, prefix="/localizations", tags=["localizations"]
)
api_router.include_router(
media_refs.router, prefix="/media-refs", tags=["media-refs"]
)
api_router.include_router(forms.router, prefix="/forms", tags=["forms"])
api_router.include_router(
form_submissions.router, prefix="/form-submissions", tags=["form-submissions"]
)
api_router.include_router(surveys.router, prefix="/surveys", tags=["surveys"])
api_router.include_router(
survey_responses.router, prefix="/survey-responses", tags=["survey-responses"]
)
api_router.include_router(
appointment_pages.router,
prefix="/appointment-pages",
tags=["appointment-pages"],
)
api_router.include_router(
appointment_booking_refs.router,
prefix="/appointment-booking-refs",
tags=["appointment-booking-refs"],
)
api_router.include_router(publish_releases.router, prefix="/publish-releases", tags=["publish-releases"])
api_router.include_router(domain_edge_bindings.router, prefix="/domain-edge-bindings", tags=["domain-edge-bindings"])
api_router.include_router(seo_metadata.router, prefix="/seo-metadata", tags=["seo-metadata"])
api_router.include_router(sitemap_shells.router, prefix="/sitemap-shells", tags=["sitemap-shells"])
api_router.include_router(robots_shells.router, prefix="/robots-shells", tags=["robots-shells"])
api_router.include_router(pwa_manifests.router, prefix="/pwa-manifests", tags=["pwa-manifests"])
api_router.include_router(bundle_definitions.router, prefix="/bundle-definitions", tags=["bundle-definitions"])
api_router.include_router(tenant_bundles.router, prefix="/tenant-bundles", tags=["tenant-bundles"])
api_router.include_router(license_bindings.router, prefix="/license-bindings", tags=["license-bindings"])
api_router.include_router(feature_toggles.router, prefix="/feature-toggles", tags=["feature-toggles"])
api_router.include_router(
consumer_connector_registrations.router,
prefix="/consumer-connector-registrations",
tags=["consumer-connector-registrations"],
)
api_router.include_router(
consumer_connector_dispatches.router,
prefix="/consumer-connector-dispatches",
tags=["consumer-connector-dispatches"],
)
api_router.include_router(widget_definitions.router, prefix="/widget-definitions", tags=["widget-definitions"])
api_router.include_router(widget_instances.router, prefix="/widget-instances", tags=["widget-instances"])
api_router.include_router(notify_dispatches.router, prefix="/notify-dispatches", tags=["notify-dispatches"])
api_router.include_router(
analytics_report_definitions.router,
prefix="/analytics-report-definitions",
tags=["analytics-report-definitions"],
)
api_router.include_router(
analytics_snapshots.router,
prefix="/analytics-snapshots",
tags=["analytics-snapshots"],
)
api_router.include_router(
ai_content_hook_registrations.router,
prefix="/ai-content-hook-registrations",
tags=["ai-content-hook-registrations"],
)
api_router.include_router(
ai_content_dispatches.router,
prefix="/ai-content-dispatches",
tags=["ai-content-dispatches"],
)

View File

@ -0,0 +1,118 @@
"""AI content dispatch APIs — Phase 11.10."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.ai_hooks import AiHooksCommands
from app.permissions.definitions import (
AI_HOOKS_CREATE,
AI_HOOKS_DELETE,
AI_HOOKS_MANAGE,
AI_HOOKS_UPDATE,
AI_HOOKS_VIEW,
)
from app.queries.ai_hooks import AiHooksQueries
from app.schemas.ai_hooks import (
AiContentDispatchCreate,
AiContentDispatchListResponse,
AiContentDispatchRead,
AiContentDispatchUpdate,
)
from app.specifications.ai_hooks import DISPATCH_SORT, AiContentDispatchListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
page_id: UUID | None = None,
registration_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return AiContentDispatchListSpec(
workspace_id=workspace_id,
site_id=site_id,
page_id=page_id,
registration_id=registration_id,
status=status_filter,
q=q,
sort_by=sort_by if sort_by in DISPATCH_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=AiContentDispatchRead, status_code=201)
async def create(
body: AiContentDispatchCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_CREATE)),
):
return await AiHooksCommands(db).create_dispatch(tenant_id, body, actor=user)
@router.get("", response_model=AiContentDispatchListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: AiContentDispatchListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)),
):
items, total = await AiHooksQueries(db).list_dispatches(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return AiContentDispatchListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=AiContentDispatchRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)),
):
return await AiHooksQueries(db).get_dispatch(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=AiContentDispatchRead)
async def update(
entity_id: UUID,
body: AiContentDispatchUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_UPDATE)),
):
return await AiHooksCommands(db).update_dispatch(tenant_id, entity_id, body, actor=user)
@router.post("/{entity_id}/generate", response_model=AiContentDispatchRead)
async def generate(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_MANAGE)),
):
return await AiHooksCommands(db).generate_dispatch(tenant_id, entity_id, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_DELETE)),
):
await AiHooksCommands(db).delete_dispatch(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,112 @@
"""AI content hook registration APIs — Phase 11.10."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.ai_hooks import AiHooksCommands
from app.permissions.definitions import (
AI_HOOKS_CREATE,
AI_HOOKS_DELETE,
AI_HOOKS_UPDATE,
AI_HOOKS_VIEW,
)
from app.queries.ai_hooks import AiHooksQueries
from app.schemas.ai_hooks import (
AiContentHookRegistrationCreate,
AiContentHookRegistrationListResponse,
AiContentHookRegistrationRead,
AiContentHookRegistrationUpdate,
)
from app.specifications.ai_hooks import (
HOOK_REGISTRATION_SORT,
AiContentHookRegistrationListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
page_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
task_kind: str | None = None,
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return AiContentHookRegistrationListSpec(
workspace_id=workspace_id,
site_id=site_id,
page_id=page_id,
status=status_filter,
task_kind=task_kind,
q=q,
sort_by=sort_by if sort_by in HOOK_REGISTRATION_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=AiContentHookRegistrationRead, status_code=201)
async def create(
body: AiContentHookRegistrationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_CREATE)),
):
return await AiHooksCommands(db).create_hook_registration(tenant_id, body, actor=user)
@router.get("", response_model=AiContentHookRegistrationListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: AiContentHookRegistrationListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)),
):
items, total = await AiHooksQueries(db).list_hook_registrations(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return AiContentHookRegistrationListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=AiContentHookRegistrationRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AI_HOOKS_VIEW)),
):
return await AiHooksQueries(db).get_hook_registration(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=AiContentHookRegistrationRead)
async def update(
entity_id: UUID,
body: AiContentHookRegistrationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_UPDATE)),
):
return await AiHooksCommands(db).update_hook_registration(
tenant_id, entity_id, body, actor=user
)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AI_HOOKS_DELETE)),
):
await AiHooksCommands(db).delete_hook_registration(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,108 @@
"""Analytics report definition APIs — Phase 11.10."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.analytics import AnalyticsCommands
from app.permissions.definitions import (
ANALYTICS_CREATE,
ANALYTICS_DELETE,
ANALYTICS_UPDATE,
ANALYTICS_VIEW,
)
from app.queries.analytics import AnalyticsQueries
from app.schemas.analytics import (
AnalyticsReportDefinitionCreate,
AnalyticsReportDefinitionListResponse,
AnalyticsReportDefinitionRead,
AnalyticsReportDefinitionUpdate,
)
from app.specifications.analytics import (
REPORT_DEFINITION_SORT,
AnalyticsReportDefinitionListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return AnalyticsReportDefinitionListSpec(
workspace_id=workspace_id,
site_id=site_id,
status=status_filter,
q=q,
sort_by=sort_by if sort_by in REPORT_DEFINITION_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=AnalyticsReportDefinitionRead, status_code=201)
async def create(
body: AnalyticsReportDefinitionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ANALYTICS_CREATE)),
):
return await AnalyticsCommands(db).create_report_definition(tenant_id, body, actor=user)
@router.get("", response_model=AnalyticsReportDefinitionListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: AnalyticsReportDefinitionListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)),
):
items, total = await AnalyticsQueries(db).list_report_definitions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return AnalyticsReportDefinitionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=AnalyticsReportDefinitionRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)),
):
return await AnalyticsQueries(db).get_report_definition(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=AnalyticsReportDefinitionRead)
async def update(
entity_id: UUID,
body: AnalyticsReportDefinitionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ANALYTICS_UPDATE)),
):
return await AnalyticsCommands(db).update_report_definition(
tenant_id, entity_id, body, actor=user
)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ANALYTICS_DELETE)),
):
await AnalyticsCommands(db).delete_report_definition(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,106 @@
"""Analytics snapshot APIs — Phase 11.10."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.analytics import AnalyticsCommands
from app.permissions.definitions import (
ANALYTICS_CREATE,
ANALYTICS_DELETE,
ANALYTICS_MANAGE,
ANALYTICS_UPDATE,
ANALYTICS_VIEW,
)
from app.queries.analytics import AnalyticsQueries
from app.schemas.analytics import (
AnalyticsSnapshotListResponse,
AnalyticsSnapshotRead,
AnalyticsSnapshotRefresh,
AnalyticsSnapshotUpdate,
)
from app.specifications.analytics import SNAPSHOT_SORT, AnalyticsSnapshotListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
report_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return AnalyticsSnapshotListSpec(
workspace_id=workspace_id,
site_id=site_id,
report_id=report_id,
status=status_filter,
q=q,
sort_by=sort_by if sort_by in SNAPSHOT_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("/refresh", response_model=AnalyticsSnapshotRead, status_code=201)
async def refresh(
body: AnalyticsSnapshotRefresh,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ANALYTICS_MANAGE)),
):
return await AnalyticsCommands(db).refresh_snapshot(tenant_id, body, actor=user)
@router.get("", response_model=AnalyticsSnapshotListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: AnalyticsSnapshotListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)),
):
items, total = await AnalyticsQueries(db).list_snapshots(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return AnalyticsSnapshotListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=AnalyticsSnapshotRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ANALYTICS_VIEW)),
):
return await AnalyticsQueries(db).get_snapshot(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=AnalyticsSnapshotRead)
async def update(
entity_id: UUID,
body: AnalyticsSnapshotUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ANALYTICS_UPDATE)),
):
return await AnalyticsCommands(db).update_snapshot(tenant_id, entity_id, body, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ANALYTICS_DELETE)),
):
await AnalyticsCommands(db).delete_snapshot(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,102 @@
"""Appointment booking-ref APIs — Phase 11.6 (external refs only)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.forms import FormsCommands
from app.permissions.definitions import (
APPOINTMENTS_CREATE,
APPOINTMENTS_UPDATE,
APPOINTMENTS_VIEW,
)
from app.queries.forms import FormsQueries
from app.schemas.forms import (
AppointmentBookingRefCreate,
AppointmentBookingRefListResponse,
AppointmentBookingRefRead,
AppointmentBookingRefUpdate,
)
from app.specifications.forms import BOOKING_SORT_FIELDS, AppointmentBookingListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"pending", "confirmed", "cancelled", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
appointment_page_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> AppointmentBookingListSpec:
if sort_by not in BOOKING_SORT_FIELDS:
sort_by = "created_at"
return AppointmentBookingListSpec(
workspace_id=workspace_id,
appointment_page_id=appointment_page_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=AppointmentBookingRefRead,
status_code=status.HTTP_201_CREATED,
)
async def create_booking_ref(
body: AppointmentBookingRefCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CREATE)),
):
return await FormsCommands(db).create_booking_ref(tenant_id, body, actor=user)
@router.get("", response_model=AppointmentBookingRefListResponse)
async def list_booking_refs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: AppointmentBookingListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)),
):
items, total = await FormsQueries(db).list_booking_refs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return AppointmentBookingRefListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{booking_id}", response_model=AppointmentBookingRefRead)
async def get_booking_ref(
booking_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)),
):
return await FormsQueries(db).get_booking_ref(tenant_id, booking_id)
@router.patch("/{booking_id}", response_model=AppointmentBookingRefRead)
async def update_booking_ref(
booking_id: UUID,
body: AppointmentBookingRefUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(APPOINTMENTS_UPDATE)),
):
return await FormsCommands(db).update_booking_ref(
tenant_id, booking_id, body, actor=user
)

View File

@ -0,0 +1,120 @@
"""Appointment page APIs — Phase 11.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.forms import FormsCommands
from app.permissions.definitions import (
APPOINTMENTS_CREATE,
APPOINTMENTS_DELETE,
APPOINTMENTS_UPDATE,
APPOINTMENTS_VIEW,
)
from app.queries.forms import FormsQueries
from app.schemas.forms import (
ExperienceAppointmentPageCreate,
ExperienceAppointmentPageListResponse,
ExperienceAppointmentPageRead,
ExperienceAppointmentPageUpdate,
)
from app.specifications.forms import (
APPOINTMENT_PAGE_SORT_FIELDS,
AppointmentPageListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
page_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> AppointmentPageListSpec:
if sort_by not in APPOINTMENT_PAGE_SORT_FIELDS:
sort_by = "created_at"
return AppointmentPageListSpec(
workspace_id=workspace_id,
site_id=site_id,
page_id=page_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=ExperienceAppointmentPageRead,
status_code=status.HTTP_201_CREATED,
)
async def create_appointment_page(
body: ExperienceAppointmentPageCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(APPOINTMENTS_CREATE)),
):
return await FormsCommands(db).create_appointment_page(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceAppointmentPageListResponse)
async def list_appointment_pages(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: AppointmentPageListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)),
):
items, total = await FormsQueries(db).list_appointment_pages(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceAppointmentPageListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{page_id}", response_model=ExperienceAppointmentPageRead)
async def get_appointment_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(APPOINTMENTS_VIEW)),
):
return await FormsQueries(db).get_appointment_page(tenant_id, page_id)
@router.patch("/{page_id}", response_model=ExperienceAppointmentPageRead)
async def update_appointment_page(
page_id: UUID,
body: ExperienceAppointmentPageUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(APPOINTMENTS_UPDATE)),
):
return await FormsCommands(db).update_appointment_page(
tenant_id, page_id, body, actor=user
)
@router.delete("/{page_id}", response_model=ExperienceAppointmentPageRead)
async def delete_appointment_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(APPOINTMENTS_DELETE)),
):
return await FormsCommands(db).soft_delete_appointment_page(
tenant_id, page_id, actor=user
)

View File

@ -0,0 +1,30 @@
"""Experience audit APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import AUDIT_VIEW
from app.schemas.foundation import ExperienceAuditLogRead
from app.services.audit_service import AuditService
from shared.security import CurrentUser
router = APIRouter()
@router.get("", response_model=list[ExperienceAuditLogRead])
async def list_audit(
entity_type: str = Query(...),
entity_id: UUID = Query(...),
limit: int = Query(default=100, ge=1, le=500),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)),
):
return await AuditService(db).list_for_entity(
tenant_id, entity_type, entity_id, limit=limit
)

View File

@ -0,0 +1,49 @@
"""Bundle definition APIs — Phase 11.8."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.bundles import BundlesCommands
from app.permissions.definitions import BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_UPDATE, BUNDLES_VIEW
from app.queries.bundles import BundlesQueries
from app.schemas.bundles import BundleDefinitionCreate, BundleDefinitionListResponse, BundleDefinitionRead, BundleDefinitionUpdate
from app.specifications.bundles import BUNDLE_DEFINITION_SORT, BundleDefinitionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(workspace_id: UUID | None = None, status_filter: str | None = Query(None, alias="status"),
bundle_key: str | None = None, q: str | None = Query(None, max_length=100),
sort_by: str = "created_at", sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")):
return BundleDefinitionListSpec(workspace_id=workspace_id, status=status_filter, bundle_key=bundle_key, q=q,
sort_by=sort_by if sort_by in BUNDLE_DEFINITION_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=BundleDefinitionRead, status_code=201)
async def create(body: BundleDefinitionCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE))):
return await BundlesCommands(db).create_bundle_definition(tenant_id, body, actor=user)
@router.get("", response_model=BundleDefinitionListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: BundleDefinitionListSpec = Depends(_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))):
items, total = await BundlesQueries(db).list_bundle_definitions(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return BundleDefinitionListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=BundleDefinitionRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))):
return await BundlesQueries(db).get_bundle_definition(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=BundleDefinitionRead)
async def update(entity_id: UUID, body: BundleDefinitionUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE))):
return await BundlesCommands(db).update_bundle_definition(tenant_id, entity_id, body, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE))):
await BundlesCommands(db).delete_bundle_definition(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,133 @@
"""Component version APIs — Phase 11.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.components import ComponentCommands
from app.permissions.definitions import (
COMPONENTS_CREATE,
COMPONENTS_MANAGE,
COMPONENTS_UPDATE,
COMPONENTS_VIEW,
)
from app.queries.components import ComponentQueries
from app.schemas.components import (
ExperienceComponentVersionCreate,
ExperienceComponentVersionListResponse,
ExperienceComponentVersionRead,
ExperienceComponentVersionUpdate,
)
from app.specifications.components import VERSION_SORT_FIELDS, ComponentVersionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_VERSION_STATUSES = {"draft", "published", "retired"}
def _list_spec(
component_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="version_number"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ComponentVersionListSpec:
if sort_by not in VERSION_SORT_FIELDS:
sort_by = "version_number"
status_value = status_filter if status_filter in _VERSION_STATUSES else None
return ComponentVersionListSpec(
component_id=component_id,
status=status_value, # type: ignore[arg-type]
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=ExperienceComponentVersionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_version(
body: ExperienceComponentVersionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_CREATE)),
):
return await ComponentCommands(db).create_version(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceComponentVersionListResponse)
async def list_versions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ComponentVersionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)),
):
items, total = await ComponentQueries(db).list_versions(
tenant_id,
offset=pagination.offset,
limit=pagination.page_size,
spec=spec,
)
return ExperienceComponentVersionListResponse(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
@router.get("/{version_id}", response_model=ExperienceComponentVersionRead)
async def get_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)),
):
return await ComponentQueries(db).get_version(tenant_id, version_id)
@router.patch("/{version_id}", response_model=ExperienceComponentVersionRead)
async def update_version(
version_id: UUID,
body: ExperienceComponentVersionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_UPDATE)),
):
return await ComponentCommands(db).update_version(
tenant_id, version_id, body, actor=user
)
@router.post("/{version_id}/publish", response_model=ExperienceComponentVersionRead)
async def publish_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)),
):
return await ComponentCommands(db).publish_version(
tenant_id, version_id, actor=user
)
@router.post("/{version_id}/retire", response_model=ExperienceComponentVersionRead)
async def retire_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)),
):
return await ComponentCommands(db).retire_version(
tenant_id, version_id, actor=user
)

View File

@ -0,0 +1,174 @@
"""Component library APIs — Phase 11.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.components import ComponentCommands
from app.permissions.definitions import (
COMPONENTS_CREATE,
COMPONENTS_DELETE,
COMPONENTS_MANAGE,
COMPONENTS_UPDATE,
COMPONENTS_VIEW,
)
from app.queries.components import ComponentQueries
from app.schemas.components import (
ExperienceComponentCreate,
ExperienceComponentListResponse,
ExperienceComponentRead,
ExperienceComponentUpdate,
)
from app.specifications.components import COMPONENT_SORT_FIELDS, ComponentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_COMPONENT_KINDS = {
"layout_block",
"content",
"media",
"navigation",
"form_shell",
"widget_shell",
"cta",
"hero",
"menu_ref",
"catalog_ref",
"custom",
}
_COMPONENT_STATUSES = {"draft", "active", "deprecated", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
kind: str | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
category: str | None = Query(default=None, max_length=100),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ComponentListSpec:
if sort_by not in COMPONENT_SORT_FIELDS:
sort_by = "created_at"
kind_value = kind if kind in _COMPONENT_KINDS else None
status_value = status_filter if status_filter in _COMPONENT_STATUSES else None
return ComponentListSpec(
workspace_id=workspace_id,
kind=kind_value, # type: ignore[arg-type]
status=status_value, # type: ignore[arg-type]
category=category,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceComponentRead, status_code=status.HTTP_201_CREATED
)
async def create_component(
body: ExperienceComponentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_CREATE)),
):
return await ComponentCommands(db).create_component(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceComponentListResponse)
async def list_components(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ComponentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)),
):
items, total = await ComponentQueries(db).list_components(
tenant_id,
offset=pagination.offset,
limit=pagination.page_size,
spec=spec,
)
return ExperienceComponentListResponse(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
@router.get("/{component_id}", response_model=ExperienceComponentRead)
async def get_component(
component_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)),
):
return await ComponentQueries(db).get_component(tenant_id, component_id)
@router.patch("/{component_id}", response_model=ExperienceComponentRead)
async def update_component(
component_id: UUID,
body: ExperienceComponentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_UPDATE)),
):
return await ComponentCommands(db).update_component(
tenant_id, component_id, body, actor=user
)
@router.post("/{component_id}/activate", response_model=ExperienceComponentRead)
async def activate_component(
component_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)),
):
return await ComponentCommands(db).activate_component(
tenant_id, component_id, actor=user
)
@router.post("/{component_id}/deprecate", response_model=ExperienceComponentRead)
async def deprecate_component(
component_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)),
):
return await ComponentCommands(db).deprecate_component(
tenant_id, component_id, actor=user
)
@router.post("/{component_id}/archive", response_model=ExperienceComponentRead)
async def archive_component(
component_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_MANAGE)),
):
return await ComponentCommands(db).archive_component(
tenant_id, component_id, actor=user
)
@router.post("/{component_id}/delete", response_model=ExperienceComponentRead)
async def soft_delete_component(
component_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_DELETE)),
):
return await ComponentCommands(db).soft_delete_component(
tenant_id, component_id, actor=user
)

View File

@ -0,0 +1,83 @@
"""Experience configuration APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
CONFIGURATIONS_CREATE,
CONFIGURATIONS_DELETE,
CONFIGURATIONS_UPDATE,
CONFIGURATIONS_VIEW,
)
from app.schemas.foundation import (
ExperienceConfigurationCreate,
ExperienceConfigurationRead,
ExperienceConfigurationUpdate,
)
from app.services.foundation import ExperienceConfigurationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExperienceConfigurationRead, status_code=status.HTTP_201_CREATED)
async def create_configuration(
body: ExperienceConfigurationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)),
):
return await ExperienceConfigurationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperienceConfigurationRead])
async def list_configurations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
):
return await ExperienceConfigurationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{configuration_id}", response_model=ExperienceConfigurationRead)
async def get_configuration(
configuration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
):
return await ExperienceConfigurationService(db).get(tenant_id, configuration_id)
@router.patch("/{configuration_id}", response_model=ExperienceConfigurationRead)
async def update_configuration(
configuration_id: UUID,
body: ExperienceConfigurationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)),
):
return await ExperienceConfigurationService(db).update(
tenant_id, configuration_id, body, actor=user
)
@router.post("/{configuration_id}/delete", response_model=ExperienceConfigurationRead)
async def soft_delete_configuration(
configuration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)),
):
return await ExperienceConfigurationService(db).soft_delete(
tenant_id, configuration_id, actor=user
)

View File

@ -0,0 +1,127 @@
"""Consumer connector dispatch APIs — Phase 11.9."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.integrations import IntegrationsCommands
from app.permissions.definitions import (
CONSUMER_CONNECTORS_CREATE,
CONSUMER_CONNECTORS_DELETE,
CONSUMER_CONNECTORS_MANAGE,
CONSUMER_CONNECTORS_UPDATE,
CONSUMER_CONNECTORS_VIEW,
)
from app.queries.integrations import IntegrationsQueries
from app.schemas.integrations import (
ConsumerConnectorDispatchCreate,
ConsumerConnectorDispatchListResponse,
ConsumerConnectorDispatchRead,
ConsumerConnectorDispatchUpdate,
)
from app.specifications.integrations import (
CONNECTOR_DISPATCH_SORT,
ConsumerConnectorDispatchListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
registration_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return ConsumerConnectorDispatchListSpec(
workspace_id=workspace_id,
site_id=site_id,
registration_id=registration_id,
status=status_filter,
q=q,
sort_by=sort_by if sort_by in CONNECTOR_DISPATCH_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=ConsumerConnectorDispatchRead, status_code=201)
async def create(
body: ConsumerConnectorDispatchCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_CREATE)),
):
return await IntegrationsCommands(db).create_consumer_connector_dispatch(
tenant_id, body, actor=user
)
@router.get("", response_model=ConsumerConnectorDispatchListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ConsumerConnectorDispatchListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)),
):
items, total = await IntegrationsQueries(db).list_consumer_connector_dispatches(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ConsumerConnectorDispatchListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=ConsumerConnectorDispatchRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)),
):
return await IntegrationsQueries(db).get_consumer_connector_dispatch(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=ConsumerConnectorDispatchRead)
async def update(
entity_id: UUID,
body: ConsumerConnectorDispatchUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_UPDATE)),
):
return await IntegrationsCommands(db).update_consumer_connector_dispatch(
tenant_id, entity_id, body, actor=user
)
@router.post("/{entity_id}/execute", response_model=ConsumerConnectorDispatchRead)
async def execute(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_MANAGE)),
):
return await IntegrationsCommands(db).execute_consumer_connector_dispatch(
tenant_id, entity_id, actor=user
)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_DELETE)),
):
await IntegrationsCommands(db).delete_consumer_connector_dispatch(
tenant_id, entity_id, actor=user
)
return Response(status_code=204)

View File

@ -0,0 +1,114 @@
"""Consumer connector registration APIs — Phase 11.9."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.integrations import IntegrationsCommands
from app.permissions.definitions import (
CONSUMER_CONNECTORS_CREATE,
CONSUMER_CONNECTORS_DELETE,
CONSUMER_CONNECTORS_UPDATE,
CONSUMER_CONNECTORS_VIEW,
)
from app.queries.integrations import IntegrationsQueries
from app.schemas.integrations import (
ConsumerConnectorRegistrationCreate,
ConsumerConnectorRegistrationListResponse,
ConsumerConnectorRegistrationRead,
ConsumerConnectorRegistrationUpdate,
)
from app.specifications.integrations import (
CONNECTOR_REGISTRATION_SORT,
ConsumerConnectorRegistrationListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
kind: str | None = None,
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return ConsumerConnectorRegistrationListSpec(
workspace_id=workspace_id,
site_id=site_id,
status=status_filter,
kind=kind,
q=q,
sort_by=sort_by if sort_by in CONNECTOR_REGISTRATION_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=ConsumerConnectorRegistrationRead, status_code=201)
async def create(
body: ConsumerConnectorRegistrationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_CREATE)),
):
return await IntegrationsCommands(db).create_consumer_connector_registration(
tenant_id, body, actor=user
)
@router.get("", response_model=ConsumerConnectorRegistrationListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ConsumerConnectorRegistrationListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)),
):
items, total = await IntegrationsQueries(db).list_consumer_connector_registrations(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ConsumerConnectorRegistrationListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=ConsumerConnectorRegistrationRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_VIEW)),
):
return await IntegrationsQueries(db).get_consumer_connector_registration(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=ConsumerConnectorRegistrationRead)
async def update(
entity_id: UUID,
body: ConsumerConnectorRegistrationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_UPDATE)),
):
return await IntegrationsCommands(db).update_consumer_connector_registration(
tenant_id, entity_id, body, actor=user
)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONSUMER_CONNECTORS_DELETE)),
):
await IntegrationsCommands(db).delete_consumer_connector_registration(
tenant_id, entity_id, actor=user
)
return Response(status_code=204)

View File

@ -0,0 +1,43 @@
"""Domain edge binding APIs — Phase 11.7."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.publishing import PublishingCommands
from app.permissions.definitions import PUBLISHING_CREATE, PUBLISHING_UPDATE, PUBLISHING_VIEW
from app.queries.publishing import PublishingQueries
from app.schemas.publishing import DomainEdgeBindingCreate, DomainEdgeBindingListResponse, DomainEdgeBindingRead, DomainEdgeBindingUpdate
from app.specifications.publishing import DomainEdgeBindingListSpec, EDGE_SORT
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, site_domain_id: UUID | None = None,
status_filter: str | None = Query(default=None, alias="status"), q: str | None = Query(default=None, max_length=100),
sort_by: str = "created_at", sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")):
return DomainEdgeBindingListSpec(workspace_id=workspace_id, site_id=site_id, site_domain_id=site_domain_id,
status=status_filter, q=q, sort_by=sort_by if sort_by in EDGE_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=DomainEdgeBindingRead, status_code=status.HTTP_201_CREATED)
async def create(body: DomainEdgeBindingCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PUBLISHING_CREATE))):
return await PublishingCommands(db).create_domain_edge_binding(tenant_id, body, actor=user)
@router.get("", response_model=DomainEdgeBindingListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: DomainEdgeBindingListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))):
items, total = await PublishingQueries(db).list_domain_edge_bindings(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return DomainEdgeBindingListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=DomainEdgeBindingRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))):
return await PublishingQueries(db).get_domain_edge_binding(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=DomainEdgeBindingRead)
async def update(entity_id: UUID, body: DomainEdgeBindingUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PUBLISHING_UPDATE))):
return await PublishingCommands(db).update_domain_edge_binding(tenant_id, entity_id, body, actor=user)

View File

@ -0,0 +1,83 @@
"""External provider config APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
EXTERNAL_PROVIDERS_CREATE,
EXTERNAL_PROVIDERS_DELETE,
EXTERNAL_PROVIDERS_UPDATE,
EXTERNAL_PROVIDERS_VIEW,
)
from app.schemas.foundation import (
ExternalProviderConfigCreate,
ExternalProviderConfigRead,
ExternalProviderConfigUpdate,
)
from app.services.foundation import ExternalProviderConfigService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED)
async def create_provider(
body: ExternalProviderConfigCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)),
):
return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExternalProviderConfigRead])
async def list_providers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
):
return await ExternalProviderConfigService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{provider_id}", response_model=ExternalProviderConfigRead)
async def get_provider(
provider_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
):
return await ExternalProviderConfigService(db).get(tenant_id, provider_id)
@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead)
async def update_provider(
provider_id: UUID,
body: ExternalProviderConfigUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)),
):
return await ExternalProviderConfigService(db).update(
tenant_id, provider_id, body, actor=user
)
@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead)
async def soft_delete_provider(
provider_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)),
):
return await ExternalProviderConfigService(db).soft_delete(
tenant_id, provider_id, actor=user
)

View File

@ -0,0 +1,50 @@
"""Feature toggle APIs — Phase 11.8."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.bundles import BundlesCommands
from app.permissions.definitions import FEATURE_TOGGLES_CREATE, FEATURE_TOGGLES_DELETE, FEATURE_TOGGLES_UPDATE, FEATURE_TOGGLES_VIEW
from app.queries.bundles import BundlesQueries
from app.schemas.bundles import FeatureToggleCreate, FeatureToggleListResponse, FeatureToggleRead, FeatureToggleUpdate
from app.specifications.bundles import FEATURE_TOGGLE_SORT, FeatureToggleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(workspace_id: UUID | None = None, site_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"), bundle_key: str | None = None,
q: str | None = Query(None, max_length=100), sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")):
return FeatureToggleListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, bundle_key=bundle_key,
q=q, sort_by=sort_by if sort_by in FEATURE_TOGGLE_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=FeatureToggleRead, status_code=201)
async def create(body: FeatureToggleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_CREATE))):
return await BundlesCommands(db).create_feature_toggle(tenant_id, body, actor=user)
@router.get("", response_model=FeatureToggleListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: FeatureToggleListSpec = Depends(_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_VIEW))):
items, total = await BundlesQueries(db).list_feature_toggles(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return FeatureToggleListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=FeatureToggleRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_VIEW))):
return await BundlesQueries(db).get_feature_toggle(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=FeatureToggleRead)
async def update(entity_id: UUID, body: FeatureToggleUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_UPDATE))):
return await BundlesCommands(db).update_feature_toggle(tenant_id, entity_id, body, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FEATURE_TOGGLES_DELETE))):
await BundlesCommands(db).delete_feature_toggle(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,96 @@
"""Form submission APIs — Phase 11.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.forms import FormsCommands
from app.permissions.definitions import FORMS_CREATE, FORMS_UPDATE, FORMS_VIEW
from app.queries.forms import FormsQueries
from app.schemas.forms import (
FormSubmissionCreate,
FormSubmissionListResponse,
FormSubmissionRead,
FormSubmissionUpdate,
)
from app.specifications.forms import SUBMISSION_SORT_FIELDS, FormSubmissionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"received", "processed", "failed", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
form_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> FormSubmissionListSpec:
if sort_by not in SUBMISSION_SORT_FIELDS:
sort_by = "created_at"
return FormSubmissionListSpec(
workspace_id=workspace_id,
form_id=form_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=FormSubmissionRead, status_code=status.HTTP_201_CREATED
)
async def create_submission(
body: FormSubmissionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FORMS_CREATE)),
):
return await FormsCommands(db).create_submission(tenant_id, body, actor=user)
@router.get("", response_model=FormSubmissionListResponse)
async def list_submissions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: FormSubmissionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FORMS_VIEW)),
):
items, total = await FormsQueries(db).list_submissions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return FormSubmissionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{submission_id}", response_model=FormSubmissionRead)
async def get_submission(
submission_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FORMS_VIEW)),
):
return await FormsQueries(db).get_submission(tenant_id, submission_id)
@router.patch("/{submission_id}", response_model=FormSubmissionRead)
async def update_submission(
submission_id: UUID,
body: FormSubmissionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FORMS_UPDATE)),
):
return await FormsCommands(db).update_submission(
tenant_id, submission_id, body, actor=user
)

View File

@ -0,0 +1,109 @@
"""Form definition APIs — Phase 11.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.forms import FormsCommands
from app.permissions.definitions import (
FORMS_CREATE,
FORMS_DELETE,
FORMS_UPDATE,
FORMS_VIEW,
)
from app.queries.forms import FormsQueries
from app.schemas.forms import (
ExperienceFormCreate,
ExperienceFormListResponse,
ExperienceFormRead,
ExperienceFormUpdate,
)
from app.specifications.forms import FORM_SORT_FIELDS, FormListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
page_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> FormListSpec:
if sort_by not in FORM_SORT_FIELDS:
sort_by = "created_at"
return FormListSpec(
workspace_id=workspace_id,
site_id=site_id,
page_id=page_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ExperienceFormRead, status_code=status.HTTP_201_CREATED)
async def create_form(
body: ExperienceFormCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FORMS_CREATE)),
):
return await FormsCommands(db).create_form(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceFormListResponse)
async def list_forms(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: FormListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FORMS_VIEW)),
):
items, total = await FormsQueries(db).list_forms(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceFormListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{form_id}", response_model=ExperienceFormRead)
async def get_form(
form_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FORMS_VIEW)),
):
return await FormsQueries(db).get_form(tenant_id, form_id)
@router.patch("/{form_id}", response_model=ExperienceFormRead)
async def update_form(
form_id: UUID,
body: ExperienceFormUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FORMS_UPDATE)),
):
return await FormsCommands(db).update_form(tenant_id, form_id, body, actor=user)
@router.delete("/{form_id}", response_model=ExperienceFormRead)
async def delete_form(
form_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FORMS_DELETE)),
):
return await FormsCommands(db).soft_delete_form(tenant_id, form_id, actor=user)

View File

@ -0,0 +1,226 @@
from fastapi import APIRouter
from app import __version__
from app.core.config import settings
router = APIRouter()
_PAGE_TYPES = [
"landing",
"mini_site",
"business_website",
"portfolio",
"restaurant",
"cafe",
"company",
"business_profile",
"digital_menu",
"digital_catalog",
"product_catalog",
"qr",
"link_in_bio",
"appointment",
"form",
"blog",
"campaign",
"knowledge",
"documentation",
"media",
"seo",
"public_profile",
"event",
"custom",
]
_COMPONENT_KINDS = [
"layout_block",
"content",
"media",
"navigation",
"form_shell",
"widget_shell",
"cta",
"hero",
"menu_ref",
"catalog_ref",
"custom",
]
_DIRECTION_MODES = ["rtl", "ltr", "both"]
@router.get("/health")
async def health_check():
return {
"status": "ok",
"service": settings.service_name,
"version": __version__,
}
@router.get("/capabilities")
async def capabilities():
return {
"service": settings.service_name,
"version": __version__,
"phase": "11.10",
"commercial_product": "Torbat Pages",
"platform": "Experience Platform",
"features": {
"foundation": True,
"health": True,
"capabilities": True,
"metrics": True,
"workspaces": True,
"locale_profiles": True,
"external_providers_ready": True,
"future_render_engines_ready": True,
"feature_toggle_shell": True,
"bundle_licensing_shell": True,
"sites": True,
"pages": True,
"page_types": True,
"site_domains": True,
"publishing_draft_archive": True,
"components": True,
"component_versions": True,
"component_version_immutability": True,
"page_component_placements": True,
"themes": True,
"theme_versions": True,
"layouts": True,
"layout_versions": True,
"directionality_ready_shells": True,
"site_theme_bindings": True,
"page_layout_bindings": True,
"page_builder": False,
"templates": True,
"template_versions": True,
"template_instantiation": True,
"site_locale_bindings": True,
"localizations": True,
"rtl_ltr": True,
"media_refs": True,
"storage_media_refs_only": True,
"forms": True,
"form_submissions": True,
"surveys": True,
"survey_responses": True,
"appointment_pages": True,
"appointment_booking_refs": True,
"publishing": True,
"publish_releases": True,
"domain_edge_bindings": True,
"seo_metadata": True,
"sitemap_shells": True,
"robots_shells": True,
"pwa_manifests": True,
"bundles": True,
"bundle_definitions": True,
"tenant_bundles": True,
"license_bindings": True,
"feature_toggles": True,
"widgets": True,
"consumer_connectors": True,
"analytics": True,
"ai_content": True,
"cms": False,
"seo_engine": True,
},
"page_types": list(_PAGE_TYPES),
"component_kinds": list(_COMPONENT_KINDS),
"direction_modes": list(_DIRECTION_MODES),
"text_directions": ["rtl", "ltr"],
"localization_targets": [
"site",
"page",
"component_placement",
"template",
"theme",
"layout",
"media_ref",
],
"media_kinds": ["image", "video", "document", "audio", "icon", "other"],
"independence": {
"standalone": True,
"superapp": True,
"integrations": [
"core",
"storage",
"crm",
"hospitality",
"marketplace",
"sports_center",
"communication",
"loyalty",
"accounting",
"delivery",
"ai",
"automation",
],
"integration_mode": "api_and_events_only",
},
}
@router.get("/metrics")
async def metrics():
return {
"service": settings.service_name,
"version": __version__,
"phase": "11.10",
"metrics": {
"workspaces": "tenant_scoped",
"sites": "tenant_scoped",
"pages": "tenant_scoped",
"site_domains": "tenant_scoped",
"components": "tenant_scoped",
"component_versions": "tenant_scoped",
"page_component_placements": "tenant_scoped",
"themes": "tenant_scoped",
"theme_versions": "tenant_scoped",
"layouts": "tenant_scoped",
"layout_versions": "tenant_scoped",
"site_theme_bindings": "tenant_scoped",
"page_layout_bindings": "tenant_scoped",
"templates": "tenant_scoped",
"template_versions": "tenant_scoped",
"template_instantiations": "tenant_scoped",
"site_locale_bindings": "tenant_scoped",
"localizations": "tenant_scoped",
"media_refs": "tenant_scoped",
"forms": "tenant_scoped",
"form_submissions": "tenant_scoped",
"surveys": "tenant_scoped",
"survey_responses": "tenant_scoped",
"appointment_pages": "tenant_scoped",
"appointment_booking_refs": "tenant_scoped",
"publish_releases": "tenant_scoped",
"domain_edge_bindings": "tenant_scoped",
"seo_metadata": "tenant_scoped",
"sitemap_shells": "tenant_scoped",
"robots_shells": "tenant_scoped",
"pwa_manifests": "tenant_scoped",
"bundle_definitions": "tenant_scoped",
"tenant_bundles": "tenant_scoped",
"license_bindings": "tenant_scoped",
"feature_toggles": "tenant_scoped",
"consumer_connector_registrations": "tenant_scoped",
"consumer_connector_dispatches": "tenant_scoped",
"widget_definitions": "tenant_scoped",
"widget_instances": "tenant_scoped",
"notify_dispatches": "tenant_scoped",
"analytics_report_definitions": "tenant_scoped",
"analytics_snapshots": "tenant_scoped",
"ai_content_hook_registrations": "tenant_scoped",
"ai_content_dispatches": "tenant_scoped",
"published_component_versions": "tenant_scoped",
"locale_profiles": "tenant_scoped",
"external_providers": "tenant_scoped",
"render_engines": "tenant_scoped",
"configurations": "tenant_scoped",
"published_pages": "tenant_scoped",
"active_sites": "tenant_scoped",
},
"note": "Operational counters reserved for later phases; foundation exposes discovery only",
}

View File

@ -0,0 +1,124 @@
"""Layout version APIs — Phase 11.3 (versioned layouts)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.themes import ThemeLayoutCommands
from app.permissions.definitions import (
LAYOUTS_CREATE,
LAYOUTS_MANAGE,
LAYOUTS_UPDATE,
LAYOUTS_VIEW,
)
from app.queries.themes import ThemeLayoutQueries
from app.schemas.themes import (
ExperienceLayoutVersionCreate,
ExperienceLayoutVersionListResponse,
ExperienceLayoutVersionRead,
ExperienceLayoutVersionUpdate,
)
from app.specifications.themes import LAYOUT_VERSION_SORT_FIELDS, LayoutVersionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "published", "retired"}
def _list_spec(
layout_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
sort_by: str = Query(default="version_number"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> LayoutVersionListSpec:
if sort_by not in LAYOUT_VERSION_SORT_FIELDS:
sort_by = "version_number"
return LayoutVersionListSpec(
layout_id=layout_id,
status=status_filter if status_filter in _STATUSES else None,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceLayoutVersionRead, status_code=status.HTTP_201_CREATED
)
async def create_layout_version(
body: ExperienceLayoutVersionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_CREATE)),
):
return await ThemeLayoutCommands(db).create_layout_version(
tenant_id, body, actor=user
)
@router.get("", response_model=ExperienceLayoutVersionListResponse)
async def list_layout_versions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: LayoutVersionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)),
):
items, total = await ThemeLayoutQueries(db).list_layout_versions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceLayoutVersionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{version_id}", response_model=ExperienceLayoutVersionRead)
async def get_layout_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)),
):
return await ThemeLayoutQueries(db).get_layout_version(tenant_id, version_id)
@router.patch("/{version_id}", response_model=ExperienceLayoutVersionRead)
async def update_layout_version(
version_id: UUID,
body: ExperienceLayoutVersionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_UPDATE)),
):
return await ThemeLayoutCommands(db).update_layout_version(
tenant_id, version_id, body, actor=user
)
@router.post("/{version_id}/publish", response_model=ExperienceLayoutVersionRead)
async def publish_layout_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)),
):
return await ThemeLayoutCommands(db).publish_layout_version(
tenant_id, version_id, actor=user
)
@router.post("/{version_id}/retire", response_model=ExperienceLayoutVersionRead)
async def retire_layout_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)),
):
return await ThemeLayoutCommands(db).retire_layout_version(
tenant_id, version_id, actor=user
)

View File

@ -0,0 +1,157 @@
"""Layout APIs — Phase 11.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.themes import ThemeLayoutCommands
from app.permissions.definitions import (
LAYOUTS_CREATE,
LAYOUTS_DELETE,
LAYOUTS_MANAGE,
LAYOUTS_UPDATE,
LAYOUTS_VIEW,
)
from app.queries.themes import ThemeLayoutQueries
from app.schemas.themes import (
ExperienceLayoutCreate,
ExperienceLayoutListResponse,
ExperienceLayoutRead,
ExperienceLayoutUpdate,
)
from app.specifications.themes import LAYOUT_SORT_FIELDS, LayoutListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "deprecated", "archived"}
_DIRECTIONS = {"rtl", "ltr", "both"}
_KINDS = {"page_shell", "app_shell", "sidebar", "split", "custom"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
direction_mode: str | None = Query(default=None),
kind: str | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> LayoutListSpec:
if sort_by not in LAYOUT_SORT_FIELDS:
sort_by = "created_at"
return LayoutListSpec(
workspace_id=workspace_id,
status=status_filter if status_filter in _STATUSES else None,
direction_mode=direction_mode if direction_mode in _DIRECTIONS else None,
kind=kind if kind in _KINDS else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceLayoutRead, status_code=status.HTTP_201_CREATED
)
async def create_layout(
body: ExperienceLayoutCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_CREATE)),
):
return await ThemeLayoutCommands(db).create_layout(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceLayoutListResponse)
async def list_layouts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: LayoutListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)),
):
items, total = await ThemeLayoutQueries(db).list_layouts(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceLayoutListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{layout_id}", response_model=ExperienceLayoutRead)
async def get_layout(
layout_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)),
):
return await ThemeLayoutQueries(db).get_layout(tenant_id, layout_id)
@router.patch("/{layout_id}", response_model=ExperienceLayoutRead)
async def update_layout(
layout_id: UUID,
body: ExperienceLayoutUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_UPDATE)),
):
return await ThemeLayoutCommands(db).update_layout(
tenant_id, layout_id, body, actor=user
)
@router.post("/{layout_id}/activate", response_model=ExperienceLayoutRead)
async def activate_layout(
layout_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)),
):
return await ThemeLayoutCommands(db).activate_layout(
tenant_id, layout_id, actor=user
)
@router.post("/{layout_id}/deprecate", response_model=ExperienceLayoutRead)
async def deprecate_layout(
layout_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)),
):
return await ThemeLayoutCommands(db).deprecate_layout(
tenant_id, layout_id, actor=user
)
@router.post("/{layout_id}/archive", response_model=ExperienceLayoutRead)
async def archive_layout(
layout_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_MANAGE)),
):
return await ThemeLayoutCommands(db).archive_layout(
tenant_id, layout_id, actor=user
)
@router.post("/{layout_id}/delete", response_model=ExperienceLayoutRead)
async def soft_delete_layout(
layout_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_DELETE)),
):
return await ThemeLayoutCommands(db).soft_delete_layout(
tenant_id, layout_id, actor=user
)

View File

@ -0,0 +1,49 @@
"""Core entitlement reference binding APIs — Phase 11.8."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.bundles import BundlesCommands
from app.permissions.definitions import BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_UPDATE, BUNDLES_VIEW
from app.queries.bundles import BundlesQueries
from app.schemas.bundles import LicenseBindingCreate, LicenseBindingListResponse, LicenseBindingRead, LicenseBindingUpdate
from app.specifications.bundles import LICENSE_BINDING_SORT, LicenseBindingListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(workspace_id: UUID | None = None, status_filter: str | None = Query(None, alias="status"),
bundle_key: str | None = None, q: str | None = Query(None, max_length=100), sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")):
return LicenseBindingListSpec(workspace_id=workspace_id, status=status_filter, bundle_key=bundle_key, q=q,
sort_by=sort_by if sort_by in LICENSE_BINDING_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=LicenseBindingRead, status_code=201)
async def create(body: LicenseBindingCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE))):
return await BundlesCommands(db).create_license_binding(tenant_id, body, actor=user)
@router.get("", response_model=LicenseBindingListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: LicenseBindingListSpec = Depends(_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))):
items, total = await BundlesQueries(db).list_license_bindings(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return LicenseBindingListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=LicenseBindingRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))):
return await BundlesQueries(db).get_license_binding(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=LicenseBindingRead)
async def update(entity_id: UUID, body: LicenseBindingUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE))):
return await BundlesCommands(db).update_license_binding(tenant_id, entity_id, body, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE))):
await BundlesCommands(db).delete_license_binding(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,70 @@
"""Experience hub APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import LOCALE_PROFILES_CREATE, LOCALE_PROFILES_DELETE, LOCALE_PROFILES_UPDATE, LOCALE_PROFILES_VIEW
from app.schemas.foundation import ExperienceLocaleProfileCreate, ExperienceLocaleProfileRead, ExperienceLocaleProfileUpdate
from app.services.foundation import ExperienceLocaleProfileService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExperienceLocaleProfileRead, status_code=status.HTTP_201_CREATED)
async def create_locale_profile(
body: ExperienceLocaleProfileCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_CREATE)),
):
return await ExperienceLocaleProfileService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperienceLocaleProfileRead])
async def list_locale_profiles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_VIEW)),
):
return await ExperienceLocaleProfileService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{locale_profile_id}", response_model=ExperienceLocaleProfileRead)
async def get_locale_profile(
locale_profile_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_VIEW)),
):
return await ExperienceLocaleProfileService(db).get(tenant_id, locale_profile_id)
@router.patch("/{locale_profile_id}", response_model=ExperienceLocaleProfileRead)
async def update_locale_profile(
locale_profile_id: UUID,
body: ExperienceLocaleProfileUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_UPDATE)),
):
return await ExperienceLocaleProfileService(db).update(tenant_id, locale_profile_id, body, actor=user)
@router.post("/{locale_profile_id}/delete", response_model=ExperienceLocaleProfileRead)
async def soft_delete_locale_profile(
locale_profile_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALE_PROFILES_DELETE)),
):
return await ExperienceLocaleProfileService(db).soft_delete(tenant_id, locale_profile_id, actor=user)

View File

@ -0,0 +1,137 @@
"""Localization content APIs — Phase 11.5."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.localization import LocalizationCommands
from app.permissions.definitions import (
LOCALES_CREATE,
LOCALES_DELETE,
LOCALES_UPDATE,
LOCALES_VIEW,
)
from app.queries.localization import LocalizationQueries
from app.schemas.localization import (
ExperienceLocalizationListResponse,
ExperienceLocalizationRead,
ExperienceLocalizationUpdate,
ExperienceLocalizationUpsert,
)
from app.specifications.localization import (
LOCALIZATION_SORT_FIELDS,
LocalizationListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "archived"}
_TARGETS = {
"site",
"page",
"component_placement",
"template",
"theme",
"layout",
"media_ref",
}
_DIRECTIONS = {"rtl", "ltr"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
target_type: str | None = Query(default=None, max_length=50),
target_id: UUID | None = Query(default=None),
locale: str | None = Query(default=None, max_length=16),
text_direction: str | None = Query(default=None, max_length=3),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> LocalizationListSpec:
if sort_by not in LOCALIZATION_SORT_FIELDS:
sort_by = "created_at"
return LocalizationListSpec(
workspace_id=workspace_id,
target_type=target_type if target_type in _TARGETS else None,
target_id=target_id,
locale=locale,
text_direction=text_direction if text_direction in _DIRECTIONS else None,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=ExperienceLocalizationRead,
status_code=status.HTTP_201_CREATED,
)
async def upsert_localization(
body: ExperienceLocalizationUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALES_CREATE)),
):
return await LocalizationCommands(db).upsert_localization(
tenant_id, body, actor=user
)
@router.get("", response_model=ExperienceLocalizationListResponse)
async def list_localizations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: LocalizationListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)),
):
items, total = await LocalizationQueries(db).list_localizations(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceLocalizationListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{localization_id}", response_model=ExperienceLocalizationRead)
async def get_localization(
localization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)),
):
return await LocalizationQueries(db).get_localization(tenant_id, localization_id)
@router.patch("/{localization_id}", response_model=ExperienceLocalizationRead)
async def update_localization(
localization_id: UUID,
body: ExperienceLocalizationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALES_UPDATE)),
):
return await LocalizationCommands(db).update_localization(
tenant_id, localization_id, body, actor=user
)
@router.delete("/{localization_id}", response_model=ExperienceLocalizationRead)
async def delete_localization(
localization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALES_DELETE)),
):
return await LocalizationCommands(db).soft_delete_localization(
tenant_id, localization_id, actor=user
)

View File

@ -0,0 +1,125 @@
"""Media reference APIs — Phase 11.5 (Storage refs only)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.localization import LocalizationCommands
from app.permissions.definitions import (
MEDIA_REFS_CREATE,
MEDIA_REFS_DELETE,
MEDIA_REFS_UPDATE,
MEDIA_REFS_VIEW,
)
from app.queries.localization import LocalizationQueries
from app.schemas.localization import (
ExperienceMediaRefCreate,
ExperienceMediaRefListResponse,
ExperienceMediaRefRead,
ExperienceMediaRefUpdate,
)
from app.specifications.localization import MEDIA_REF_SORT_FIELDS, MediaRefListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_KINDS = {"image", "video", "document", "audio", "icon", "other"}
_STATUSES = {"draft", "active", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
page_id: UUID | None = Query(default=None),
owner_type: str | None = Query(default=None, max_length=50),
owner_id: UUID | None = Query(default=None),
kind: str | None = Query(default=None, max_length=20),
locale: str | None = Query(default=None, max_length=16),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="sort_order"),
sort_dir: str = Query(default="asc", pattern="^(?i)(asc|desc)$"),
) -> MediaRefListSpec:
if sort_by not in MEDIA_REF_SORT_FIELDS:
sort_by = "sort_order"
return MediaRefListSpec(
workspace_id=workspace_id,
site_id=site_id,
page_id=page_id,
owner_type=owner_type,
owner_id=owner_id,
kind=kind if kind in _KINDS else None,
locale=locale,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceMediaRefRead, status_code=status.HTTP_201_CREATED
)
async def create_media_ref(
body: ExperienceMediaRefCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(MEDIA_REFS_CREATE)),
):
return await LocalizationCommands(db).create_media_ref(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceMediaRefListResponse)
async def list_media_refs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: MediaRefListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(MEDIA_REFS_VIEW)),
):
items, total = await LocalizationQueries(db).list_media_refs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceMediaRefListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{media_ref_id}", response_model=ExperienceMediaRefRead)
async def get_media_ref(
media_ref_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(MEDIA_REFS_VIEW)),
):
return await LocalizationQueries(db).get_media_ref(tenant_id, media_ref_id)
@router.patch("/{media_ref_id}", response_model=ExperienceMediaRefRead)
async def update_media_ref(
media_ref_id: UUID,
body: ExperienceMediaRefUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(MEDIA_REFS_UPDATE)),
):
return await LocalizationCommands(db).update_media_ref(
tenant_id, media_ref_id, body, actor=user
)
@router.delete("/{media_ref_id}", response_model=ExperienceMediaRefRead)
async def delete_media_ref(
media_ref_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(MEDIA_REFS_DELETE)),
):
return await LocalizationCommands(db).soft_delete_media_ref(
tenant_id, media_ref_id, actor=user
)

View File

@ -0,0 +1,116 @@
"""Notify dispatch APIs — Phase 11.9."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.integrations import IntegrationsCommands
from app.permissions.definitions import (
NOTIFY_CREATE,
NOTIFY_DELETE,
NOTIFY_MANAGE,
NOTIFY_UPDATE,
NOTIFY_VIEW,
)
from app.queries.integrations import IntegrationsQueries
from app.schemas.integrations import (
NotifyDispatchCreate,
NotifyDispatchListResponse,
NotifyDispatchRead,
NotifyDispatchUpdate,
)
from app.specifications.integrations import NOTIFY_DISPATCH_SORT, NotifyDispatchListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return NotifyDispatchListSpec(
workspace_id=workspace_id,
site_id=site_id,
status=status_filter,
q=q,
sort_by=sort_by if sort_by in NOTIFY_DISPATCH_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=NotifyDispatchRead, status_code=201)
async def create(
body: NotifyDispatchCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(NOTIFY_CREATE)),
):
return await IntegrationsCommands(db).create_notify_dispatch(tenant_id, body, actor=user)
@router.get("", response_model=NotifyDispatchListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: NotifyDispatchListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(NOTIFY_VIEW)),
):
items, total = await IntegrationsQueries(db).list_notify_dispatches(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return NotifyDispatchListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=NotifyDispatchRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(NOTIFY_VIEW)),
):
return await IntegrationsQueries(db).get_notify_dispatch(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=NotifyDispatchRead)
async def update(
entity_id: UUID,
body: NotifyDispatchUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(NOTIFY_UPDATE)),
):
return await IntegrationsCommands(db).update_notify_dispatch(
tenant_id, entity_id, body, actor=user
)
@router.post("/{entity_id}/send", response_model=NotifyDispatchRead)
async def send(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(NOTIFY_MANAGE)),
):
return await IntegrationsCommands(db).send_notify_dispatch(tenant_id, entity_id, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(NOTIFY_DELETE)),
):
await IntegrationsCommands(db).delete_notify_dispatch(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,120 @@
"""Page component placement APIs — Phase 11.2 composition shells."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.components import ComponentCommands
from app.permissions.definitions import (
COMPONENTS_CREATE,
COMPONENTS_DELETE,
COMPONENTS_UPDATE,
COMPONENTS_VIEW,
)
from app.queries.components import ComponentQueries
from app.schemas.components import (
PageComponentPlacementCreate,
PageComponentPlacementListResponse,
PageComponentPlacementRead,
PageComponentPlacementUpdate,
)
from app.specifications.components import PLACEMENT_SORT_FIELDS, PlacementListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
page_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
component_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="sort_order"),
sort_dir: str = Query(default="asc", pattern="^(?i)(asc|desc)$"),
) -> PlacementListSpec:
if sort_by not in PLACEMENT_SORT_FIELDS:
sort_by = "sort_order"
return PlacementListSpec(
page_id=page_id,
site_id=site_id,
component_id=component_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=PageComponentPlacementRead,
status_code=status.HTTP_201_CREATED,
)
async def create_placement(
body: PageComponentPlacementCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_CREATE)),
):
return await ComponentCommands(db).create_placement(tenant_id, body, actor=user)
@router.get("", response_model=PageComponentPlacementListResponse)
async def list_placements(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: PlacementListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)),
):
items, total = await ComponentQueries(db).list_placements(
tenant_id,
offset=pagination.offset,
limit=pagination.page_size,
spec=spec,
)
return PageComponentPlacementListResponse(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
@router.get("/{placement_id}", response_model=PageComponentPlacementRead)
async def get_placement(
placement_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(COMPONENTS_VIEW)),
):
return await ComponentQueries(db).get_placement(tenant_id, placement_id)
@router.patch("/{placement_id}", response_model=PageComponentPlacementRead)
async def update_placement(
placement_id: UUID,
body: PageComponentPlacementUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_UPDATE)),
):
return await ComponentCommands(db).update_placement(
tenant_id, placement_id, body, actor=user
)
@router.post("/{placement_id}/delete", response_model=PageComponentPlacementRead)
async def soft_delete_placement(
placement_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(COMPONENTS_DELETE)),
):
return await ComponentCommands(db).soft_delete_placement(
tenant_id, placement_id, actor=user
)

View File

@ -0,0 +1,100 @@
"""Page layout binding APIs — Phase 11.3 (versioned bindings)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.themes import ThemeLayoutCommands
from app.permissions.definitions import LAYOUTS_CREATE, LAYOUTS_DELETE, LAYOUTS_VIEW
from app.queries.themes import ThemeLayoutQueries
from app.schemas.themes import (
PageLayoutBindingCreate,
PageLayoutBindingListResponse,
PageLayoutBindingRead,
)
from app.specifications.themes import (
PAGE_LAYOUT_BINDING_SORT_FIELDS,
PageLayoutBindingListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
page_id: UUID | None = Query(default=None),
layout_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
workspace_id: UUID | None = Query(default=None),
is_active: bool | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> PageLayoutBindingListSpec:
if sort_by not in PAGE_LAYOUT_BINDING_SORT_FIELDS:
sort_by = "created_at"
return PageLayoutBindingListSpec(
page_id=page_id,
layout_id=layout_id,
site_id=site_id,
workspace_id=workspace_id,
is_active=is_active,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=PageLayoutBindingRead,
status_code=status.HTTP_201_CREATED,
)
async def assign_page_layout(
body: PageLayoutBindingCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_CREATE)),
):
return await ThemeLayoutCommands(db).assign_page_layout(tenant_id, body, actor=user)
@router.get("", response_model=PageLayoutBindingListResponse)
async def list_page_layout_bindings(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: PageLayoutBindingListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)),
):
items, total = await ThemeLayoutQueries(db).list_page_layout_bindings(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return PageLayoutBindingListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{binding_id}", response_model=PageLayoutBindingRead)
async def get_page_layout_binding(
binding_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LAYOUTS_VIEW)),
):
return await ThemeLayoutQueries(db).get_page_layout_binding(tenant_id, binding_id)
@router.post("/{binding_id}/unassign", response_model=PageLayoutBindingRead)
async def unassign_page_layout(
binding_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LAYOUTS_DELETE)),
):
return await ThemeLayoutCommands(db).unassign_page_layout(
tenant_id, binding_id, actor=user
)

View File

@ -0,0 +1,112 @@
"""Pages APIs — Phase 11.1."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
PAGES_CREATE,
PAGES_DELETE,
PAGES_MANAGE,
PAGES_UPDATE,
PAGES_VIEW,
)
from app.schemas.sites import ExperiencePageCreate, ExperiencePageRead, ExperiencePageUpdate
from app.services.sites import ExperiencePageService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExperiencePageRead, status_code=status.HTTP_201_CREATED)
async def create_page(
body: ExperiencePageCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PAGES_CREATE)),
):
return await ExperiencePageService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperiencePageRead])
async def list_pages(
site_id: UUID | None = Query(default=None),
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PAGES_VIEW)),
):
svc = ExperiencePageService(db)
if site_id is not None:
return await svc.list_by_site(
tenant_id, site_id, offset=pagination.offset, limit=pagination.page_size
)
return await svc.list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{page_id}", response_model=ExperiencePageRead)
async def get_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PAGES_VIEW)),
):
return await ExperiencePageService(db).get(tenant_id, page_id)
@router.patch("/{page_id}", response_model=ExperiencePageRead)
async def update_page(
page_id: UUID,
body: ExperiencePageUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PAGES_UPDATE)),
):
return await ExperiencePageService(db).update(tenant_id, page_id, body, actor=user)
@router.post("/{page_id}/publish", response_model=ExperiencePageRead)
async def publish_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PAGES_MANAGE)),
):
return await ExperiencePageService(db).publish(tenant_id, page_id, actor=user)
@router.post("/{page_id}/unpublish", response_model=ExperiencePageRead)
async def unpublish_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PAGES_MANAGE)),
):
return await ExperiencePageService(db).unpublish(tenant_id, page_id, actor=user)
@router.post("/{page_id}/archive", response_model=ExperiencePageRead)
async def archive_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PAGES_MANAGE)),
):
return await ExperiencePageService(db).archive(tenant_id, page_id, actor=user)
@router.post("/{page_id}/delete", response_model=ExperiencePageRead)
async def soft_delete_page(
page_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PAGES_DELETE)),
):
return await ExperiencePageService(db).soft_delete(tenant_id, page_id, actor=user)

View File

@ -0,0 +1,50 @@
"""Publish release APIs — Phase 11.7."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.publishing import PublishingCommands
from app.permissions.definitions import PUBLISHING_CREATE, PUBLISHING_UPDATE, PUBLISHING_VIEW
from app.queries.publishing import PublishingQueries
from app.schemas.publishing import PublishReleaseCreate, PublishReleaseListResponse, PublishReleaseRead, PublishReleaseUpdate
from app.specifications.publishing import PUBLISH_SORT, PublishReleaseListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(workspace_id: UUID | None = None, target_type: str | None = None, target_id: UUID | None = None,
status_filter: str | None = Query(default=None, alias="status"), q: str | None = Query(default=None, max_length=100),
sort_by: str = "created_at", sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")):
return PublishReleaseListSpec(workspace_id=workspace_id, target_type=target_type, target_id=target_id, status=status_filter,
q=q, sort_by=sort_by if sort_by in PUBLISH_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=PublishReleaseRead, status_code=status.HTTP_201_CREATED)
async def create(body: PublishReleaseCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PUBLISHING_CREATE))):
return await PublishingCommands(db).create_publish_release(tenant_id, body, actor=user)
@router.get("", response_model=PublishReleaseListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: PublishReleaseListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))):
items, total = await PublishingQueries(db).list_publish_releases(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return PublishReleaseListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=PublishReleaseRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PUBLISHING_VIEW))):
return await PublishingQueries(db).get_publish_release(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=PublishReleaseRead)
async def update(entity_id: UUID, body: PublishReleaseUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PUBLISHING_UPDATE))):
return await PublishingCommands(db).update_publish_release(tenant_id, entity_id, body, actor=user)

View File

@ -0,0 +1,33 @@
"""PWA manifest APIs — Phase 11.7."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.publishing import PublishingCommands
from app.permissions.definitions import PWA_CREATE, PWA_UPDATE, PWA_VIEW
from app.queries.publishing import PublishingQueries
from app.schemas.publishing import PwaManifestListResponse, PwaManifestRead, PwaManifestUpdate, PwaManifestUpsert
from app.specifications.publishing import PWA_SORT, PwaManifestListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100), sort_by: str = "created_at",
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")):
return PwaManifestListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, q=q,
sort_by=sort_by if sort_by in PWA_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=PwaManifestRead, status_code=status.HTTP_201_CREATED)
async def upsert(body: PwaManifestUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PWA_CREATE))):
return await PublishingCommands(db).upsert_pwa(tenant_id, body, actor=user)
@router.get("", response_model=PwaManifestListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), spec: PwaManifestListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(PWA_VIEW))):
items, total = await PublishingQueries(db).list_pwa(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return PwaManifestListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=PwaManifestRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(PWA_VIEW))):
return await PublishingQueries(db).get_pwa(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=PwaManifestRead)
async def update(entity_id: UUID, body: PwaManifestUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(PWA_UPDATE))):
return await PublishingCommands(db).update_pwa(tenant_id, entity_id, body, actor=user)

View File

@ -0,0 +1,83 @@
"""Routing engine registration APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
RENDER_ENGINES_CREATE,
RENDER_ENGINES_DELETE,
RENDER_ENGINES_UPDATE,
RENDER_ENGINES_VIEW,
)
from app.schemas.foundation import (
RenderEngineRegistrationCreate,
RenderEngineRegistrationRead,
RenderEngineRegistrationUpdate,
)
from app.services.foundation import RenderEngineRegistrationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=RenderEngineRegistrationRead, status_code=status.HTTP_201_CREATED)
async def create_engine(
body: RenderEngineRegistrationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_CREATE)),
):
return await RenderEngineRegistrationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[RenderEngineRegistrationRead])
async def list_engines(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_VIEW)),
):
return await RenderEngineRegistrationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{engine_id}", response_model=RenderEngineRegistrationRead)
async def get_engine(
engine_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_VIEW)),
):
return await RenderEngineRegistrationService(db).get(tenant_id, engine_id)
@router.patch("/{engine_id}", response_model=RenderEngineRegistrationRead)
async def update_engine(
engine_id: UUID,
body: RenderEngineRegistrationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_UPDATE)),
):
return await RenderEngineRegistrationService(db).update(
tenant_id, engine_id, body, actor=user
)
@router.post("/{engine_id}/delete", response_model=RenderEngineRegistrationRead)
async def soft_delete_engine(
engine_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(RENDER_ENGINES_DELETE)),
):
return await RenderEngineRegistrationService(db).soft_delete(
tenant_id, engine_id, actor=user
)

View File

@ -0,0 +1,33 @@
"""Robots shell APIs — Phase 11.7."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.publishing import PublishingCommands
from app.permissions.definitions import SEO_CREATE, SEO_UPDATE, SEO_VIEW
from app.queries.publishing import PublishingQueries
from app.schemas.publishing import RobotsShellListResponse, RobotsShellRead, RobotsShellUpdate, RobotsShellUpsert
from app.specifications.publishing import ROBOTS_SORT, RobotsShellListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100), sort_by: str = "created_at",
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")):
return RobotsShellListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, q=q,
sort_by=sort_by if sort_by in ROBOTS_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=RobotsShellRead, status_code=status.HTTP_201_CREATED)
async def upsert(body: RobotsShellUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_CREATE))):
return await PublishingCommands(db).upsert_robots(tenant_id, body, actor=user)
@router.get("", response_model=RobotsShellListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), spec: RobotsShellListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))):
items, total = await PublishingQueries(db).list_robots(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return RobotsShellListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=RobotsShellRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))):
return await PublishingQueries(db).get_robots(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=RobotsShellRead)
async def update(entity_id: UUID, body: RobotsShellUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_UPDATE))):
return await PublishingCommands(db).update_robots(tenant_id, entity_id, body, actor=user)

View File

@ -0,0 +1,43 @@
"""SEO metadata APIs — Phase 11.7."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.publishing import PublishingCommands
from app.permissions.definitions import SEO_CREATE, SEO_UPDATE, SEO_VIEW
from app.queries.publishing import PublishingQueries
from app.schemas.publishing import SeoMetadataListResponse, SeoMetadataRead, SeoMetadataUpdate, SeoMetadataUpsert
from app.specifications.publishing import SEO_SORT, SeoMetadataListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(workspace_id: UUID | None = None, target_type: str | None = None, target_id: UUID | None = None,
status_filter: str | None = Query(default=None, alias="status"), q: str | None = Query(default=None, max_length=100),
sort_by: str = "created_at", sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")):
return SeoMetadataListSpec(workspace_id=workspace_id, target_type=target_type, target_id=target_id, status=status_filter,
q=q, sort_by=sort_by if sort_by in SEO_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=SeoMetadataRead, status_code=status.HTTP_201_CREATED)
async def upsert(body: SeoMetadataUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SEO_CREATE))):
return await PublishingCommands(db).upsert_seo(tenant_id, body, actor=user)
@router.get("", response_model=SeoMetadataListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: SeoMetadataListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SEO_VIEW))):
items, total = await PublishingQueries(db).list_seo(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return SeoMetadataListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=SeoMetadataRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SEO_VIEW))):
return await PublishingQueries(db).get_seo(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=SeoMetadataRead)
async def update(entity_id: UUID, body: SeoMetadataUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_UPDATE))):
return await PublishingCommands(db).update_seo(tenant_id, entity_id, body, actor=user)

View File

@ -0,0 +1,39 @@
"""Experience settings APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW
from app.schemas.foundation import ExperienceSettingRead, ExperienceSettingUpsert
from app.services.foundation import ExperienceSettingService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.put("", response_model=ExperienceSettingRead, status_code=status.HTTP_200_OK)
async def upsert_setting(
body: ExperienceSettingUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)),
):
return await ExperienceSettingService(db).upsert(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperienceSettingRead])
async def list_settings(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)),
):
return await ExperienceSettingService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)

View File

@ -0,0 +1,86 @@
"""Site domain APIs — Phase 11.1."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
DOMAINS_CREATE,
DOMAINS_DELETE,
DOMAINS_UPDATE,
DOMAINS_VIEW,
)
from app.schemas.sites import (
ExperienceSiteDomainCreate,
ExperienceSiteDomainRead,
ExperienceSiteDomainUpdate,
)
from app.services.sites import ExperienceSiteDomainService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post(
"", response_model=ExperienceSiteDomainRead, status_code=status.HTTP_201_CREATED
)
async def create_domain(
body: ExperienceSiteDomainCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DOMAINS_CREATE)),
):
return await ExperienceSiteDomainService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperienceSiteDomainRead])
async def list_domains(
site_id: UUID = Query(...),
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DOMAINS_VIEW)),
):
return await ExperienceSiteDomainService(db).list_by_site(
tenant_id, site_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{domain_id}", response_model=ExperienceSiteDomainRead)
async def get_domain(
domain_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DOMAINS_VIEW)),
):
return await ExperienceSiteDomainService(db).get(tenant_id, domain_id)
@router.patch("/{domain_id}", response_model=ExperienceSiteDomainRead)
async def update_domain(
domain_id: UUID,
body: ExperienceSiteDomainUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DOMAINS_UPDATE)),
):
return await ExperienceSiteDomainService(db).update(
tenant_id, domain_id, body, actor=user
)
@router.post("/{domain_id}/delete", response_model=ExperienceSiteDomainRead)
async def soft_delete_domain(
domain_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DOMAINS_DELETE)),
):
return await ExperienceSiteDomainService(db).soft_delete(
tenant_id, domain_id, actor=user
)

View File

@ -0,0 +1,127 @@
"""Site locale binding APIs — Phase 11.5."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.localization import LocalizationCommands
from app.permissions.definitions import (
LOCALES_CREATE,
LOCALES_DELETE,
LOCALES_UPDATE,
LOCALES_VIEW,
)
from app.queries.localization import LocalizationQueries
from app.schemas.localization import (
SiteLocaleBindingCreate,
SiteLocaleBindingListResponse,
SiteLocaleBindingRead,
SiteLocaleBindingUpdate,
)
from app.specifications.localization import (
SITE_LOCALE_BINDING_SORT_FIELDS,
SiteLocaleBindingListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_DIRECTIONS = {"rtl", "ltr"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
locale_profile_id: UUID | None = Query(default=None),
locale_code: str | None = Query(default=None, max_length=16),
text_direction: str | None = Query(default=None, max_length=3),
is_default: bool | None = Query(default=None),
is_active: bool | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="sort_order"),
sort_dir: str = Query(default="asc", pattern="^(?i)(asc|desc)$"),
) -> SiteLocaleBindingListSpec:
if sort_by not in SITE_LOCALE_BINDING_SORT_FIELDS:
sort_by = "sort_order"
return SiteLocaleBindingListSpec(
workspace_id=workspace_id,
site_id=site_id,
locale_profile_id=locale_profile_id,
locale_code=locale_code,
text_direction=text_direction if text_direction in _DIRECTIONS else None,
is_default=is_default,
is_active=is_active,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=SiteLocaleBindingRead, status_code=status.HTTP_201_CREATED
)
async def create_site_locale_binding(
body: SiteLocaleBindingCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALES_CREATE)),
):
return await LocalizationCommands(db).create_site_locale_binding(
tenant_id, body, actor=user
)
@router.get("", response_model=SiteLocaleBindingListResponse)
async def list_site_locale_bindings(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SiteLocaleBindingListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)),
):
items, total = await LocalizationQueries(db).list_site_locale_bindings(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return SiteLocaleBindingListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{binding_id}", response_model=SiteLocaleBindingRead)
async def get_site_locale_binding(
binding_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(LOCALES_VIEW)),
):
return await LocalizationQueries(db).get_site_locale_binding(tenant_id, binding_id)
@router.patch("/{binding_id}", response_model=SiteLocaleBindingRead)
async def update_site_locale_binding(
binding_id: UUID,
body: SiteLocaleBindingUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALES_UPDATE)),
):
return await LocalizationCommands(db).update_site_locale_binding(
tenant_id, binding_id, body, actor=user
)
@router.delete("/{binding_id}", response_model=SiteLocaleBindingRead)
async def delete_site_locale_binding(
binding_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(LOCALES_DELETE)),
):
return await LocalizationCommands(db).soft_delete_site_locale_binding(
tenant_id, binding_id, actor=user
)

View File

@ -0,0 +1,98 @@
"""Site theme binding APIs — Phase 11.3 (versioned bindings)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.themes import ThemeLayoutCommands
from app.permissions.definitions import THEMES_CREATE, THEMES_DELETE, THEMES_VIEW
from app.queries.themes import ThemeLayoutQueries
from app.schemas.themes import (
SiteThemeBindingCreate,
SiteThemeBindingListResponse,
SiteThemeBindingRead,
)
from app.specifications.themes import (
SITE_THEME_BINDING_SORT_FIELDS,
SiteThemeBindingListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
site_id: UUID | None = Query(default=None),
theme_id: UUID | None = Query(default=None),
workspace_id: UUID | None = Query(default=None),
is_active: bool | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> SiteThemeBindingListSpec:
if sort_by not in SITE_THEME_BINDING_SORT_FIELDS:
sort_by = "created_at"
return SiteThemeBindingListSpec(
site_id=site_id,
theme_id=theme_id,
workspace_id=workspace_id,
is_active=is_active,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=SiteThemeBindingRead,
status_code=status.HTTP_201_CREATED,
)
async def assign_site_theme(
body: SiteThemeBindingCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_CREATE)),
):
return await ThemeLayoutCommands(db).assign_site_theme(tenant_id, body, actor=user)
@router.get("", response_model=SiteThemeBindingListResponse)
async def list_site_theme_bindings(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SiteThemeBindingListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(THEMES_VIEW)),
):
items, total = await ThemeLayoutQueries(db).list_site_theme_bindings(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return SiteThemeBindingListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{binding_id}", response_model=SiteThemeBindingRead)
async def get_site_theme_binding(
binding_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(THEMES_VIEW)),
):
return await ThemeLayoutQueries(db).get_site_theme_binding(tenant_id, binding_id)
@router.post("/{binding_id}/unassign", response_model=SiteThemeBindingRead)
async def unassign_site_theme(
binding_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_DELETE)),
):
return await ThemeLayoutCommands(db).unassign_site_theme(
tenant_id, binding_id, actor=user
)

View File

@ -0,0 +1,33 @@
"""Sitemap shell APIs — Phase 11.7."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.publishing import PublishingCommands
from app.permissions.definitions import SEO_CREATE, SEO_UPDATE, SEO_VIEW
from app.queries.publishing import PublishingQueries
from app.schemas.publishing import SitemapShellListResponse, SitemapShellRead, SitemapShellUpdate, SitemapShellUpsert
from app.specifications.publishing import SITEMAP_SORT, SitemapShellListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(workspace_id: UUID | None = None, site_id: UUID | None = None, status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100), sort_by: str = "created_at",
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$")):
return SitemapShellListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, q=q,
sort_by=sort_by if sort_by in SITEMAP_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=SitemapShellRead, status_code=status.HTTP_201_CREATED)
async def upsert(body: SitemapShellUpsert, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_CREATE))):
return await PublishingCommands(db).upsert_sitemap(tenant_id, body, actor=user)
@router.get("", response_model=SitemapShellListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination), spec: SitemapShellListSpec = Depends(_list_spec), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))):
items, total = await PublishingQueries(db).list_sitemaps(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return SitemapShellListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=SitemapShellRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), _user: CurrentUser = Depends(require_permissions(SEO_VIEW))):
return await PublishingQueries(db).get_sitemap(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=SitemapShellRead)
async def update(entity_id: UUID, body: SitemapShellUpdate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(SEO_UPDATE))):
return await PublishingCommands(db).update_sitemap(tenant_id, entity_id, body, actor=user)

View File

@ -0,0 +1,115 @@
"""Sites APIs — Phase 11.1."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
SITES_CREATE,
SITES_DELETE,
SITES_MANAGE,
SITES_UPDATE,
SITES_VIEW,
)
from app.schemas.sites import ExperienceSiteCreate, ExperienceSiteRead, ExperienceSiteUpdate
from app.services.sites import ExperienceSiteService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExperienceSiteRead, status_code=status.HTTP_201_CREATED)
async def create_site(
body: ExperienceSiteCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SITES_CREATE)),
):
return await ExperienceSiteService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperienceSiteRead])
async def list_sites(
workspace_id: UUID | None = Query(default=None),
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SITES_VIEW)),
):
svc = ExperienceSiteService(db)
if workspace_id is not None:
return await svc.list_by_workspace(
tenant_id,
workspace_id,
offset=pagination.offset,
limit=pagination.page_size,
)
return await svc.list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{site_id}", response_model=ExperienceSiteRead)
async def get_site(
site_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SITES_VIEW)),
):
return await ExperienceSiteService(db).get(tenant_id, site_id)
@router.patch("/{site_id}", response_model=ExperienceSiteRead)
async def update_site(
site_id: UUID,
body: ExperienceSiteUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SITES_UPDATE)),
):
return await ExperienceSiteService(db).update(tenant_id, site_id, body, actor=user)
@router.post("/{site_id}/publish", response_model=ExperienceSiteRead)
async def publish_site(
site_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SITES_MANAGE)),
):
return await ExperienceSiteService(db).publish(tenant_id, site_id, actor=user)
@router.post("/{site_id}/unpublish", response_model=ExperienceSiteRead)
async def unpublish_site(
site_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SITES_MANAGE)),
):
return await ExperienceSiteService(db).unpublish(tenant_id, site_id, actor=user)
@router.post("/{site_id}/archive", response_model=ExperienceSiteRead)
async def archive_site(
site_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SITES_MANAGE)),
):
return await ExperienceSiteService(db).archive(tenant_id, site_id, actor=user)
@router.post("/{site_id}/delete", response_model=ExperienceSiteRead)
async def soft_delete_site(
site_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SITES_DELETE)),
):
return await ExperienceSiteService(db).soft_delete(tenant_id, site_id, actor=user)

View File

@ -0,0 +1,96 @@
"""Survey response APIs — Phase 11.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.forms import FormsCommands
from app.permissions.definitions import SURVEYS_CREATE, SURVEYS_UPDATE, SURVEYS_VIEW
from app.queries.forms import FormsQueries
from app.schemas.forms import (
SurveyResponseCreate,
SurveyResponseListResponse,
SurveyResponseRead,
SurveyResponseUpdate,
)
from app.specifications.forms import RESPONSE_SORT_FIELDS, SurveyResponseListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"received", "processed", "failed", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
survey_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> SurveyResponseListSpec:
if sort_by not in RESPONSE_SORT_FIELDS:
sort_by = "created_at"
return SurveyResponseListSpec(
workspace_id=workspace_id,
survey_id=survey_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=SurveyResponseRead, status_code=status.HTTP_201_CREATED
)
async def create_response(
body: SurveyResponseCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SURVEYS_CREATE)),
):
return await FormsCommands(db).create_survey_response(tenant_id, body, actor=user)
@router.get("", response_model=SurveyResponseListResponse)
async def list_responses(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SurveyResponseListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)),
):
items, total = await FormsQueries(db).list_survey_responses(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return SurveyResponseListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{response_id}", response_model=SurveyResponseRead)
async def get_response(
response_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)),
):
return await FormsQueries(db).get_survey_response(tenant_id, response_id)
@router.patch("/{response_id}", response_model=SurveyResponseRead)
async def update_response(
response_id: UUID,
body: SurveyResponseUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SURVEYS_UPDATE)),
):
return await FormsCommands(db).update_survey_response(
tenant_id, response_id, body, actor=user
)

View File

@ -0,0 +1,113 @@
"""Survey definition APIs — Phase 11.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.forms import FormsCommands
from app.permissions.definitions import (
SURVEYS_CREATE,
SURVEYS_DELETE,
SURVEYS_UPDATE,
SURVEYS_VIEW,
)
from app.queries.forms import FormsQueries
from app.schemas.forms import (
ExperienceSurveyCreate,
ExperienceSurveyListResponse,
ExperienceSurveyRead,
ExperienceSurveyUpdate,
)
from app.specifications.forms import SURVEY_SORT_FIELDS, SurveyListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
page_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> SurveyListSpec:
if sort_by not in SURVEY_SORT_FIELDS:
sort_by = "created_at"
return SurveyListSpec(
workspace_id=workspace_id,
site_id=site_id,
page_id=page_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceSurveyRead, status_code=status.HTTP_201_CREATED
)
async def create_survey(
body: ExperienceSurveyCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SURVEYS_CREATE)),
):
return await FormsCommands(db).create_survey(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceSurveyListResponse)
async def list_surveys(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SurveyListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)),
):
items, total = await FormsQueries(db).list_surveys(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceSurveyListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{survey_id}", response_model=ExperienceSurveyRead)
async def get_survey(
survey_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SURVEYS_VIEW)),
):
return await FormsQueries(db).get_survey(tenant_id, survey_id)
@router.patch("/{survey_id}", response_model=ExperienceSurveyRead)
async def update_survey(
survey_id: UUID,
body: ExperienceSurveyUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SURVEYS_UPDATE)),
):
return await FormsCommands(db).update_survey(
tenant_id, survey_id, body, actor=user
)
@router.delete("/{survey_id}", response_model=ExperienceSurveyRead)
async def delete_survey(
survey_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SURVEYS_DELETE)),
):
return await FormsCommands(db).soft_delete_survey(tenant_id, survey_id, actor=user)

View File

@ -0,0 +1,94 @@
"""Template instantiation APIs — Phase 11.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.templates import TemplateCommands
from app.permissions.definitions import TEMPLATES_CREATE, TEMPLATES_VIEW
from app.queries.templates import TemplateQueries
from app.schemas.templates import (
TemplateInstantiateRequest,
TemplateInstantiateResponse,
TemplateInstantiationListResponse,
TemplateInstantiationRead,
)
from app.specifications.templates import INSTANTIATION_SORT_FIELDS, InstantiationListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"pending", "completed", "failed"}
def _list_spec(
template_id: UUID | None = Query(default=None),
site_id: UUID | None = Query(default=None),
page_id: UUID | None = Query(default=None),
workspace_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> InstantiationListSpec:
if sort_by not in INSTANTIATION_SORT_FIELDS:
sort_by = "created_at"
return InstantiationListSpec(
template_id=template_id,
site_id=site_id,
page_id=page_id,
workspace_id=workspace_id,
status=status_filter if status_filter in _STATUSES else None,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=TemplateInstantiateResponse,
status_code=status.HTTP_201_CREATED,
)
async def instantiate_template(
body: TemplateInstantiateRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_CREATE)),
):
record, page = await TemplateCommands(db).instantiate(
tenant_id, body, actor=user
)
return TemplateInstantiateResponse(
instantiation=record,
page_id=page.id if page is not None else record.page_id,
)
@router.get("", response_model=TemplateInstantiationListResponse)
async def list_instantiations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: InstantiationListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)),
):
items, total = await TemplateQueries(db).list_instantiations(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return TemplateInstantiationListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{instantiation_id}", response_model=TemplateInstantiationRead)
async def get_instantiation(
instantiation_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)),
):
return await TemplateQueries(db).get_instantiation(tenant_id, instantiation_id)

View File

@ -0,0 +1,129 @@
"""Template version APIs — Phase 11.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.templates import TemplateCommands
from app.permissions.definitions import (
TEMPLATES_CREATE,
TEMPLATES_MANAGE,
TEMPLATES_UPDATE,
TEMPLATES_VIEW,
)
from app.queries.templates import TemplateQueries
from app.schemas.templates import (
ExperienceTemplateVersionCreate,
ExperienceTemplateVersionListResponse,
ExperienceTemplateVersionRead,
ExperienceTemplateVersionUpdate,
)
from app.specifications.templates import (
TEMPLATE_VERSION_SORT_FIELDS,
TemplateVersionListSpec,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "published", "retired"}
def _list_spec(
template_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="version_number"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> TemplateVersionListSpec:
if sort_by not in TEMPLATE_VERSION_SORT_FIELDS:
sort_by = "version_number"
return TemplateVersionListSpec(
template_id=template_id,
status=status_filter if status_filter in _STATUSES else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"",
response_model=ExperienceTemplateVersionRead,
status_code=status.HTTP_201_CREATED,
)
async def create_version(
body: ExperienceTemplateVersionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_CREATE)),
):
return await TemplateCommands(db).create_version(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceTemplateVersionListResponse)
async def list_versions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: TemplateVersionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)),
):
items, total = await TemplateQueries(db).list_versions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceTemplateVersionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{version_id}", response_model=ExperienceTemplateVersionRead)
async def get_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)),
):
return await TemplateQueries(db).get_version(tenant_id, version_id)
@router.patch("/{version_id}", response_model=ExperienceTemplateVersionRead)
async def update_version(
version_id: UUID,
body: ExperienceTemplateVersionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_UPDATE)),
):
return await TemplateCommands(db).update_version(
tenant_id, version_id, body, actor=user
)
@router.post("/{version_id}/publish", response_model=ExperienceTemplateVersionRead)
async def publish_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)),
):
return await TemplateCommands(db).publish_version(
tenant_id, version_id, actor=user
)
@router.post("/{version_id}/retire", response_model=ExperienceTemplateVersionRead)
async def retire_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)),
):
return await TemplateCommands(db).retire_version(
tenant_id, version_id, actor=user
)

View File

@ -0,0 +1,155 @@
"""Template catalog APIs — Phase 11.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.templates import TemplateCommands
from app.permissions.definitions import (
TEMPLATES_CREATE,
TEMPLATES_DELETE,
TEMPLATES_MANAGE,
TEMPLATES_UPDATE,
TEMPLATES_VIEW,
)
from app.queries.templates import TemplateQueries
from app.schemas.templates import (
ExperienceTemplateCreate,
ExperienceTemplateListResponse,
ExperienceTemplateRead,
ExperienceTemplateUpdate,
)
from app.specifications.templates import TEMPLATE_SORT_FIELDS, TemplateListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "deprecated", "archived"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
page_type: str | None = Query(default=None, max_length=50),
is_starter_pack: bool | None = Query(default=None),
category: str | None = Query(default=None, max_length=100),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> TemplateListSpec:
if sort_by not in TEMPLATE_SORT_FIELDS:
sort_by = "created_at"
return TemplateListSpec(
workspace_id=workspace_id,
status=status_filter if status_filter in _STATUSES else None,
page_type=page_type,
is_starter_pack=is_starter_pack,
category=category,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceTemplateRead, status_code=status.HTTP_201_CREATED
)
async def create_template(
body: ExperienceTemplateCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_CREATE)),
):
return await TemplateCommands(db).create_template(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceTemplateListResponse)
async def list_templates(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: TemplateListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)),
):
items, total = await TemplateQueries(db).list_templates(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceTemplateListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{template_id}", response_model=ExperienceTemplateRead)
async def get_template(
template_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TEMPLATES_VIEW)),
):
return await TemplateQueries(db).get_template(tenant_id, template_id)
@router.patch("/{template_id}", response_model=ExperienceTemplateRead)
async def update_template(
template_id: UUID,
body: ExperienceTemplateUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_UPDATE)),
):
return await TemplateCommands(db).update_template(
tenant_id, template_id, body, actor=user
)
@router.post("/{template_id}/activate", response_model=ExperienceTemplateRead)
async def activate_template(
template_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)),
):
return await TemplateCommands(db).activate_template(
tenant_id, template_id, actor=user
)
@router.post("/{template_id}/deprecate", response_model=ExperienceTemplateRead)
async def deprecate_template(
template_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)),
):
return await TemplateCommands(db).deprecate_template(
tenant_id, template_id, actor=user
)
@router.post("/{template_id}/archive", response_model=ExperienceTemplateRead)
async def archive_template(
template_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_MANAGE)),
):
return await TemplateCommands(db).archive_template(
tenant_id, template_id, actor=user
)
@router.post("/{template_id}/delete", response_model=ExperienceTemplateRead)
async def soft_delete_template(
template_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TEMPLATES_DELETE)),
):
return await TemplateCommands(db).soft_delete_template(
tenant_id, template_id, actor=user
)

View File

@ -0,0 +1,50 @@
"""Tenant bundle APIs — Phase 11.8."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.bundles import BundlesCommands
from app.permissions.definitions import BUNDLES_CREATE, BUNDLES_DELETE, BUNDLES_UPDATE, BUNDLES_VIEW
from app.queries.bundles import BundlesQueries
from app.schemas.bundles import TenantBundleCreate, TenantBundleListResponse, TenantBundleRead, TenantBundleUpdate
from app.specifications.bundles import TENANT_BUNDLE_SORT, TenantBundleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(workspace_id: UUID | None = None, site_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"), bundle_key: str | None = None,
q: str | None = Query(None, max_length=100), sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$")):
return TenantBundleListSpec(workspace_id=workspace_id, site_id=site_id, status=status_filter, bundle_key=bundle_key,
q=q, sort_by=sort_by if sort_by in TENANT_BUNDLE_SORT else "created_at", sort_dir=sort_dir)
@router.post("", response_model=TenantBundleRead, status_code=201)
async def create(body: TenantBundleCreate, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE))):
return await BundlesCommands(db).create_tenant_bundle(tenant_id, body, actor=user)
@router.get("", response_model=TenantBundleListResponse)
async def list_items(tenant_id: UUID = Depends(require_tenant), pagination: PaginationParams = Depends(get_pagination),
spec: TenantBundleListSpec = Depends(_spec), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))):
items, total = await BundlesQueries(db).list_tenant_bundles(tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec)
return TenantBundleListResponse(items=items, total=total, page=pagination.page, page_size=pagination.page_size)
@router.get("/{entity_id}", response_model=TenantBundleRead)
async def get(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW))):
return await BundlesQueries(db).get_tenant_bundle(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=TenantBundleRead)
async def update(entity_id: UUID, body: TenantBundleUpdate, tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db), user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE))):
return await BundlesCommands(db).update_tenant_bundle(tenant_id, entity_id, body, actor=user)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(entity_id: UUID, tenant_id: UUID = Depends(require_tenant), db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE))):
await BundlesCommands(db).delete_tenant_bundle(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,124 @@
"""Theme version APIs — Phase 11.3 (versioned themes)."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.themes import ThemeLayoutCommands
from app.permissions.definitions import (
THEMES_CREATE,
THEMES_MANAGE,
THEMES_UPDATE,
THEMES_VIEW,
)
from app.queries.themes import ThemeLayoutQueries
from app.schemas.themes import (
ExperienceThemeVersionCreate,
ExperienceThemeVersionListResponse,
ExperienceThemeVersionRead,
ExperienceThemeVersionUpdate,
)
from app.specifications.themes import THEME_VERSION_SORT_FIELDS, ThemeVersionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "published", "retired"}
def _list_spec(
theme_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
sort_by: str = Query(default="version_number"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ThemeVersionListSpec:
if sort_by not in THEME_VERSION_SORT_FIELDS:
sort_by = "version_number"
return ThemeVersionListSpec(
theme_id=theme_id,
status=status_filter if status_filter in _STATUSES else None,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post(
"", response_model=ExperienceThemeVersionRead, status_code=status.HTTP_201_CREATED
)
async def create_theme_version(
body: ExperienceThemeVersionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_CREATE)),
):
return await ThemeLayoutCommands(db).create_theme_version(
tenant_id, body, actor=user
)
@router.get("", response_model=ExperienceThemeVersionListResponse)
async def list_theme_versions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ThemeVersionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(THEMES_VIEW)),
):
items, total = await ThemeLayoutQueries(db).list_theme_versions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceThemeVersionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{version_id}", response_model=ExperienceThemeVersionRead)
async def get_theme_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(THEMES_VIEW)),
):
return await ThemeLayoutQueries(db).get_theme_version(tenant_id, version_id)
@router.patch("/{version_id}", response_model=ExperienceThemeVersionRead)
async def update_theme_version(
version_id: UUID,
body: ExperienceThemeVersionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_UPDATE)),
):
return await ThemeLayoutCommands(db).update_theme_version(
tenant_id, version_id, body, actor=user
)
@router.post("/{version_id}/publish", response_model=ExperienceThemeVersionRead)
async def publish_theme_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)),
):
return await ThemeLayoutCommands(db).publish_theme_version(
tenant_id, version_id, actor=user
)
@router.post("/{version_id}/retire", response_model=ExperienceThemeVersionRead)
async def retire_theme_version(
version_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)),
):
return await ThemeLayoutCommands(db).retire_theme_version(
tenant_id, version_id, actor=user
)

View File

@ -0,0 +1,146 @@
"""Theme APIs — Phase 11.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.themes import ThemeLayoutCommands
from app.permissions.definitions import (
THEMES_CREATE,
THEMES_DELETE,
THEMES_MANAGE,
THEMES_UPDATE,
THEMES_VIEW,
)
from app.queries.themes import ThemeLayoutQueries
from app.schemas.themes import (
ExperienceThemeCreate,
ExperienceThemeListResponse,
ExperienceThemeRead,
ExperienceThemeUpdate,
)
from app.specifications.themes import THEME_SORT_FIELDS, ThemeListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
_STATUSES = {"draft", "active", "deprecated", "archived"}
_DIRECTIONS = {"rtl", "ltr", "both"}
def _list_spec(
workspace_id: UUID | None = Query(default=None),
status_filter: str | None = Query(default=None, alias="status"),
direction_mode: str | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ThemeListSpec:
if sort_by not in THEME_SORT_FIELDS:
sort_by = "created_at"
return ThemeListSpec(
workspace_id=workspace_id,
status=status_filter if status_filter in _STATUSES else None,
direction_mode=direction_mode if direction_mode in _DIRECTIONS else None,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ExperienceThemeRead, status_code=status.HTTP_201_CREATED)
async def create_theme(
body: ExperienceThemeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_CREATE)),
):
return await ThemeLayoutCommands(db).create_theme(tenant_id, body, actor=user)
@router.get("", response_model=ExperienceThemeListResponse)
async def list_themes(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ThemeListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(THEMES_VIEW)),
):
items, total = await ThemeLayoutQueries(db).list_themes(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ExperienceThemeListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{theme_id}", response_model=ExperienceThemeRead)
async def get_theme(
theme_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(THEMES_VIEW)),
):
return await ThemeLayoutQueries(db).get_theme(tenant_id, theme_id)
@router.patch("/{theme_id}", response_model=ExperienceThemeRead)
async def update_theme(
theme_id: UUID,
body: ExperienceThemeUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_UPDATE)),
):
return await ThemeLayoutCommands(db).update_theme(
tenant_id, theme_id, body, actor=user
)
@router.post("/{theme_id}/activate", response_model=ExperienceThemeRead)
async def activate_theme(
theme_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)),
):
return await ThemeLayoutCommands(db).activate_theme(tenant_id, theme_id, actor=user)
@router.post("/{theme_id}/deprecate", response_model=ExperienceThemeRead)
async def deprecate_theme(
theme_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)),
):
return await ThemeLayoutCommands(db).deprecate_theme(
tenant_id, theme_id, actor=user
)
@router.post("/{theme_id}/archive", response_model=ExperienceThemeRead)
async def archive_theme(
theme_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_MANAGE)),
):
return await ThemeLayoutCommands(db).archive_theme(tenant_id, theme_id, actor=user)
@router.post("/{theme_id}/delete", response_model=ExperienceThemeRead)
async def soft_delete_theme(
theme_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(THEMES_DELETE)),
):
return await ThemeLayoutCommands(db).soft_delete_theme(
tenant_id, theme_id, actor=user
)

View File

@ -0,0 +1,105 @@
"""Widget definition APIs — Phase 11.9."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.integrations import IntegrationsCommands
from app.permissions.definitions import (
WIDGETS_CREATE,
WIDGETS_DELETE,
WIDGETS_UPDATE,
WIDGETS_VIEW,
)
from app.queries.integrations import IntegrationsQueries
from app.schemas.integrations import (
WidgetDefinitionCreate,
WidgetDefinitionListResponse,
WidgetDefinitionRead,
WidgetDefinitionUpdate,
)
from app.specifications.integrations import WIDGET_DEFINITION_SORT, WidgetDefinitionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
widget_kind: str | None = None,
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return WidgetDefinitionListSpec(
workspace_id=workspace_id,
status=status_filter,
widget_kind=widget_kind,
q=q,
sort_by=sort_by if sort_by in WIDGET_DEFINITION_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=WidgetDefinitionRead, status_code=201)
async def create(
body: WidgetDefinitionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WIDGETS_CREATE)),
):
return await IntegrationsCommands(db).create_widget_definition(tenant_id, body, actor=user)
@router.get("", response_model=WidgetDefinitionListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: WidgetDefinitionListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)),
):
items, total = await IntegrationsQueries(db).list_widget_definitions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return WidgetDefinitionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=WidgetDefinitionRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)),
):
return await IntegrationsQueries(db).get_widget_definition(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=WidgetDefinitionRead)
async def update(
entity_id: UUID,
body: WidgetDefinitionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WIDGETS_UPDATE)),
):
return await IntegrationsCommands(db).update_widget_definition(
tenant_id, entity_id, body, actor=user
)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WIDGETS_DELETE)),
):
await IntegrationsCommands(db).delete_widget_definition(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,117 @@
"""Widget instance APIs — Phase 11.9."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.integrations import IntegrationsCommands
from app.permissions.definitions import (
WIDGETS_CREATE,
WIDGETS_DELETE,
WIDGETS_UPDATE,
WIDGETS_VIEW,
)
from app.queries.integrations import IntegrationsQueries
from app.schemas.integrations import (
WidgetInstanceCreate,
WidgetInstanceListResponse,
WidgetInstanceRead,
WidgetInstanceUpdate,
)
from app.specifications.integrations import WIDGET_INSTANCE_SORT, WidgetInstanceListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _spec(
workspace_id: UUID | None = None,
site_id: UUID | None = None,
status_filter: str | None = Query(None, alias="status"),
widget_kind: str | None = None,
q: str | None = Query(None, max_length=100),
sort_by: str = "created_at",
sort_dir: str = Query("desc", pattern="^(?i)(asc|desc)$"),
):
return WidgetInstanceListSpec(
workspace_id=workspace_id,
site_id=site_id,
status=status_filter,
widget_kind=widget_kind,
q=q,
sort_by=sort_by if sort_by in WIDGET_INSTANCE_SORT else "created_at",
sort_dir=sort_dir,
)
@router.post("", response_model=WidgetInstanceRead, status_code=201)
async def create(
body: WidgetInstanceCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WIDGETS_CREATE)),
):
return await IntegrationsCommands(db).create_widget_instance(tenant_id, body, actor=user)
@router.get("/by-embed/{embed_ref}", response_model=WidgetInstanceRead)
async def get_by_embed(
embed_ref: str,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)),
):
return await IntegrationsQueries(db).get_widget_instance_by_embed(tenant_id, embed_ref)
@router.get("", response_model=WidgetInstanceListResponse)
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: WidgetInstanceListSpec = Depends(_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)),
):
items, total = await IntegrationsQueries(db).list_widget_instances(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return WidgetInstanceListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{entity_id}", response_model=WidgetInstanceRead)
async def get(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WIDGETS_VIEW)),
):
return await IntegrationsQueries(db).get_widget_instance(tenant_id, entity_id)
@router.patch("/{entity_id}", response_model=WidgetInstanceRead)
async def update(
entity_id: UUID,
body: WidgetInstanceUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WIDGETS_UPDATE)),
):
return await IntegrationsCommands(db).update_widget_instance(
tenant_id, entity_id, body, actor=user
)
@router.delete("/{entity_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete(
entity_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WIDGETS_DELETE)),
):
await IntegrationsCommands(db).delete_widget_instance(tenant_id, entity_id, actor=user)
return Response(status_code=204)

View File

@ -0,0 +1,83 @@
"""Experience organization APIs — Phase 11.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
WORKSPACES_CREATE,
WORKSPACES_DELETE,
WORKSPACES_UPDATE,
WORKSPACES_VIEW,
)
from app.schemas.foundation import (
ExperienceWorkspaceCreate,
ExperienceWorkspaceRead,
ExperienceWorkspaceUpdate,
)
from app.services.foundation import ExperienceWorkspaceService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExperienceWorkspaceRead, status_code=status.HTTP_201_CREATED)
async def create_workspace(
body: ExperienceWorkspaceCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKSPACES_CREATE)),
):
return await ExperienceWorkspaceService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExperienceWorkspaceRead])
async def list_workspaces(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKSPACES_VIEW)),
):
return await ExperienceWorkspaceService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{workspace_id}", response_model=ExperienceWorkspaceRead)
async def get_workspace(
workspace_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKSPACES_VIEW)),
):
return await ExperienceWorkspaceService(db).get(tenant_id, workspace_id)
@router.patch("/{workspace_id}", response_model=ExperienceWorkspaceRead)
async def update_workspace(
workspace_id: UUID,
body: ExperienceWorkspaceUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKSPACES_UPDATE)),
):
return await ExperienceWorkspaceService(db).update(
tenant_id, workspace_id, body, actor=user
)
@router.post("/{workspace_id}/delete", response_model=ExperienceWorkspaceRead)
async def soft_delete_workspace(
workspace_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKSPACES_DELETE)),
):
return await ExperienceWorkspaceService(db).soft_delete(
tenant_id, workspace_id, actor=user
)

View File

@ -0,0 +1,16 @@
"""Experience commands package."""
from app.commands.components import ComponentCommands
from app.commands.forms import FormsCommands
from app.commands.localization import LocalizationCommands
from app.commands.publishing import PublishingCommands
from app.commands.templates import TemplateCommands
from app.commands.themes import ThemeLayoutCommands
__all__ = [
"ComponentCommands",
"ThemeLayoutCommands",
"TemplateCommands",
"LocalizationCommands",
"FormsCommands",
"PublishingCommands",
]

View File

@ -0,0 +1,30 @@
"""Command handlers for Phase 11.10 AI content hooks."""
from app.services.ai_hooks import ExperienceAiHooksService
class AiHooksCommands:
def __init__(self, session):
self.service = ExperienceAiHooksService(session)
async def create_hook_registration(self, tenant_id, body, *, actor=None):
return await self.service.create_hook_registration(tenant_id, body, actor=actor)
async def update_hook_registration(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_hook_registration(
tenant_id, entity_id, body, actor=actor
)
async def delete_hook_registration(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_hook_registration(tenant_id, entity_id, actor=actor)
async def create_dispatch(self, tenant_id, body, *, actor=None):
return await self.service.create_dispatch(tenant_id, body, actor=actor)
async def update_dispatch(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_dispatch(tenant_id, entity_id, body, actor=actor)
async def delete_dispatch(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_dispatch(tenant_id, entity_id, actor=actor)
async def generate_dispatch(self, tenant_id, entity_id, *, actor=None):
return await self.service.generate_dispatch(tenant_id, entity_id, actor=actor)

View File

@ -0,0 +1,27 @@
"""Command handlers for Phase 11.10 analytics."""
from app.services.analytics import ExperienceAnalyticsService
class AnalyticsCommands:
def __init__(self, session):
self.service = ExperienceAnalyticsService(session)
async def create_report_definition(self, tenant_id, body, *, actor=None):
return await self.service.create_report_definition(tenant_id, body, actor=actor)
async def update_report_definition(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_report_definition(
tenant_id, entity_id, body, actor=actor
)
async def delete_report_definition(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_report_definition(tenant_id, entity_id, actor=actor)
async def refresh_snapshot(self, tenant_id, body, *, actor=None):
return await self.service.refresh_snapshot(tenant_id, body, actor=actor)
async def update_snapshot(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_snapshot(tenant_id, entity_id, body, actor=actor)
async def delete_snapshot(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_snapshot(tenant_id, entity_id, actor=actor)

View File

@ -0,0 +1,43 @@
"""Command handlers for Phase 11.8."""
from app.services.bundles import ExperienceBundlesService
class BundlesCommands:
def __init__(self, session):
self.service = ExperienceBundlesService(session)
async def create_bundle_definition(self, tenant_id, body, *, actor=None):
return await self.service.create_bundle_definition(tenant_id, body, actor=actor)
async def update_bundle_definition(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_bundle_definition(tenant_id, entity_id, body, actor=actor)
async def delete_bundle_definition(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_bundle_definition(tenant_id, entity_id, actor=actor)
async def create_tenant_bundle(self, tenant_id, body, *, actor=None):
return await self.service.create_tenant_bundle(tenant_id, body, actor=actor)
async def update_tenant_bundle(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_tenant_bundle(tenant_id, entity_id, body, actor=actor)
async def delete_tenant_bundle(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_tenant_bundle(tenant_id, entity_id, actor=actor)
async def create_license_binding(self, tenant_id, body, *, actor=None):
return await self.service.create_license_binding(tenant_id, body, actor=actor)
async def update_license_binding(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_license_binding(tenant_id, entity_id, body, actor=actor)
async def delete_license_binding(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_license_binding(tenant_id, entity_id, actor=actor)
async def create_feature_toggle(self, tenant_id, body, *, actor=None):
return await self.service.create_feature_toggle(tenant_id, body, actor=actor)
async def update_feature_toggle(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_feature_toggle(tenant_id, entity_id, body, actor=actor)
async def delete_feature_toggle(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_feature_toggle(tenant_id, entity_id, actor=actor)

View File

@ -0,0 +1,126 @@
"""Component command handlers — Phase 11.2."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.components import (
ExperienceComponentCreate,
ExperienceComponentUpdate,
ExperienceComponentVersionCreate,
ExperienceComponentVersionUpdate,
PageComponentPlacementCreate,
PageComponentPlacementUpdate,
)
from app.services.components import ExperienceComponentService
from shared.security import CurrentUser
class ComponentCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = ExperienceComponentService(session)
async def create_component(
self, tenant_id: UUID, body: ExperienceComponentCreate, *, actor: CurrentUser | None
):
return await self.service.create_component(tenant_id, body, actor=actor)
async def update_component(
self,
tenant_id: UUID,
component_id: UUID,
body: ExperienceComponentUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_component(
tenant_id, component_id, body, actor=actor
)
async def activate_component(
self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None
):
return await self.service.activate_component(
tenant_id, component_id, actor=actor
)
async def deprecate_component(
self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None
):
return await self.service.deprecate_component(
tenant_id, component_id, actor=actor
)
async def archive_component(
self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None
):
return await self.service.archive_component(
tenant_id, component_id, actor=actor
)
async def soft_delete_component(
self, tenant_id: UUID, component_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_component(
tenant_id, component_id, actor=actor
)
async def create_version(
self,
tenant_id: UUID,
body: ExperienceComponentVersionCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_version(tenant_id, body, actor=actor)
async def update_version(
self,
tenant_id: UUID,
version_id: UUID,
body: ExperienceComponentVersionUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_version(
tenant_id, version_id, body, actor=actor
)
async def publish_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.publish_version(tenant_id, version_id, actor=actor)
async def retire_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.retire_version(tenant_id, version_id, actor=actor)
async def create_placement(
self,
tenant_id: UUID,
body: PageComponentPlacementCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_placement(tenant_id, body, actor=actor)
async def update_placement(
self,
tenant_id: UUID,
placement_id: UUID,
body: PageComponentPlacementUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_placement(
tenant_id, placement_id, body, actor=actor
)
async def soft_delete_placement(
self, tenant_id: UUID, placement_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_placement(
tenant_id, placement_id, actor=actor
)

View File

@ -0,0 +1,169 @@
"""Forms / surveys / appointments command handlers — Phase 11.6."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.forms import (
AppointmentBookingRefCreate,
AppointmentBookingRefUpdate,
ExperienceAppointmentPageCreate,
ExperienceAppointmentPageUpdate,
ExperienceFormCreate,
ExperienceFormUpdate,
ExperienceSurveyCreate,
ExperienceSurveyUpdate,
FormSubmissionCreate,
FormSubmissionUpdate,
SurveyResponseCreate,
SurveyResponseUpdate,
)
from app.services.forms import ExperienceFormsService
from shared.security import CurrentUser
class FormsCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = ExperienceFormsService(session)
async def create_form(
self,
tenant_id: UUID,
body: ExperienceFormCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_form(tenant_id, body, actor=actor)
async def update_form(
self,
tenant_id: UUID,
form_id: UUID,
body: ExperienceFormUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_form(tenant_id, form_id, body, actor=actor)
async def soft_delete_form(
self, tenant_id: UUID, form_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_form(tenant_id, form_id, actor=actor)
async def create_submission(
self,
tenant_id: UUID,
body: FormSubmissionCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_submission(tenant_id, body, actor=actor)
async def update_submission(
self,
tenant_id: UUID,
submission_id: UUID,
body: FormSubmissionUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_submission(
tenant_id, submission_id, body, actor=actor
)
async def create_survey(
self,
tenant_id: UUID,
body: ExperienceSurveyCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_survey(tenant_id, body, actor=actor)
async def update_survey(
self,
tenant_id: UUID,
survey_id: UUID,
body: ExperienceSurveyUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_survey(
tenant_id, survey_id, body, actor=actor
)
async def soft_delete_survey(
self, tenant_id: UUID, survey_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_survey(tenant_id, survey_id, actor=actor)
async def create_survey_response(
self,
tenant_id: UUID,
body: SurveyResponseCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_survey_response(tenant_id, body, actor=actor)
async def update_survey_response(
self,
tenant_id: UUID,
response_id: UUID,
body: SurveyResponseUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_survey_response(
tenant_id, response_id, body, actor=actor
)
async def create_appointment_page(
self,
tenant_id: UUID,
body: ExperienceAppointmentPageCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_appointment_page(tenant_id, body, actor=actor)
async def update_appointment_page(
self,
tenant_id: UUID,
page_id: UUID,
body: ExperienceAppointmentPageUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_appointment_page(
tenant_id, page_id, body, actor=actor
)
async def soft_delete_appointment_page(
self, tenant_id: UUID, page_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_appointment_page(
tenant_id, page_id, actor=actor
)
async def create_booking_ref(
self,
tenant_id: UUID,
body: AppointmentBookingRefCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_booking_ref(tenant_id, body, actor=actor)
async def update_booking_ref(
self,
tenant_id: UUID,
booking_id: UUID,
body: AppointmentBookingRefUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_booking_ref(
tenant_id, booking_id, body, actor=actor
)

View File

@ -0,0 +1,68 @@
"""Command handlers for Phase 11.9."""
from app.services.integrations import ExperienceIntegrationsService
class IntegrationsCommands:
def __init__(self, session):
self.service = ExperienceIntegrationsService(session)
async def create_consumer_connector_registration(self, tenant_id, body, *, actor=None):
return await self.service.create_consumer_connector_registration(tenant_id, body, actor=actor)
async def update_consumer_connector_registration(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_consumer_connector_registration(
tenant_id, entity_id, body, actor=actor
)
async def delete_consumer_connector_registration(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_consumer_connector_registration(
tenant_id, entity_id, actor=actor
)
async def create_consumer_connector_dispatch(self, tenant_id, body, *, actor=None):
return await self.service.create_consumer_connector_dispatch(tenant_id, body, actor=actor)
async def update_consumer_connector_dispatch(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_consumer_connector_dispatch(
tenant_id, entity_id, body, actor=actor
)
async def delete_consumer_connector_dispatch(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_consumer_connector_dispatch(
tenant_id, entity_id, actor=actor
)
async def execute_consumer_connector_dispatch(self, tenant_id, entity_id, *, actor=None):
return await self.service.execute_consumer_connector_dispatch(
tenant_id, entity_id, actor=actor
)
async def create_widget_definition(self, tenant_id, body, *, actor=None):
return await self.service.create_widget_definition(tenant_id, body, actor=actor)
async def update_widget_definition(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_widget_definition(tenant_id, entity_id, body, actor=actor)
async def delete_widget_definition(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_widget_definition(tenant_id, entity_id, actor=actor)
async def create_widget_instance(self, tenant_id, body, *, actor=None):
return await self.service.create_widget_instance(tenant_id, body, actor=actor)
async def update_widget_instance(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_widget_instance(tenant_id, entity_id, body, actor=actor)
async def delete_widget_instance(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_widget_instance(tenant_id, entity_id, actor=actor)
async def create_notify_dispatch(self, tenant_id, body, *, actor=None):
return await self.service.create_notify_dispatch(tenant_id, body, actor=actor)
async def update_notify_dispatch(self, tenant_id, entity_id, body, *, actor=None):
return await self.service.update_notify_dispatch(tenant_id, entity_id, body, actor=actor)
async def delete_notify_dispatch(self, tenant_id, entity_id, *, actor=None):
return await self.service.delete_notify_dispatch(tenant_id, entity_id, actor=actor)
async def send_notify_dispatch(self, tenant_id, entity_id, *, actor=None):
return await self.service.send_notify_dispatch(tenant_id, entity_id, actor=actor)

View File

@ -0,0 +1,108 @@
"""Localization & media-ref command handlers — Phase 11.5."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.localization import (
ExperienceLocalizationUpdate,
ExperienceLocalizationUpsert,
ExperienceMediaRefCreate,
ExperienceMediaRefUpdate,
SiteLocaleBindingCreate,
SiteLocaleBindingUpdate,
)
from app.services.localization import ExperienceLocalizationService
from shared.security import CurrentUser
class LocalizationCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = ExperienceLocalizationService(session)
async def create_site_locale_binding(
self,
tenant_id: UUID,
body: SiteLocaleBindingCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_site_locale_binding(
tenant_id, body, actor=actor
)
async def update_site_locale_binding(
self,
tenant_id: UUID,
binding_id: UUID,
body: SiteLocaleBindingUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_site_locale_binding(
tenant_id, binding_id, body, actor=actor
)
async def soft_delete_site_locale_binding(
self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_site_locale_binding(
tenant_id, binding_id, actor=actor
)
async def upsert_localization(
self,
tenant_id: UUID,
body: ExperienceLocalizationUpsert,
*,
actor: CurrentUser | None,
):
return await self.service.upsert_localization(tenant_id, body, actor=actor)
async def update_localization(
self,
tenant_id: UUID,
localization_id: UUID,
body: ExperienceLocalizationUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_localization(
tenant_id, localization_id, body, actor=actor
)
async def soft_delete_localization(
self, tenant_id: UUID, localization_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_localization(
tenant_id, localization_id, actor=actor
)
async def create_media_ref(
self,
tenant_id: UUID,
body: ExperienceMediaRefCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_media_ref(tenant_id, body, actor=actor)
async def update_media_ref(
self,
tenant_id: UUID,
media_ref_id: UUID,
body: ExperienceMediaRefUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_media_ref(
tenant_id, media_ref_id, body, actor=actor
)
async def soft_delete_media_ref(
self, tenant_id: UUID, media_ref_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_media_ref(
tenant_id, media_ref_id, actor=actor
)

View File

@ -0,0 +1,64 @@
"""Publishing, SEO and PWA command handlers — Phase 11.7."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.publishing import (
DomainEdgeBindingCreate,
DomainEdgeBindingUpdate,
PublishReleaseCreate,
PublishReleaseUpdate,
PwaManifestUpdate,
PwaManifestUpsert,
RobotsShellUpdate,
RobotsShellUpsert,
SeoMetadataUpdate,
SeoMetadataUpsert,
SitemapShellUpdate,
SitemapShellUpsert,
)
from app.services.publishing import ExperiencePublishingService
from shared.security import CurrentUser
class PublishingCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = ExperiencePublishingService(session)
async def create_publish_release(self, tenant_id: UUID, body: PublishReleaseCreate, *, actor: CurrentUser | None):
return await self.service.create_publish_release(tenant_id, body, actor=actor)
async def update_publish_release(self, tenant_id: UUID, entity_id: UUID, body: PublishReleaseUpdate, *, actor: CurrentUser | None):
return await self.service.update_publish_release(tenant_id, entity_id, body, actor=actor)
async def create_domain_edge_binding(self, tenant_id: UUID, body: DomainEdgeBindingCreate, *, actor: CurrentUser | None):
return await self.service.create_domain_edge_binding(tenant_id, body, actor=actor)
async def update_domain_edge_binding(self, tenant_id: UUID, entity_id: UUID, body: DomainEdgeBindingUpdate, *, actor: CurrentUser | None):
return await self.service.update_domain_edge_binding(tenant_id, entity_id, body, actor=actor)
async def upsert_seo(self, tenant_id: UUID, body: SeoMetadataUpsert, *, actor: CurrentUser | None):
return await self.service.upsert_seo(tenant_id, body, actor=actor)
async def update_seo(self, tenant_id: UUID, entity_id: UUID, body: SeoMetadataUpdate, *, actor: CurrentUser | None):
return await self.service.update_seo(tenant_id, entity_id, body, actor=actor)
async def upsert_sitemap(self, tenant_id: UUID, body: SitemapShellUpsert, *, actor: CurrentUser | None):
return await self.service.upsert_sitemap(tenant_id, body, actor=actor)
async def update_sitemap(self, tenant_id: UUID, entity_id: UUID, body: SitemapShellUpdate, *, actor: CurrentUser | None):
return await self.service.update_sitemap(tenant_id, entity_id, body, actor=actor)
async def upsert_robots(self, tenant_id: UUID, body: RobotsShellUpsert, *, actor: CurrentUser | None):
return await self.service.upsert_robots(tenant_id, body, actor=actor)
async def update_robots(self, tenant_id: UUID, entity_id: UUID, body: RobotsShellUpdate, *, actor: CurrentUser | None):
return await self.service.update_robots(tenant_id, entity_id, body, actor=actor)
async def upsert_pwa(self, tenant_id: UUID, body: PwaManifestUpsert, *, actor: CurrentUser | None):
return await self.service.upsert_pwa(tenant_id, body, actor=actor)
async def update_pwa(self, tenant_id: UUID, entity_id: UUID, body: PwaManifestUpdate, *, actor: CurrentUser | None):
return await self.service.update_pwa(tenant_id, entity_id, body, actor=actor)

View File

@ -0,0 +1,102 @@
"""Template command handlers — Phase 11.4."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.templates import (
ExperienceTemplateCreate,
ExperienceTemplateUpdate,
ExperienceTemplateVersionCreate,
ExperienceTemplateVersionUpdate,
TemplateInstantiateRequest,
)
from app.services.templates import ExperienceTemplateService
from shared.security import CurrentUser
class TemplateCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = ExperienceTemplateService(session)
async def create_template(
self, tenant_id: UUID, body: ExperienceTemplateCreate, *, actor: CurrentUser | None
):
return await self.service.create_template(tenant_id, body, actor=actor)
async def update_template(
self,
tenant_id: UUID,
template_id: UUID,
body: ExperienceTemplateUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_template(
tenant_id, template_id, body, actor=actor
)
async def activate_template(
self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None
):
return await self.service.activate_template(tenant_id, template_id, actor=actor)
async def deprecate_template(
self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None
):
return await self.service.deprecate_template(
tenant_id, template_id, actor=actor
)
async def archive_template(
self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None
):
return await self.service.archive_template(tenant_id, template_id, actor=actor)
async def soft_delete_template(
self, tenant_id: UUID, template_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_template(
tenant_id, template_id, actor=actor
)
async def create_version(
self,
tenant_id: UUID,
body: ExperienceTemplateVersionCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_version(tenant_id, body, actor=actor)
async def update_version(
self,
tenant_id: UUID,
version_id: UUID,
body: ExperienceTemplateVersionUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_version(
tenant_id, version_id, body, actor=actor
)
async def publish_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.publish_version(tenant_id, version_id, actor=actor)
async def retire_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.retire_version(tenant_id, version_id, actor=actor)
async def instantiate(
self,
tenant_id: UUID,
body: TemplateInstantiateRequest,
*,
actor: CurrentUser | None,
):
return await self.service.instantiate(tenant_id, body, actor=actor)

View File

@ -0,0 +1,240 @@
"""Theme & layout command handlers — Phase 11.3 (versioned)."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.themes import (
ExperienceLayoutCreate,
ExperienceLayoutUpdate,
ExperienceLayoutVersionCreate,
ExperienceLayoutVersionUpdate,
ExperienceThemeCreate,
ExperienceThemeUpdate,
ExperienceThemeVersionCreate,
ExperienceThemeVersionUpdate,
PageLayoutBindingCreate,
PageLayoutBindingUpdate,
SiteThemeBindingCreate,
SiteThemeBindingUpdate,
)
from app.services.themes import ExperienceThemeService
from shared.security import CurrentUser
class ThemeLayoutCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = ExperienceThemeService(session)
# --- Themes ---
async def create_theme(
self, tenant_id: UUID, body: ExperienceThemeCreate, *, actor: CurrentUser | None
):
return await self.service.create_theme(tenant_id, body, actor=actor)
async def update_theme(
self,
tenant_id: UUID,
theme_id: UUID,
body: ExperienceThemeUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_theme(tenant_id, theme_id, body, actor=actor)
async def activate_theme(
self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None
):
return await self.service.activate_theme(tenant_id, theme_id, actor=actor)
async def deprecate_theme(
self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None
):
return await self.service.deprecate_theme(tenant_id, theme_id, actor=actor)
async def archive_theme(
self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None
):
return await self.service.archive_theme(tenant_id, theme_id, actor=actor)
async def soft_delete_theme(
self, tenant_id: UUID, theme_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_theme(tenant_id, theme_id, actor=actor)
# --- Theme Versions ---
async def create_theme_version(
self,
tenant_id: UUID,
body: ExperienceThemeVersionCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_theme_version(tenant_id, body, actor=actor)
async def update_theme_version(
self,
tenant_id: UUID,
version_id: UUID,
body: ExperienceThemeVersionUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_theme_version(
tenant_id, version_id, body, actor=actor
)
async def publish_theme_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.publish_theme_version(
tenant_id, version_id, actor=actor
)
async def retire_theme_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.retire_theme_version(
tenant_id, version_id, actor=actor
)
# --- Layouts ---
async def create_layout(
self, tenant_id: UUID, body: ExperienceLayoutCreate, *, actor: CurrentUser | None
):
return await self.service.create_layout(tenant_id, body, actor=actor)
async def update_layout(
self,
tenant_id: UUID,
layout_id: UUID,
body: ExperienceLayoutUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_layout(
tenant_id, layout_id, body, actor=actor
)
async def activate_layout(
self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None
):
return await self.service.activate_layout(tenant_id, layout_id, actor=actor)
async def deprecate_layout(
self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None
):
return await self.service.deprecate_layout(tenant_id, layout_id, actor=actor)
async def archive_layout(
self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None
):
return await self.service.archive_layout(tenant_id, layout_id, actor=actor)
async def soft_delete_layout(
self, tenant_id: UUID, layout_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_layout(
tenant_id, layout_id, actor=actor
)
# --- Layout Versions ---
async def create_layout_version(
self,
tenant_id: UUID,
body: ExperienceLayoutVersionCreate,
*,
actor: CurrentUser | None,
):
return await self.service.create_layout_version(tenant_id, body, actor=actor)
async def update_layout_version(
self,
tenant_id: UUID,
version_id: UUID,
body: ExperienceLayoutVersionUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_layout_version(
tenant_id, version_id, body, actor=actor
)
async def publish_layout_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.publish_layout_version(
tenant_id, version_id, actor=actor
)
async def retire_layout_version(
self, tenant_id: UUID, version_id: UUID, *, actor: CurrentUser | None
):
return await self.service.retire_layout_version(
tenant_id, version_id, actor=actor
)
# --- Site Theme Bindings ---
async def assign_site_theme(
self,
tenant_id: UUID,
body: SiteThemeBindingCreate,
*,
actor: CurrentUser | None,
):
return await self.service.assign_site_theme(tenant_id, body, actor=actor)
async def update_site_theme_binding(
self,
tenant_id: UUID,
binding_id: UUID,
body: SiteThemeBindingUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_site_theme_binding(
tenant_id, binding_id, body, actor=actor
)
async def unassign_site_theme(
self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_site_theme_binding(
tenant_id, binding_id, actor=actor
)
# --- Page Layout Bindings ---
async def assign_page_layout(
self,
tenant_id: UUID,
body: PageLayoutBindingCreate,
*,
actor: CurrentUser | None,
):
return await self.service.assign_page_layout(tenant_id, body, actor=actor)
async def update_page_layout_binding(
self,
tenant_id: UUID,
binding_id: UUID,
body: PageLayoutBindingUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update_page_layout_binding(
tenant_id, binding_id, body, actor=actor
)
async def unassign_page_layout(
self, tenant_id: UUID, binding_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete_page_layout_binding(
tenant_id, binding_id, actor=actor
)

View File

@ -0,0 +1,75 @@
"""Experience Service settings."""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
environment: Literal["development", "staging", "production", "test"] = "development"
debug: bool = True
log_level: str = "INFO"
service_name: str = Field(
default="experience-service", validation_alias="EXPERIENCE_SERVICE_NAME"
)
api_v1_prefix: str = "/api/v1"
database_url: str = Field(
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/experience_db",
validation_alias="EXPERIENCE_DATABASE_URL",
)
database_url_sync: str = Field(
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/experience_db",
validation_alias="EXPERIENCE_DATABASE_URL_SYNC",
)
core_service_url: str = Field(
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
)
keycloak_enabled: bool = True
keycloak_server_url: str = Field(
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
)
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
jwt_algorithm: str = "RS256"
jwt_audience: str = "account"
jwt_verify_signature: bool = True
auth_required: bool = True
cors_origins: str = Field(
default="http://localhost:3000,http://127.0.0.1:3000",
validation_alias="CORS_ORIGINS",
)
@property
def keycloak_public_base(self) -> str:
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
@property
def keycloak_public_realm_url(self) -> str:
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import StaticPool
from app.core.config import settings
class Base(DeclarativeBase):
pass
_engine_kwargs: dict = {"pool_pre_ping": True, "future": True}
if settings.database_url.startswith("sqlite"):
_engine_kwargs["poolclass"] = StaticPool
_engine_kwargs["connect_args"] = {"check_same_thread": False}
engine = create_async_engine(settings.database_url, **_engine_kwargs)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise

View File

@ -0,0 +1,14 @@
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
stream=sys.stdout,
)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

View File

@ -0,0 +1,49 @@
"""JWT authentication dependencies for Experience service."""
from __future__ import annotations
from functools import lru_cache
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from shared.auth import JWTSettings, JWTValidator
from shared.exceptions import UnauthorizedError
from shared.security import CurrentUser
_bearer = HTTPBearer(auto_error=False)
@lru_cache
def get_jwt_validator() -> JWTValidator:
return JWTValidator(
JWTSettings(
keycloak_enabled=settings.keycloak_enabled,
keycloak_server_url=settings.keycloak_server_url,
keycloak_realm=settings.keycloak_realm,
jwt_algorithm=settings.jwt_algorithm,
jwt_audience=settings.jwt_audience,
jwt_verify_signature=settings.jwt_verify_signature,
jwt_issuer=settings.keycloak_public_realm_url,
)
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> CurrentUser:
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if credentials is None or not credentials.credentials:
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
return await get_jwt_validator().validate(credentials.credentials)
async def get_optional_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> CurrentUser | None:
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if credentials is None or not credentials.credentials:
return None
return await get_jwt_validator().validate(credentials.credentials)

View File

@ -0,0 +1,63 @@
"""Experience event publisher — builds EventEnvelope contracts; no bus consumers yet."""
from __future__ import annotations
from typing import Any, Protocol
from uuid import UUID, uuid4
from shared.events import EventEnvelope
from app.core.config import settings
from app.events.types import ExperienceEventType
class EventPublisher(Protocol):
def publish(
self,
*,
event_type: ExperienceEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope: ...
class InMemoryEventPublisher:
"""Records published envelopes for tests and local verification."""
def __init__(self) -> None:
self.published: list[EventEnvelope] = []
def publish(
self,
*,
event_type: ExperienceEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope:
envelope = EventEnvelope(
event_id=uuid4(),
event_type=event_type.value,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
tenant_id=tenant_id,
source_service=settings.service_name,
payload=payload or {},
)
self.published.append(envelope)
return envelope
_default_publisher = InMemoryEventPublisher()
def get_event_publisher() -> InMemoryEventPublisher:
return _default_publisher
def reset_event_publisher() -> InMemoryEventPublisher:
global _default_publisher
_default_publisher = InMemoryEventPublisher()
return _default_publisher

View File

@ -0,0 +1,158 @@
"""Experience event type contracts (publish-only)."""
from __future__ import annotations
import enum
class ExperienceEventType(str, enum.Enum):
WORKSPACE_CREATED = "experience.workspace.created"
WORKSPACE_UPDATED = "experience.workspace.updated"
LOCALE_PROFILE_CREATED = "experience.locale_profile.created"
LOCALE_PROFILE_UPDATED = "experience.locale_profile.updated"
EXTERNAL_PROVIDER_REGISTERED = "experience.external_provider.registered"
EXTERNAL_PROVIDER_UPDATED = "experience.external_provider.updated"
RENDER_ENGINE_REGISTERED = "experience.render_engine.registered"
RENDER_ENGINE_UPDATED = "experience.render_engine.updated"
CONFIGURATION_CREATED = "experience.configuration.created"
CONFIGURATION_UPDATED = "experience.configuration.updated"
SETTING_UPSERTED = "experience.setting.upserted"
SITE_CREATED = "experience.site.created"
SITE_UPDATED = "experience.site.updated"
SITE_PUBLISHED = "experience.site.published"
SITE_UNPUBLISHED = "experience.site.unpublished"
SITE_ARCHIVED = "experience.site.archived"
PAGE_CREATED = "experience.page.created"
PAGE_UPDATED = "experience.page.updated"
PAGE_PUBLISHED = "experience.page.published"
PAGE_UNPUBLISHED = "experience.page.unpublished"
PAGE_ARCHIVED = "experience.page.archived"
SITE_DOMAIN_BOUND = "experience.site_domain.bound"
SITE_DOMAIN_UPDATED = "experience.site_domain.updated"
COMPONENT_CREATED = "experience.component.created"
COMPONENT_UPDATED = "experience.component.updated"
COMPONENT_ACTIVATED = "experience.component.activated"
COMPONENT_DEPRECATED = "experience.component.deprecated"
COMPONENT_ARCHIVED = "experience.component.archived"
COMPONENT_VERSION_CREATED = "experience.component_version.created"
COMPONENT_VERSION_UPDATED = "experience.component_version.updated"
COMPONENT_VERSION_PUBLISHED = "experience.component_version.published"
COMPONENT_VERSION_RETIRED = "experience.component_version.retired"
PAGE_COMPONENT_PLACED = "experience.page_component.placed"
PAGE_COMPONENT_UPDATED = "experience.page_component.updated"
PAGE_COMPONENT_REMOVED = "experience.page_component.removed"
THEME_CREATED = "experience.theme.created"
THEME_UPDATED = "experience.theme.updated"
THEME_ACTIVATED = "experience.theme.activated"
THEME_DEPRECATED = "experience.theme.deprecated"
THEME_ARCHIVED = "experience.theme.archived"
THEME_VERSION_CREATED = "experience.theme_version.created"
THEME_VERSION_UPDATED = "experience.theme_version.updated"
THEME_VERSION_PUBLISHED = "experience.theme_version.published"
THEME_VERSION_RETIRED = "experience.theme_version.retired"
LAYOUT_CREATED = "experience.layout.created"
LAYOUT_UPDATED = "experience.layout.updated"
LAYOUT_ACTIVATED = "experience.layout.activated"
LAYOUT_DEPRECATED = "experience.layout.deprecated"
LAYOUT_ARCHIVED = "experience.layout.archived"
LAYOUT_VERSION_CREATED = "experience.layout_version.created"
LAYOUT_VERSION_UPDATED = "experience.layout_version.updated"
LAYOUT_VERSION_PUBLISHED = "experience.layout_version.published"
LAYOUT_VERSION_RETIRED = "experience.layout_version.retired"
SITE_THEME_ASSIGNED = "experience.site_theme.assigned"
SITE_THEME_REPLACED = "experience.site_theme.replaced"
SITE_THEME_UNASSIGNED = "experience.site_theme.unassigned"
SITE_THEME_CLEARED = "experience.site_theme.cleared"
PAGE_LAYOUT_ASSIGNED = "experience.page_layout.assigned"
PAGE_LAYOUT_REPLACED = "experience.page_layout.replaced"
PAGE_LAYOUT_UNASSIGNED = "experience.page_layout.unassigned"
PAGE_LAYOUT_CLEARED = "experience.page_layout.cleared"
TEMPLATE_CREATED = "experience.template.created"
TEMPLATE_UPDATED = "experience.template.updated"
TEMPLATE_ACTIVATED = "experience.template.activated"
TEMPLATE_DEPRECATED = "experience.template.deprecated"
TEMPLATE_ARCHIVED = "experience.template.archived"
TEMPLATE_VERSION_CREATED = "experience.template_version.created"
TEMPLATE_VERSION_UPDATED = "experience.template_version.updated"
TEMPLATE_VERSION_PUBLISHED = "experience.template_version.published"
TEMPLATE_VERSION_RETIRED = "experience.template_version.retired"
TEMPLATE_INSTANTIATED = "experience.template.instantiated"
SITE_LOCALE_ASSIGNED = "experience.site_locale.assigned"
SITE_LOCALE_UPDATED = "experience.site_locale.updated"
SITE_LOCALE_REPLACED = "experience.site_locale.replaced"
SITE_LOCALE_UNASSIGNED = "experience.site_locale.unassigned"
LOCALIZATION_UPSERTED = "experience.localization.upserted"
LOCALIZATION_UPDATED = "experience.localization.updated"
LOCALIZATION_ARCHIVED = "experience.localization.archived"
MEDIA_REF_CREATED = "experience.media_ref.created"
MEDIA_REF_UPDATED = "experience.media_ref.updated"
MEDIA_REF_ARCHIVED = "experience.media_ref.archived"
FORM_CREATED = "experience.form.created"
FORM_UPDATED = "experience.form.updated"
FORM_ARCHIVED = "experience.form.archived"
FORM_SUBMISSION_RECEIVED = "experience.form_submission.received"
FORM_SUBMISSION_UPDATED = "experience.form_submission.updated"
SURVEY_CREATED = "experience.survey.created"
SURVEY_UPDATED = "experience.survey.updated"
SURVEY_ARCHIVED = "experience.survey.archived"
SURVEY_RESPONSE_RECEIVED = "experience.survey_response.received"
SURVEY_RESPONSE_UPDATED = "experience.survey_response.updated"
APPOINTMENT_PAGE_CREATED = "experience.appointment_page.created"
APPOINTMENT_PAGE_UPDATED = "experience.appointment_page.updated"
APPOINTMENT_PAGE_ARCHIVED = "experience.appointment_page.archived"
APPOINTMENT_BOOKING_REF_CREATED = "experience.appointment_booking_ref.created"
APPOINTMENT_BOOKING_REF_UPDATED = "experience.appointment_booking_ref.updated"
PUBLISH_RELEASE_COMPLETED = "experience.publish_release.completed"
PUBLISH_RELEASE_UPDATED = "experience.publish_release.updated"
DOMAIN_EDGE_BOUND = "experience.domain_edge.bound"
DOMAIN_EDGE_UPDATED = "experience.domain_edge.updated"
SEO_METADATA_UPSERTED = "experience.seo_metadata.upserted"
SEO_METADATA_UPDATED = "experience.seo_metadata.updated"
SITEMAP_UPSERTED = "experience.sitemap.upserted"
SITEMAP_UPDATED = "experience.sitemap.updated"
ROBOTS_UPSERTED = "experience.robots.upserted"
ROBOTS_UPDATED = "experience.robots.updated"
PWA_MANIFEST_UPSERTED = "experience.pwa_manifest.upserted"
PWA_MANIFEST_UPDATED = "experience.pwa_manifest.updated"
BUNDLE_DEFINITION_CREATED = "experience.bundle_definition.created"
BUNDLE_DEFINITION_UPDATED = "experience.bundle_definition.updated"
BUNDLE_DEFINITION_DELETED = "experience.bundle_definition.deleted"
TENANT_BUNDLE_CREATED = "experience.tenant_bundle.created"
TENANT_BUNDLE_UPDATED = "experience.tenant_bundle.updated"
TENANT_BUNDLE_DELETED = "experience.tenant_bundle.deleted"
LICENSE_BINDING_CREATED = "experience.license_binding.created"
LICENSE_BINDING_UPDATED = "experience.license_binding.updated"
LICENSE_BINDING_DELETED = "experience.license_binding.deleted"
FEATURE_TOGGLE_CREATED = "experience.feature_toggle.created"
FEATURE_TOGGLE_UPDATED = "experience.feature_toggle.updated"
FEATURE_TOGGLE_DELETED = "experience.feature_toggle.deleted"
CONSUMER_CONNECTOR_REGISTRATION_CREATED = "experience.consumer_connector_registration.created"
CONSUMER_CONNECTOR_REGISTRATION_UPDATED = "experience.consumer_connector_registration.updated"
CONSUMER_CONNECTOR_REGISTRATION_DELETED = "experience.consumer_connector_registration.deleted"
CONSUMER_CONNECTOR_DISPATCH_CREATED = "experience.consumer_connector_dispatch.created"
CONSUMER_CONNECTOR_DISPATCH_UPDATED = "experience.consumer_connector_dispatch.updated"
CONSUMER_CONNECTOR_DISPATCH_DELETED = "experience.consumer_connector_dispatch.deleted"
CONSUMER_CONNECTOR_DISPATCH_EXECUTED = "experience.consumer_connector_dispatch.executed"
WIDGET_DEFINITION_CREATED = "experience.widget_definition.created"
WIDGET_DEFINITION_UPDATED = "experience.widget_definition.updated"
WIDGET_DEFINITION_DELETED = "experience.widget_definition.deleted"
WIDGET_INSTANCE_CREATED = "experience.widget_instance.created"
WIDGET_INSTANCE_UPDATED = "experience.widget_instance.updated"
WIDGET_INSTANCE_DELETED = "experience.widget_instance.deleted"
NOTIFY_DISPATCH_CREATED = "experience.notify_dispatch.created"
NOTIFY_DISPATCH_UPDATED = "experience.notify_dispatch.updated"
NOTIFY_DISPATCH_DELETED = "experience.notify_dispatch.deleted"
NOTIFY_DISPATCH_SENT = "experience.notify_dispatch.sent"
ANALYTICS_REPORT_DEFINITION_CREATED = "experience.analytics_report_definition.created"
ANALYTICS_REPORT_DEFINITION_UPDATED = "experience.analytics_report_definition.updated"
ANALYTICS_REPORT_DEFINITION_DELETED = "experience.analytics_report_definition.deleted"
ANALYTICS_SNAPSHOT_CREATED = "experience.analytics_snapshot.created"
ANALYTICS_SNAPSHOT_UPDATED = "experience.analytics_snapshot.updated"
ANALYTICS_SNAPSHOT_DELETED = "experience.analytics_snapshot.deleted"
ANALYTICS_SNAPSHOT_REFRESHED = "experience.analytics_snapshot.refreshed"
AI_CONTENT_HOOK_REGISTRATION_CREATED = "experience.ai_content_hook_registration.created"
AI_CONTENT_HOOK_REGISTRATION_UPDATED = "experience.ai_content_hook_registration.updated"
AI_CONTENT_HOOK_REGISTRATION_DELETED = "experience.ai_content_hook_registration.deleted"
AI_CONTENT_DISPATCH_CREATED = "experience.ai_content_dispatch.created"
AI_CONTENT_DISPATCH_UPDATED = "experience.ai_content_dispatch.updated"
AI_CONTENT_DISPATCH_DELETED = "experience.ai_content_dispatch.deleted"
AI_CONTENT_DISPATCH_GENERATED = "experience.ai_content_dispatch.generated"

View File

@ -0,0 +1,69 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app import __version__
from app.api.v1 import api_router
from app.api.v1 import health
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.middlewares.tenant import TenantHeaderMiddleware
from shared.exceptions import AppError
from shared.responses import ErrorDetail, ErrorResponse
configure_logging(settings.log_level)
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("service_starting", extra={"service": settings.service_name})
yield
def create_app() -> FastAPI:
app = FastAPI(
title="Experience Platform",
version=__version__,
description="Torbat Pages — Experience Platform (Phase 11.9 Consumer Integrations & Widgets)",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(TenantHeaderMiddleware)
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
error=ErrorDetail(
code=exc.error_code, message=exc.message, details=exc.details
)
).model_dump(),
)
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
return JSONResponse(
status_code=500,
content=ErrorResponse(
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
).model_dump(),
)
app.include_router(health.router)
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app
app = create_app()

View File

@ -0,0 +1,24 @@
"""Tenant header resolution middleware."""
from __future__ import annotations
from uuid import UUID
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
class TenantHeaderMiddleware(BaseHTTPMiddleware):
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
async def dispatch(self, request: Request, call_next) -> Response:
setattr(request.state, STATE_TENANT_ID, None)
raw = request.headers.get(HEADER_TENANT_ID)
if raw:
try:
setattr(request.state, STATE_TENANT_ID, UUID(raw))
except ValueError:
pass
return await call_next(request)

View File

@ -0,0 +1,77 @@
"""Import all models for Alembic metadata discovery."""
from app.models.foundation import ( # noqa: F401
ExperienceAuditLog,
ExperienceConfiguration,
ExperienceLocaleProfile,
ExperienceWorkspace,
ExperiencePermission,
ExperienceRole,
ExperienceSetting,
ExternalProviderConfig,
RenderEngineRegistration,
)
from app.models.sites import ( # noqa: F401
ExperiencePage,
ExperienceSite,
ExperienceSiteDomain,
)
from app.models.components import ( # noqa: F401
ExperienceComponent,
ExperienceComponentVersion,
PageComponentPlacement,
)
from app.models.themes import ( # noqa: F401
ExperienceLayout,
ExperienceLayoutVersion,
ExperienceTheme,
ExperienceThemeVersion,
PageLayoutBinding,
SiteThemeBinding,
)
from app.models.templates import ( # noqa: F401
ExperienceTemplate,
ExperienceTemplateVersion,
TemplateInstantiation,
)
from app.models.localization import ( # noqa: F401
ExperienceLocalization,
ExperienceMediaRef,
SiteLocaleBinding,
)
from app.models.forms import ( # noqa: F401
AppointmentBookingRef,
ExperienceAppointmentPage,
ExperienceForm,
ExperienceSurvey,
FormSubmission,
SurveyResponse,
)
from app.models.publishing import ( # noqa: F401
DomainEdgeBinding,
PublishRelease,
PwaManifest,
RobotsShell,
SeoMetadata,
SitemapShell,
)
from app.models.bundles import ( # noqa: F401
ExperienceBundleDefinition,
ExperienceFeatureToggle,
ExperienceLicenseBinding,
ExperienceTenantBundle,
)
from app.models.integrations import ( # noqa: F401
ExperienceConsumerConnectorDispatch,
ExperienceConsumerConnectorRegistration,
ExperienceNotifyDispatch,
ExperienceWidgetDefinition,
ExperienceWidgetInstance,
)
from app.models.analytics import ( # noqa: F401
ExperienceAnalyticsReportDefinition,
ExperienceAnalyticsSnapshot,
)
from app.models.ai_hooks import ( # noqa: F401
ExperienceAiContentDispatch,
ExperienceAiContentHookRegistration,
)

View File

@ -0,0 +1,119 @@
"""AI content hook aggregates — Phase 11.10.
Experience stores hook registrations and dispatches only. Model inference
remains on the Enterprise AI Platform via AIProvider contracts.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
GUID,
AiContentDispatchStatus,
AiContentHookStatus,
AiContentTaskKind,
)
class ExperienceAiContentHookRegistration(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_ai_content_hook_registrations"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
task_kind: Mapped[AiContentTaskKind] = mapped_column(nullable=False)
external_ai_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[AiContentHookStatus] = mapped_column(
default=AiContentHookStatus.DRAFT, nullable=False
)
config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"code",
name="uq_experience_ai_content_hook_registrations_tenant_code",
),
Index(
"ix_experience_ai_content_hook_registrations_workspace",
"tenant_id",
"workspace_id",
),
Index(
"ix_experience_ai_content_hook_registrations_status",
"tenant_id",
"status",
),
Index(
"ix_experience_ai_content_hook_registrations_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceAiContentDispatch(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_ai_content_dispatches"
registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
task: Mapped[str] = mapped_column(String(100), nullable=False)
context_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[AiContentDispatchStatus] = mapped_column(
default=AiContentDispatchStatus.PENDING, nullable=False
)
response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
generated_content_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
__table_args__ = (
Index(
"ix_experience_ai_content_dispatches_registration",
"tenant_id",
"registration_id",
),
Index(
"ix_experience_ai_content_dispatches_status",
"tenant_id",
"status",
),
Index(
"ix_experience_ai_content_dispatches_tenant_deleted",
"tenant_id",
"is_deleted",
),
)

View File

@ -0,0 +1,109 @@
"""Analytics aggregates — Phase 11.10.
Report definitions describe which local metric keys to compute; snapshots
store point-in-time metrics payloads from simple local SQL counts only.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import AnalyticsReportStatus, AnalyticsSnapshotStatus, GUID
class ExperienceAnalyticsReportDefinition(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_analytics_report_definitions"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
metric_keys: Mapped[list] = mapped_column(JSON, nullable=False)
filters: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[AnalyticsReportStatus] = mapped_column(
default=AnalyticsReportStatus.DRAFT, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"code",
name="uq_experience_analytics_report_definitions_tenant_code",
),
Index(
"ix_experience_analytics_report_definitions_workspace",
"tenant_id",
"workspace_id",
),
Index(
"ix_experience_analytics_report_definitions_status",
"tenant_id",
"status",
),
Index(
"ix_experience_analytics_report_definitions_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceAnalyticsSnapshot(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_analytics_snapshots"
report_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
period_start: Mapped[str | None] = mapped_column(String(32), nullable=True)
period_end: Mapped[str | None] = mapped_column(String(32), nullable=True)
metrics: Mapped[dict] = mapped_column(JSON, nullable=False)
status: Mapped[AnalyticsSnapshotStatus] = mapped_column(
default=AnalyticsSnapshotStatus.ACTIVE, nullable=False
)
__table_args__ = (
Index(
"ix_experience_analytics_snapshots_report",
"tenant_id",
"report_id",
),
Index(
"ix_experience_analytics_snapshots_workspace",
"tenant_id",
"workspace_id",
),
Index(
"ix_experience_analytics_snapshots_tenant_deleted",
"tenant_id",
"is_deleted",
),
)

View File

@ -0,0 +1,51 @@
"""Shared model mixins."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.types import GUID
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(
GUID(), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class TenantMixin:
"""Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded."""
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
class SoftDeleteMixin:
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
class ActorAuditMixin:
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
class OptimisticLockMixin:
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)

View File

@ -0,0 +1,208 @@
"""Capability bundles, licensing refs & feature toggles — Phase 11.8.
ExperienceBundleDefinition = catalog of commercial capability packs.
ExperienceTenantBundle = activated pack for a workspace (optionally site).
ExperienceLicenseBinding = Core entitlement refs only (no plan-engine ownership).
ExperienceFeatureToggle = per-workspace/site feature flags coordinated with bundles.
No widgets/connectors in this phase.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
GUID,
BundleDefinitionStatus,
BundleKey,
FeatureToggleStatus,
LicenseBindingStatus,
TenantBundleStatus,
)
class ExperienceBundleDefinition(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Catalog entry for a licensable Experience capability bundle."""
__tablename__ = "experience_bundle_definitions"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
bundle_key: Mapped[BundleKey] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[BundleDefinitionStatus] = mapped_column(
default=BundleDefinitionStatus.DRAFT, nullable=False
)
feature_keys: Mapped[list | None] = mapped_column(JSON, nullable=True)
permission_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True)
api_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"code",
name="uq_experience_bundle_definitions_tenant_code",
),
UniqueConstraint(
"tenant_id",
"bundle_key",
name="uq_experience_bundle_definitions_tenant_key",
),
Index("ix_experience_bundle_definitions_status", "tenant_id", "status"),
Index(
"ix_experience_bundle_definitions_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceTenantBundle(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Activated bundle assignment for a workspace (optional site scope)."""
__tablename__ = "experience_tenant_bundles"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
bundle_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
bundle_key: Mapped[BundleKey] = mapped_column(nullable=False)
status: Mapped[TenantBundleStatus] = mapped_column(
default=TenantBundleStatus.PENDING, nullable=False
)
core_entitlement_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
activated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
activated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"site_id",
"bundle_key",
name="uq_experience_tenant_bundles_scope_key",
),
Index("ix_experience_tenant_bundles_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_tenant_bundles_status", "tenant_id", "status"),
Index(
"ix_experience_tenant_bundles_tenant_deleted", "tenant_id", "is_deleted"
),
)
class ExperienceLicenseBinding(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Licensing shell — stores Core entitlement refs only (no plan engine)."""
__tablename__ = "experience_license_bindings"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
tenant_bundle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
bundle_key: Mapped[BundleKey | None] = mapped_column(nullable=True)
core_entitlement_ref: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[LicenseBindingStatus] = mapped_column(
default=LicenseBindingStatus.PENDING, nullable=False
)
starts_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
ends_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"core_entitlement_ref",
name="uq_experience_license_bindings_entitlement",
),
Index("ix_experience_license_bindings_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_license_bindings_status", "tenant_id", "status"),
Index(
"ix_experience_license_bindings_tenant_deleted", "tenant_id", "is_deleted"
),
)
class ExperienceFeatureToggle(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Per-workspace/site feature toggle for capability discovery."""
__tablename__ = "experience_feature_toggles"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
feature_key: Mapped[str] = mapped_column(String(100), nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
status: Mapped[FeatureToggleStatus] = mapped_column(
default=FeatureToggleStatus.ACTIVE, nullable=False
)
bundle_key: Mapped[BundleKey | None] = mapped_column(nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"site_id",
"feature_key",
name="uq_experience_feature_toggles_scope_key",
),
Index("ix_experience_feature_toggles_enabled", "tenant_id", "enabled"),
Index("ix_experience_feature_toggles_status", "tenant_id", "status"),
Index(
"ix_experience_feature_toggles_tenant_deleted", "tenant_id", "is_deleted"
),
)

View File

@ -0,0 +1,162 @@
"""Component library aggregates — Phase 11.2.
ExperienceComponent = mutable catalog entry.
ExperienceComponentVersion = immutable once published.
PageComponentPlacement = composition shell pinning a version onto a page.
No theme/layout engines in this phase.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
ComponentKind,
ComponentStatus,
ComponentVersionStatus,
GUID,
)
class ExperienceComponent(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Reusable component definition in the tenant library."""
__tablename__ = "experience_components"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
kind: Mapped[ComponentKind] = mapped_column(
default=ComponentKind.CONTENT, nullable=False
)
status: Mapped[ComponentStatus] = mapped_column(
default=ComponentStatus.DRAFT, nullable=False
)
category: Mapped[str | None] = mapped_column(String(100), nullable=True)
tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
latest_version_number: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
published_version_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
is_system: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"code",
name="uq_experience_components_tenant_code",
),
Index("ix_experience_components_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_components_kind", "tenant_id", "kind"),
Index("ix_experience_components_status", "tenant_id", "status"),
Index("ix_experience_components_category", "tenant_id", "category"),
Index("ix_experience_components_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperienceComponentVersion(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Immutable published revision of a component."""
__tablename__ = "experience_component_versions"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
component_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
version_number: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[ComponentVersionStatus] = mapped_column(
default=ComponentVersionStatus.DRAFT, nullable=False
)
definition_schema: Mapped[dict | None] = mapped_column(JSON, nullable=True)
props_defaults: Mapped[dict | None] = mapped_column(JSON, nullable=True)
render_hint: Mapped[dict | None] = mapped_column(JSON, nullable=True)
changelog: Mapped[str | None] = mapped_column(Text, nullable=True)
is_immutable: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
retired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"component_id",
"version_number",
name="uq_experience_component_versions_num",
),
Index("ix_experience_component_versions_component", "tenant_id", "component_id"),
Index("ix_experience_component_versions_status", "tenant_id", "status"),
Index(
"ix_experience_component_versions_tenant_deleted", "tenant_id", "is_deleted"
),
)
class PageComponentPlacement(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Composition shell: pin a component version onto a page (no layout engine)."""
__tablename__ = "experience_page_component_placements"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
page_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
component_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
component_version_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
slot_key: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
props_override: Mapped[dict | None] = mapped_column(JSON, nullable=True)
is_visible: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"page_id",
"slot_key",
name="uq_experience_page_component_placements_slot",
),
Index("ix_experience_page_placements_page", "tenant_id", "page_id"),
Index("ix_experience_page_placements_component", "tenant_id", "component_id"),
Index(
"ix_experience_page_placements_tenant_deleted", "tenant_id", "is_deleted"
),
)

View File

@ -0,0 +1,276 @@
"""Forms, surveys & appointment page shells — Phase 11.6.
ExperienceForm / FormSubmission = form definitions + submission shells (CRM/Communication refs only).
ExperienceSurvey / SurveyResponse = survey definitions + response shells.
ExperienceAppointmentPage / AppointmentBookingRef = appointment page shells + external booking refs.
No deep CRM/scheduling ownership; no SEO/PWA in this phase.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
GUID,
AppointmentBookingStatus,
AppointmentPageStatus,
FormStatus,
FormSubmissionStatus,
SurveyResponseStatus,
SurveyStatus,
)
class ExperienceForm(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Form definition shell bound to a workspace (optionally a site/page)."""
__tablename__ = "experience_forms"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[FormStatus] = mapped_column(default=FormStatus.DRAFT, nullable=False)
form_schema_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
submit_action_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"code",
name="uq_experience_forms_tenant_code",
),
Index("ix_experience_forms_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_forms_site", "tenant_id", "site_id"),
Index("ix_experience_forms_page", "tenant_id", "page_id"),
Index("ix_experience_forms_status", "tenant_id", "status"),
Index("ix_experience_forms_tenant_deleted", "tenant_id", "is_deleted"),
)
class FormSubmission(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Form submission shell — payload + external refs only (no CRM ownership)."""
__tablename__ = "experience_form_submissions"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
form_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[FormSubmissionStatus] = mapped_column(
default=FormSubmissionStatus.RECEIVED, nullable=False
)
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
crm_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
communication_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
external_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_experience_form_submissions_form", "tenant_id", "form_id"),
Index("ix_experience_form_submissions_status", "tenant_id", "status"),
Index(
"ix_experience_form_submissions_tenant_deleted", "tenant_id", "is_deleted"
),
)
class ExperienceSurvey(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Survey definition shell."""
__tablename__ = "experience_surveys"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[SurveyStatus] = mapped_column(
default=SurveyStatus.DRAFT, nullable=False
)
questions_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"code",
name="uq_experience_surveys_tenant_code",
),
Index("ix_experience_surveys_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_surveys_status", "tenant_id", "status"),
Index("ix_experience_surveys_tenant_deleted", "tenant_id", "is_deleted"),
)
class SurveyResponse(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Survey response shell — answers + external refs only."""
__tablename__ = "experience_survey_responses"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
survey_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[SurveyResponseStatus] = mapped_column(
default=SurveyResponseStatus.RECEIVED, nullable=False
)
answers_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
crm_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
external_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
submitted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_experience_survey_responses_survey", "tenant_id", "survey_id"),
Index("ix_experience_survey_responses_status", "tenant_id", "status"),
Index(
"ix_experience_survey_responses_tenant_deleted", "tenant_id", "is_deleted"
),
)
class ExperienceAppointmentPage(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Appointment page shell — booking provider/service refs only (no scheduler)."""
__tablename__ = "experience_appointment_pages"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[AppointmentPageStatus] = mapped_column(
default=AppointmentPageStatus.DRAFT, nullable=False
)
booking_provider_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
service_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
availability_shell_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"code",
name="uq_experience_appointment_pages_tenant_code",
),
Index("ix_experience_appointment_pages_site", "tenant_id", "site_id"),
Index("ix_experience_appointment_pages_status", "tenant_id", "status"),
Index(
"ix_experience_appointment_pages_tenant_deleted", "tenant_id", "is_deleted"
),
)
class AppointmentBookingRef(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""External booking reference shell — no scheduling engine ownership."""
__tablename__ = "experience_appointment_booking_refs"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
appointment_page_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
external_booking_ref: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[AppointmentBookingStatus] = mapped_column(
default=AppointmentBookingStatus.PENDING, nullable=False
)
crm_contact_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
slot_start: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
slot_end: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"appointment_page_id",
"external_booking_ref",
name="uq_experience_appointment_booking_refs_ext",
),
Index(
"ix_experience_appointment_booking_refs_page",
"tenant_id",
"appointment_page_id",
),
Index(
"ix_experience_appointment_booking_refs_status", "tenant_id", "status"
),
Index(
"ix_experience_appointment_booking_refs_tenant_deleted",
"tenant_id",
"is_deleted",
),
)

View File

@ -0,0 +1,303 @@
"""Experience foundation aggregates — Phase 11.0.
Independent aggregates use UUID references within experience_db only.
No SQLAlchemy relationship graphs between aggregates.
No dispatch/routing/tracking engines shells and contracts only.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
AuditAction,
GUID,
LifecycleStatus,
ProviderKind,
ProviderStatus,
RenderEngineStatus,
)
class ExperienceWorkspace(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Root delivery organization / fleet operator shell for a tenant (Torbat Pages)."""
__tablename__ = "experience_workspaces"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LifecycleStatus] = mapped_column(
default=LifecycleStatus.DRAFT, nullable=False
)
legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_experience_workspaces_tenant_code"),
Index("ix_experience_workspaces_tenant_status", "tenant_id", "status"),
Index("ix_experience_workspaces_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperienceLocaleProfile(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Locale / directionality profile under an experience workspace."""
__tablename__ = "experience_locale_profiles"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LifecycleStatus] = mapped_column(
default=LifecycleStatus.ACTIVE, nullable=False
)
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
text_direction: Mapped[str] = mapped_column(String(3), default="rtl", nullable=False)
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "workspace_id", "code", name="uq_experience_locale_profiles_tenant_code"
),
Index("ix_experience_locale_profiles_org", "tenant_id", "workspace_id"),
Index("ix_experience_locale_profiles_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperienceRole(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Local experience RBAC role catalog shell."""
__tablename__ = "experience_roles"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LifecycleStatus] = mapped_column(
default=LifecycleStatus.ACTIVE, nullable=False
)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_experience_roles_tenant_code"),
Index("ix_experience_roles_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperiencePermission(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Local delivery permission catalog shell (complements platform experience.* tree)."""
__tablename__ = "experience_permissions"
role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_experience_permissions_tenant_code"),
Index("ix_experience_permissions_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExternalProviderConfig(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""External theme/CDN/component provider registration shell — credentials stay here."""
__tablename__ = "external_provider_configs"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
provider_kind: Mapped[ProviderKind] = mapped_column(
default=ProviderKind.CUSTOM, nullable=False
)
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[ProviderStatus] = mapped_column(
default=ProviderStatus.REGISTERED, nullable=False
)
priority: Mapped[int] = mapped_column(default=100, nullable=False)
credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_external_provider_configs_tenant_code"
),
Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"),
Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"),
)
class RenderEngineRegistration(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Future render/SSG engine registration — adapter ready, no engine implementation."""
__tablename__ = "render_engine_registrations"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[RenderEngineStatus] = mapped_column(
default=RenderEngineStatus.REGISTERED, nullable=False
)
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_render_engine_registrations_tenant_code"
),
Index("ix_render_engine_registrations_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperienceConfiguration(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Tenant experience configuration — publish/SEO/analytics policy shells."""
__tablename__ = "experience_configurations"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
locale_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
publish_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
seo_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
analytics_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"code",
name="uq_experience_configurations_tenant_code",
),
Index("ix_experience_configurations_org", "tenant_id", "workspace_id"),
Index("ix_experience_configurations_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperienceSetting(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Key/value experience settings shell."""
__tablename__ = "experience_settings"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
locale_profile_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[dict | None] = mapped_column(JSON, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"workspace_id",
"locale_profile_id",
"key",
name="uq_experience_settings_tenant_key",
),
Index("ix_experience_settings_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExperienceAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
"""Append-only experience audit trail."""
__tablename__ = "experience_audit_logs"
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
action: Mapped[AuditAction] = mapped_column(nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index(
"ix_experience_audit_entity",
"tenant_id",
"entity_type",
"entity_id",
),
)

View File

@ -0,0 +1,257 @@
"""Consumer connectors, widgets & notify dispatches — Phase 11.9.
Experience stores connector registration shells, widget definitions/instances,
and Communication notify client dispatches. No SMS provider ownership (ADR-012/016).
"""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
GUID,
ConnectorDispatchDirection,
ConnectorDispatchStatus,
ConsumerConnectorKind,
ConsumerConnectorStatus,
NotifyDispatchStatus,
WidgetDefinitionStatus,
WidgetInstanceStatus,
WidgetKind,
)
class ExperienceConsumerConnectorRegistration(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_consumer_connector_registrations"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
kind: Mapped[ConsumerConnectorKind] = mapped_column(nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[ConsumerConnectorStatus] = mapped_column(
default=ConsumerConnectorStatus.DRAFT, nullable=False
)
external_service_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
entity_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"code",
name="uq_experience_consumer_connector_registrations_tenant_code",
),
Index(
"ix_experience_consumer_connector_registrations_workspace",
"tenant_id",
"workspace_id",
),
Index(
"ix_experience_consumer_connector_registrations_status",
"tenant_id",
"status",
),
Index(
"ix_experience_consumer_connector_registrations_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceConsumerConnectorDispatch(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_consumer_connector_dispatches"
registration_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
direction: Mapped[ConnectorDispatchDirection] = mapped_column(nullable=False)
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
payload_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[ConnectorDispatchStatus] = mapped_column(
default=ConnectorDispatchStatus.PENDING, nullable=False
)
response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index(
"ix_experience_consumer_connector_dispatches_registration",
"tenant_id",
"registration_id",
),
Index(
"ix_experience_consumer_connector_dispatches_status",
"tenant_id",
"status",
),
Index(
"ix_experience_consumer_connector_dispatches_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceWidgetDefinition(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_widget_definitions"
workspace_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
widget_kind: Mapped[WidgetKind] = mapped_column(nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[WidgetDefinitionStatus] = mapped_column(
default=WidgetDefinitionStatus.DRAFT, nullable=False
)
connector_kind: Mapped[ConsumerConnectorKind | None] = mapped_column(nullable=True)
default_config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
permission_prefixes: Mapped[list | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"code",
name="uq_experience_widget_definitions_tenant_code",
),
Index("ix_experience_widget_definitions_status", "tenant_id", "status"),
Index(
"ix_experience_widget_definitions_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceWidgetInstance(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_widget_instances"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
widget_definition_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
widget_kind: Mapped[WidgetKind] = mapped_column(nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[WidgetInstanceStatus] = mapped_column(
default=WidgetInstanceStatus.DRAFT, nullable=False
)
connector_registration_id: Mapped[uuid.UUID | None] = mapped_column(
GUID(), nullable=True
)
entity_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
embed_ref: Mapped[str] = mapped_column(String(255), nullable=False)
config_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"site_id",
"code",
name="uq_experience_widget_instances_site_code",
),
UniqueConstraint(
"tenant_id",
"embed_ref",
name="uq_experience_widget_instances_embed_ref",
),
Index("ix_experience_widget_instances_site", "tenant_id", "site_id"),
Index("ix_experience_widget_instances_status", "tenant_id", "status"),
Index(
"ix_experience_widget_instances_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceNotifyDispatch(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "experience_notify_dispatches"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
widget_instance_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
connector_registration_id: Mapped[uuid.UUID | None] = mapped_column(
GUID(), nullable=True
)
channel: Mapped[str] = mapped_column(String(50), nullable=False)
template_key: Mapped[str] = mapped_column(String(100), nullable=False)
communication_request_ref: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
to_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
payload_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[NotifyDispatchStatus] = mapped_column(
default=NotifyDispatchStatus.PENDING, nullable=False
)
response_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_experience_notify_dispatches_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_notify_dispatches_status", "tenant_id", "status"),
Index(
"ix_experience_notify_dispatches_tenant_deleted",
"tenant_id",
"is_deleted",
),
)

View File

@ -0,0 +1,177 @@
"""Localization, directionality & media-ref aggregates — Phase 11.5.
SiteLocaleBinding = bind a locale profile (+ concrete RTL/LTR) to a site.
ExperienceLocalization = multi-locale content shells for experience targets.
ExperienceMediaRef = Storage file references only (no binaries in experience_db).
No forms/surveys/appointments engine in this phase.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
GUID,
LocalizationStatus,
LocalizationTarget,
MediaKind,
MediaRefStatus,
TextDirection,
)
class SiteLocaleBinding(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Pin a locale profile (+ concrete direction) onto a site."""
__tablename__ = "experience_site_locale_bindings"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
locale_profile_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
locale_code: Mapped[str] = mapped_column(String(16), nullable=False)
text_direction: Mapped[TextDirection] = mapped_column(
default=TextDirection.RTL, nullable=False
)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"site_id",
"locale_profile_id",
name="uq_experience_site_locale_bindings_profile",
),
Index("ix_experience_site_locale_bindings_site", "tenant_id", "site_id"),
Index(
"ix_experience_site_locale_bindings_profile",
"tenant_id",
"locale_profile_id",
),
Index(
"ix_experience_site_locale_bindings_locale",
"tenant_id",
"locale_code",
),
Index(
"ix_experience_site_locale_bindings_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class ExperienceLocalization(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Multi-locale content shell for experience targets (RTL/LTR first-class)."""
__tablename__ = "experience_localizations"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
target_type: Mapped[LocalizationTarget] = mapped_column(nullable=False)
target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
locale: Mapped[str] = mapped_column(String(16), nullable=False)
text_direction: Mapped[TextDirection] = mapped_column(
default=TextDirection.RTL, nullable=False
)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
content_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status: Mapped[LocalizationStatus] = mapped_column(
default=LocalizationStatus.DRAFT, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"target_type",
"target_id",
"locale",
name="uq_experience_localizations_locale",
),
Index(
"ix_experience_localizations_target",
"tenant_id",
"target_type",
"target_id",
),
Index("ix_experience_localizations_locale", "tenant_id", "locale"),
Index("ix_experience_localizations_status", "tenant_id", "status"),
Index(
"ix_experience_localizations_tenant_deleted", "tenant_id", "is_deleted"
),
)
class ExperienceMediaRef(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Media reference shell — Storage file refs only (no binaries)."""
__tablename__ = "experience_media_refs"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
page_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
owner_type: Mapped[str | None] = mapped_column(String(50), nullable=True)
owner_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
kind: Mapped[MediaKind] = mapped_column(default=MediaKind.IMAGE, nullable=False)
storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False)
alt_text: Mapped[str | None] = mapped_column(String(255), nullable=True)
caption: Mapped[str | None] = mapped_column(String(500), nullable=True)
locale: Mapped[str | None] = mapped_column(String(16), nullable=True)
text_direction: Mapped[TextDirection | None] = mapped_column(nullable=True)
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
status: Mapped[MediaRefStatus] = mapped_column(
default=MediaRefStatus.ACTIVE, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_experience_media_refs_workspace", "tenant_id", "workspace_id"),
Index("ix_experience_media_refs_site", "tenant_id", "site_id"),
Index("ix_experience_media_refs_page", "tenant_id", "page_id"),
Index("ix_experience_media_refs_owner", "tenant_id", "owner_type", "owner_id"),
Index("ix_experience_media_refs_kind", "tenant_id", "kind"),
Index("ix_experience_media_refs_locale", "tenant_id", "locale"),
Index("ix_experience_media_refs_status", "tenant_id", "status"),
Index(
"ix_experience_media_refs_tenant_deleted", "tenant_id", "is_deleted"
),
)

View File

@ -0,0 +1,257 @@
"""Publishing, domain-edge refs, SEO & PWA shells — Phase 11.7.
PublishRelease = publish workflow audit (site/page) beyond simple status flips.
DomainEdgeBinding = DNS/SSL/edge map refs (ADR-009); edge ownership stays Core/Nginx.
SeoMetadata / SitemapShell / RobotsShell = SEO document shells.
PwaManifest = PWA readiness shell (manifest + service-worker ref).
No bundles/feature toggles in this phase.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
GUID,
DomainEdgeStatus,
PublishReleaseStatus,
PublishTarget,
PwaStatus,
RobotsStatus,
SeoStatus,
SeoTarget,
SitemapStatus,
)
class PublishRelease(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Publish workflow shell for a site or page."""
__tablename__ = "experience_publish_releases"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
target_type: Mapped[PublishTarget] = mapped_column(nullable=False)
target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[PublishReleaseStatus] = mapped_column(
default=PublishReleaseStatus.PENDING, nullable=False
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index(
"ix_experience_publish_releases_target",
"tenant_id",
"target_type",
"target_id",
),
Index("ix_experience_publish_releases_status", "tenant_id", "status"),
Index(
"ix_experience_publish_releases_tenant_deleted", "tenant_id", "is_deleted"
),
)
class DomainEdgeBinding(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Edge DNS/SSL binding refs for a site domain (ADR-009) — no Nginx ownership."""
__tablename__ = "experience_domain_edge_bindings"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_domain_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
host: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[DomainEdgeStatus] = mapped_column(
default=DomainEdgeStatus.PENDING, nullable=False
)
dns_status_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
ssl_cert_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
edge_map_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"site_domain_id",
name="uq_experience_domain_edge_bindings_domain",
),
Index("ix_experience_domain_edge_bindings_site", "tenant_id", "site_id"),
Index("ix_experience_domain_edge_bindings_host", "tenant_id", "host"),
Index("ix_experience_domain_edge_bindings_status", "tenant_id", "status"),
Index(
"ix_experience_domain_edge_bindings_tenant_deleted",
"tenant_id",
"is_deleted",
),
)
class SeoMetadata(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""SEO metadata shell for a site or page."""
__tablename__ = "experience_seo_metadata"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
target_type: Mapped[SeoTarget] = mapped_column(nullable=False)
target_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[SeoStatus] = mapped_column(default=SeoStatus.DRAFT, nullable=False)
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
canonical_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
robots_directive: Mapped[str | None] = mapped_column(String(100), nullable=True)
og_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"target_type",
"target_id",
name="uq_experience_seo_metadata_target",
),
Index("ix_experience_seo_metadata_status", "tenant_id", "status"),
Index(
"ix_experience_seo_metadata_tenant_deleted", "tenant_id", "is_deleted"
),
)
class SitemapShell(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Sitemap document shell for a site."""
__tablename__ = "experience_sitemap_shells"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[SitemapStatus] = mapped_column(
default=SitemapStatus.DRAFT, nullable=False
)
entries_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
storage_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "site_id", name="uq_experience_sitemap_shells_site"
),
Index("ix_experience_sitemap_shells_status", "tenant_id", "status"),
Index(
"ix_experience_sitemap_shells_tenant_deleted", "tenant_id", "is_deleted"
),
)
class RobotsShell(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Robots.txt shell for a site."""
__tablename__ = "experience_robots_shells"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[RobotsStatus] = mapped_column(
default=RobotsStatus.DRAFT, nullable=False
)
content_text: Mapped[str | None] = mapped_column(Text, nullable=True)
rules_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "site_id", name="uq_experience_robots_shells_site"
),
Index("ix_experience_robots_shells_status", "tenant_id", "status"),
Index(
"ix_experience_robots_shells_tenant_deleted", "tenant_id", "is_deleted"
),
)
class PwaManifest(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""PWA readiness shell — manifest JSON + service-worker Storage ref."""
__tablename__ = "experience_pwa_manifests"
workspace_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
site_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[PwaStatus] = mapped_column(default=PwaStatus.DRAFT, nullable=False)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
short_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
start_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
manifest_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
service_worker_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "site_id", name="uq_experience_pwa_manifests_site"
),
Index("ix_experience_pwa_manifests_status", "tenant_id", "status"),
Index(
"ix_experience_pwa_manifests_tenant_deleted", "tenant_id", "is_deleted"
),
)

Some files were not shown because too many files have changed in this diff Show More