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:
parent
625275e55b
commit
203671a7bf
22
backend/services/experience/Dockerfile.dev
Normal file
22
backend/services/experience/Dockerfile.dev
Normal 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
|
||||||
42
backend/services/experience/README.md
Normal file
42
backend/services/experience/README.md
Normal 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)
|
||||||
41
backend/services/experience/alembic.ini
Normal file
41
backend/services/experience/alembic.ini
Normal 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
|
||||||
42
backend/services/experience/alembic/env.py
Normal file
42
backend/services/experience/alembic/env.py
Normal 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()
|
||||||
19
backend/services/experience/alembic/versions/0001_initial.py
Normal file
19
backend/services/experience/alembic/versions/0001_initial.py
Normal 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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
1
backend/services/experience/app/__init__.py
Normal file
1
backend/services/experience/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.11.10.0"
|
||||||
39
backend/services/experience/app/api/deps.py
Normal file
39
backend/services/experience/app/api/deps.py
Normal 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
|
||||||
48
backend/services/experience/app/api/permissions.py
Normal file
48
backend/services/experience/app/api/permissions.py
Normal 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
|
||||||
194
backend/services/experience/app/api/v1/__init__.py
Normal file
194
backend/services/experience/app/api/v1/__init__.py
Normal 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"],
|
||||||
|
)
|
||||||
118
backend/services/experience/app/api/v1/ai_content_dispatches.py
Normal file
118
backend/services/experience/app/api/v1/ai_content_dispatches.py
Normal 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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
106
backend/services/experience/app/api/v1/analytics_snapshots.py
Normal file
106
backend/services/experience/app/api/v1/analytics_snapshots.py
Normal 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)
|
||||||
@ -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
|
||||||
|
)
|
||||||
120
backend/services/experience/app/api/v1/appointment_pages.py
Normal file
120
backend/services/experience/app/api/v1/appointment_pages.py
Normal 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
|
||||||
|
)
|
||||||
30
backend/services/experience/app/api/v1/audit.py
Normal file
30
backend/services/experience/app/api/v1/audit.py
Normal 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
|
||||||
|
)
|
||||||
49
backend/services/experience/app/api/v1/bundle_definitions.py
Normal file
49
backend/services/experience/app/api/v1/bundle_definitions.py
Normal 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)
|
||||||
133
backend/services/experience/app/api/v1/component_versions.py
Normal file
133
backend/services/experience/app/api/v1/component_versions.py
Normal 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
|
||||||
|
)
|
||||||
174
backend/services/experience/app/api/v1/components.py
Normal file
174
backend/services/experience/app/api/v1/components.py
Normal 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
|
||||||
|
)
|
||||||
83
backend/services/experience/app/api/v1/configurations.py
Normal file
83
backend/services/experience/app/api/v1/configurations.py
Normal 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
|
||||||
|
)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
83
backend/services/experience/app/api/v1/external_providers.py
Normal file
83
backend/services/experience/app/api/v1/external_providers.py
Normal 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
|
||||||
|
)
|
||||||
50
backend/services/experience/app/api/v1/feature_toggles.py
Normal file
50
backend/services/experience/app/api/v1/feature_toggles.py
Normal 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)
|
||||||
96
backend/services/experience/app/api/v1/form_submissions.py
Normal file
96
backend/services/experience/app/api/v1/form_submissions.py
Normal 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
|
||||||
|
)
|
||||||
109
backend/services/experience/app/api/v1/forms.py
Normal file
109
backend/services/experience/app/api/v1/forms.py
Normal 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)
|
||||||
226
backend/services/experience/app/api/v1/health.py
Normal file
226
backend/services/experience/app/api/v1/health.py
Normal 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",
|
||||||
|
}
|
||||||
124
backend/services/experience/app/api/v1/layout_versions.py
Normal file
124
backend/services/experience/app/api/v1/layout_versions.py
Normal 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
|
||||||
|
)
|
||||||
157
backend/services/experience/app/api/v1/layouts.py
Normal file
157
backend/services/experience/app/api/v1/layouts.py
Normal 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
|
||||||
|
)
|
||||||
49
backend/services/experience/app/api/v1/license_bindings.py
Normal file
49
backend/services/experience/app/api/v1/license_bindings.py
Normal 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)
|
||||||
70
backend/services/experience/app/api/v1/locale_profiles.py
Normal file
70
backend/services/experience/app/api/v1/locale_profiles.py
Normal 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)
|
||||||
137
backend/services/experience/app/api/v1/localizations.py
Normal file
137
backend/services/experience/app/api/v1/localizations.py
Normal 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
|
||||||
|
)
|
||||||
125
backend/services/experience/app/api/v1/media_refs.py
Normal file
125
backend/services/experience/app/api/v1/media_refs.py
Normal 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
|
||||||
|
)
|
||||||
116
backend/services/experience/app/api/v1/notify_dispatches.py
Normal file
116
backend/services/experience/app/api/v1/notify_dispatches.py
Normal 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)
|
||||||
@ -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
|
||||||
|
)
|
||||||
100
backend/services/experience/app/api/v1/page_layout_bindings.py
Normal file
100
backend/services/experience/app/api/v1/page_layout_bindings.py
Normal 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
|
||||||
|
)
|
||||||
112
backend/services/experience/app/api/v1/pages.py
Normal file
112
backend/services/experience/app/api/v1/pages.py
Normal 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)
|
||||||
50
backend/services/experience/app/api/v1/publish_releases.py
Normal file
50
backend/services/experience/app/api/v1/publish_releases.py
Normal 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)
|
||||||
33
backend/services/experience/app/api/v1/pwa_manifests.py
Normal file
33
backend/services/experience/app/api/v1/pwa_manifests.py
Normal 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)
|
||||||
83
backend/services/experience/app/api/v1/render_engines.py
Normal file
83
backend/services/experience/app/api/v1/render_engines.py
Normal 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
|
||||||
|
)
|
||||||
33
backend/services/experience/app/api/v1/robots_shells.py
Normal file
33
backend/services/experience/app/api/v1/robots_shells.py
Normal 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)
|
||||||
43
backend/services/experience/app/api/v1/seo_metadata.py
Normal file
43
backend/services/experience/app/api/v1/seo_metadata.py
Normal 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)
|
||||||
39
backend/services/experience/app/api/v1/settings.py
Normal file
39
backend/services/experience/app/api/v1/settings.py
Normal 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
|
||||||
|
)
|
||||||
86
backend/services/experience/app/api/v1/site_domains.py
Normal file
86
backend/services/experience/app/api/v1/site_domains.py
Normal 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
|
||||||
|
)
|
||||||
127
backend/services/experience/app/api/v1/site_locale_bindings.py
Normal file
127
backend/services/experience/app/api/v1/site_locale_bindings.py
Normal 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
|
||||||
|
)
|
||||||
@ -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
|
||||||
|
)
|
||||||
33
backend/services/experience/app/api/v1/sitemap_shells.py
Normal file
33
backend/services/experience/app/api/v1/sitemap_shells.py
Normal 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)
|
||||||
115
backend/services/experience/app/api/v1/sites.py
Normal file
115
backend/services/experience/app/api/v1/sites.py
Normal 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)
|
||||||
96
backend/services/experience/app/api/v1/survey_responses.py
Normal file
96
backend/services/experience/app/api/v1/survey_responses.py
Normal 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
|
||||||
|
)
|
||||||
113
backend/services/experience/app/api/v1/surveys.py
Normal file
113
backend/services/experience/app/api/v1/surveys.py
Normal 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)
|
||||||
@ -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)
|
||||||
129
backend/services/experience/app/api/v1/template_versions.py
Normal file
129
backend/services/experience/app/api/v1/template_versions.py
Normal 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
|
||||||
|
)
|
||||||
155
backend/services/experience/app/api/v1/templates.py
Normal file
155
backend/services/experience/app/api/v1/templates.py
Normal 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
|
||||||
|
)
|
||||||
50
backend/services/experience/app/api/v1/tenant_bundles.py
Normal file
50
backend/services/experience/app/api/v1/tenant_bundles.py
Normal 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)
|
||||||
124
backend/services/experience/app/api/v1/theme_versions.py
Normal file
124
backend/services/experience/app/api/v1/theme_versions.py
Normal 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
|
||||||
|
)
|
||||||
146
backend/services/experience/app/api/v1/themes.py
Normal file
146
backend/services/experience/app/api/v1/themes.py
Normal 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
|
||||||
|
)
|
||||||
105
backend/services/experience/app/api/v1/widget_definitions.py
Normal file
105
backend/services/experience/app/api/v1/widget_definitions.py
Normal 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)
|
||||||
117
backend/services/experience/app/api/v1/widget_instances.py
Normal file
117
backend/services/experience/app/api/v1/widget_instances.py
Normal 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)
|
||||||
83
backend/services/experience/app/api/v1/workspaces.py
Normal file
83
backend/services/experience/app/api/v1/workspaces.py
Normal 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
|
||||||
|
)
|
||||||
16
backend/services/experience/app/commands/__init__.py
Normal file
16
backend/services/experience/app/commands/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
30
backend/services/experience/app/commands/ai_hooks.py
Normal file
30
backend/services/experience/app/commands/ai_hooks.py
Normal 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)
|
||||||
27
backend/services/experience/app/commands/analytics.py
Normal file
27
backend/services/experience/app/commands/analytics.py
Normal 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)
|
||||||
43
backend/services/experience/app/commands/bundles.py
Normal file
43
backend/services/experience/app/commands/bundles.py
Normal 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)
|
||||||
126
backend/services/experience/app/commands/components.py
Normal file
126
backend/services/experience/app/commands/components.py
Normal 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
|
||||||
|
)
|
||||||
169
backend/services/experience/app/commands/forms.py
Normal file
169
backend/services/experience/app/commands/forms.py
Normal 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
|
||||||
|
)
|
||||||
68
backend/services/experience/app/commands/integrations.py
Normal file
68
backend/services/experience/app/commands/integrations.py
Normal 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)
|
||||||
108
backend/services/experience/app/commands/localization.py
Normal file
108
backend/services/experience/app/commands/localization.py
Normal 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
|
||||||
|
)
|
||||||
64
backend/services/experience/app/commands/publishing.py
Normal file
64
backend/services/experience/app/commands/publishing.py
Normal 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)
|
||||||
102
backend/services/experience/app/commands/templates.py
Normal file
102
backend/services/experience/app/commands/templates.py
Normal 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)
|
||||||
240
backend/services/experience/app/commands/themes.py
Normal file
240
backend/services/experience/app/commands/themes.py
Normal 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
|
||||||
|
)
|
||||||
75
backend/services/experience/app/core/config.py
Normal file
75
backend/services/experience/app/core/config.py
Normal 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()
|
||||||
27
backend/services/experience/app/core/database.py
Normal file
27
backend/services/experience/app/core/database.py
Normal 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
|
||||||
14
backend/services/experience/app/core/logging.py
Normal file
14
backend/services/experience/app/core/logging.py
Normal 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)
|
||||||
49
backend/services/experience/app/core/security.py
Normal file
49
backend/services/experience/app/core/security.py
Normal 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)
|
||||||
63
backend/services/experience/app/events/publisher.py
Normal file
63
backend/services/experience/app/events/publisher.py
Normal 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
|
||||||
158
backend/services/experience/app/events/types.py
Normal file
158
backend/services/experience/app/events/types.py
Normal 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"
|
||||||
69
backend/services/experience/app/main.py
Normal file
69
backend/services/experience/app/main.py
Normal 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()
|
||||||
24
backend/services/experience/app/middlewares/tenant.py
Normal file
24
backend/services/experience/app/middlewares/tenant.py
Normal 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)
|
||||||
77
backend/services/experience/app/models/__init__.py
Normal file
77
backend/services/experience/app/models/__init__.py
Normal 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,
|
||||||
|
)
|
||||||
119
backend/services/experience/app/models/ai_hooks.py
Normal file
119
backend/services/experience/app/models/ai_hooks.py
Normal 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
109
backend/services/experience/app/models/analytics.py
Normal file
109
backend/services/experience/app/models/analytics.py
Normal 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
51
backend/services/experience/app/models/base.py
Normal file
51
backend/services/experience/app/models/base.py
Normal 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)
|
||||||
208
backend/services/experience/app/models/bundles.py
Normal file
208
backend/services/experience/app/models/bundles.py
Normal 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"
|
||||||
|
),
|
||||||
|
)
|
||||||
162
backend/services/experience/app/models/components.py
Normal file
162
backend/services/experience/app/models/components.py
Normal 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"
|
||||||
|
),
|
||||||
|
)
|
||||||
276
backend/services/experience/app/models/forms.py
Normal file
276
backend/services/experience/app/models/forms.py
Normal 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
303
backend/services/experience/app/models/foundation.py
Normal file
303
backend/services/experience/app/models/foundation.py
Normal 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
257
backend/services/experience/app/models/integrations.py
Normal file
257
backend/services/experience/app/models/integrations.py
Normal 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
177
backend/services/experience/app/models/localization.py
Normal file
177
backend/services/experience/app/models/localization.py
Normal 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"
|
||||||
|
),
|
||||||
|
)
|
||||||
257
backend/services/experience/app/models/publishing.py
Normal file
257
backend/services/experience/app/models/publishing.py
Normal 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
Loading…
Reference in New Issue
Block a user