TorbatYar/backend/services/experience/app/models/publishing.py
Mortezakoohjani 203671a7bf feat(experience): ship Experience Platform phases 11.0-11.10
Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:43:10 +03:30

258 lines
8.9 KiB
Python

"""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"
),
)