feat: add multi-platform CMS architecture (Phase 1)
Implement the foundation for multi-platform support allowing independent business offerings (OMS, Loyalty, etc.) with their own CMS pages. Database Models: - Add Platform model for business offerings (domain, branding, config) - Add VendorPlatform junction table for many-to-many relationship - Update SubscriptionTier with platform_id and CMS limits - Update ContentPage with platform_id, is_platform_page for three-tier hierarchy - Add CMS feature codes (cms_basic, cms_custom_pages, cms_templates, etc.) Three-Tier Content Resolution: 1. Vendor override (platform_id + vendor_id + slug) 2. Vendor default (platform_id + vendor_id=NULL + is_platform_page=False) 3. Platform marketing pages (is_platform_page=True) New Components: - PlatformContextMiddleware for detecting platform from domain/path - ContentPageService updated with full three-tier resolution - Platform folder structure (app/platforms/oms/, app/platforms/loyalty/) - Alembic migration with backfill for existing data Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
374
alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py
Normal file
374
alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""add multi-platform support
|
||||
|
||||
Revision ID: z4e5f6a7b8c9
|
||||
Revises: 1b398cf45e85
|
||||
Create Date: 2026-01-18 12:00:00.000000
|
||||
|
||||
This migration adds multi-platform support:
|
||||
1. Creates platforms table for business offerings (OMS, Loyalty, etc.)
|
||||
2. Creates vendor_platforms junction table for many-to-many relationship
|
||||
3. Adds platform_id and CMS limits to subscription_tiers
|
||||
4. Adds platform_id and is_platform_page to content_pages
|
||||
5. Inserts default "oms" platform
|
||||
6. Backfills existing data to OMS platform
|
||||
7. Creates vendor_platforms entries for existing vendors
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "z4e5f6a7b8c9"
|
||||
down_revision: Union[str, None] = "1b398cf45e85"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Platform marketing page slugs (is_platform_page=True)
|
||||
PLATFORM_PAGE_SLUGS = [
|
||||
"platform_homepage",
|
||||
"home",
|
||||
"pricing",
|
||||
"about",
|
||||
"contact",
|
||||
"faq",
|
||||
"terms",
|
||||
"privacy",
|
||||
"features",
|
||||
"integrations",
|
||||
]
|
||||
|
||||
# CMS limits per tier
|
||||
CMS_TIER_LIMITS = {
|
||||
"essential": {"cms_pages_limit": 3, "cms_custom_pages_limit": 0},
|
||||
"professional": {"cms_pages_limit": 10, "cms_custom_pages_limit": 5},
|
||||
"business": {"cms_pages_limit": 30, "cms_custom_pages_limit": 20},
|
||||
"enterprise": {"cms_pages_limit": None, "cms_custom_pages_limit": None}, # Unlimited
|
||||
}
|
||||
|
||||
# CMS features per tier
|
||||
CMS_TIER_FEATURES = {
|
||||
"essential": ["cms_basic"],
|
||||
"professional": ["cms_basic", "cms_custom_pages", "cms_seo"],
|
||||
"business": ["cms_basic", "cms_custom_pages", "cms_seo", "cms_templates"],
|
||||
"enterprise": ["cms_basic", "cms_custom_pages", "cms_unlimited_pages", "cms_templates", "cms_seo", "cms_scheduling"],
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# =========================================================================
|
||||
# 1. Create platforms table
|
||||
# =========================================================================
|
||||
op.create_table(
|
||||
"platforms",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(50), nullable=False),
|
||||
sa.Column("name", sa.String(100), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("domain", sa.String(255), nullable=True),
|
||||
sa.Column("path_prefix", sa.String(50), nullable=True),
|
||||
sa.Column("logo", sa.String(500), nullable=True),
|
||||
sa.Column("logo_dark", sa.String(500), nullable=True),
|
||||
sa.Column("favicon", sa.String(500), nullable=True),
|
||||
sa.Column("theme_config", sa.JSON(), nullable=True),
|
||||
sa.Column("default_language", sa.String(5), nullable=False, server_default="fr"),
|
||||
sa.Column("supported_languages", sa.JSON(), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("settings", sa.JSON(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_platforms_code", "platforms", ["code"], unique=True)
|
||||
op.create_index("ix_platforms_domain", "platforms", ["domain"], unique=True)
|
||||
op.create_index("ix_platforms_path_prefix", "platforms", ["path_prefix"], unique=True)
|
||||
op.create_index("idx_platform_active", "platforms", ["is_active"])
|
||||
op.create_index("idx_platform_public", "platforms", ["is_public", "is_active"])
|
||||
|
||||
# =========================================================================
|
||||
# 2. Create vendor_platforms junction table
|
||||
# =========================================================================
|
||||
op.create_table(
|
||||
"vendor_platforms",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("vendor_id", sa.Integer(), nullable=False),
|
||||
sa.Column("platform_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tier_id", sa.Integer(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
|
||||
sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"),
|
||||
sa.Column("custom_subdomain", sa.String(100), nullable=True),
|
||||
sa.Column("settings", sa.JSON(), nullable=True),
|
||||
sa.Column("joined_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(["vendor_id"], ["vendors.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["platform_id"], ["platforms.id"], ondelete="CASCADE"),
|
||||
sa.ForeignKeyConstraint(["tier_id"], ["subscription_tiers.id"], ondelete="SET NULL"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_vendor_platforms_vendor_id", "vendor_platforms", ["vendor_id"])
|
||||
op.create_index("ix_vendor_platforms_platform_id", "vendor_platforms", ["platform_id"])
|
||||
op.create_index("ix_vendor_platforms_tier_id", "vendor_platforms", ["tier_id"])
|
||||
op.create_index("idx_vendor_platform_active", "vendor_platforms", ["vendor_id", "platform_id", "is_active"])
|
||||
op.create_index("idx_vendor_platform_primary", "vendor_platforms", ["vendor_id", "is_primary"])
|
||||
op.create_unique_constraint("uq_vendor_platform", "vendor_platforms", ["vendor_id", "platform_id"])
|
||||
|
||||
# =========================================================================
|
||||
# 3. Add platform_id and CMS columns to subscription_tiers
|
||||
# =========================================================================
|
||||
# Add platform_id column (nullable for global tiers)
|
||||
op.add_column(
|
||||
"subscription_tiers",
|
||||
sa.Column("platform_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_subscription_tiers_platform_id", "subscription_tiers", ["platform_id"])
|
||||
op.create_foreign_key(
|
||||
"fk_subscription_tiers_platform_id",
|
||||
"subscription_tiers",
|
||||
"platforms",
|
||||
["platform_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
# Add CMS limit columns
|
||||
op.add_column(
|
||||
"subscription_tiers",
|
||||
sa.Column("cms_pages_limit", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"subscription_tiers",
|
||||
sa.Column("cms_custom_pages_limit", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("idx_tier_platform_active", "subscription_tiers", ["platform_id", "is_active"])
|
||||
|
||||
# =========================================================================
|
||||
# 4. Add platform_id and is_platform_page to content_pages
|
||||
# =========================================================================
|
||||
# Add platform_id column (will be set to NOT NULL after backfill)
|
||||
op.add_column(
|
||||
"content_pages",
|
||||
sa.Column("platform_id", sa.Integer(), nullable=True),
|
||||
)
|
||||
op.create_index("ix_content_pages_platform_id", "content_pages", ["platform_id"])
|
||||
|
||||
# Add is_platform_page column
|
||||
op.add_column(
|
||||
"content_pages",
|
||||
sa.Column("is_platform_page", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
op.create_index("idx_platform_page_type", "content_pages", ["platform_id", "is_platform_page"])
|
||||
|
||||
# =========================================================================
|
||||
# 5. Insert default OMS platform
|
||||
# =========================================================================
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
INSERT INTO platforms (code, name, description, domain, path_prefix, default_language,
|
||||
supported_languages, is_active, is_public, theme_config, settings,
|
||||
created_at, updated_at)
|
||||
VALUES ('oms', 'Wizamart OMS', 'Order Management System for Luxembourg merchants',
|
||||
'oms.lu', 'oms', 'fr', '["fr", "de", "en"]', true, true, '{}', '{}',
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
""")
|
||||
)
|
||||
|
||||
# Get the OMS platform ID
|
||||
result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'oms'"))
|
||||
oms_platform_id = result.fetchone()[0]
|
||||
|
||||
# =========================================================================
|
||||
# 6. Backfill content_pages with platform_id
|
||||
# =========================================================================
|
||||
conn.execute(
|
||||
sa.text(f"UPDATE content_pages SET platform_id = {oms_platform_id} WHERE platform_id IS NULL")
|
||||
)
|
||||
|
||||
# Set is_platform_page=True for platform marketing page slugs
|
||||
# Only for pages that have vendor_id=NULL (platform-level pages)
|
||||
slugs_list = ", ".join([f"'{slug}'" for slug in PLATFORM_PAGE_SLUGS])
|
||||
conn.execute(
|
||||
sa.text(f"""
|
||||
UPDATE content_pages
|
||||
SET is_platform_page = true
|
||||
WHERE vendor_id IS NULL AND slug IN ({slugs_list})
|
||||
""")
|
||||
)
|
||||
|
||||
# Make platform_id NOT NULL after backfill
|
||||
op.alter_column("content_pages", "platform_id", nullable=False)
|
||||
|
||||
# Add foreign key constraint
|
||||
op.create_foreign_key(
|
||||
"fk_content_pages_platform_id",
|
||||
"content_pages",
|
||||
"platforms",
|
||||
["platform_id"],
|
||||
["id"],
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 7. Update content_pages constraints
|
||||
# =========================================================================
|
||||
# Drop old unique constraint
|
||||
op.drop_constraint("uq_vendor_slug", "content_pages", type_="unique")
|
||||
|
||||
# Create new unique constraint including platform_id
|
||||
op.create_unique_constraint(
|
||||
"uq_platform_vendor_slug",
|
||||
"content_pages",
|
||||
["platform_id", "vendor_id", "slug"],
|
||||
)
|
||||
|
||||
# Update indexes
|
||||
op.drop_index("idx_vendor_published", table_name="content_pages")
|
||||
op.drop_index("idx_slug_published", table_name="content_pages")
|
||||
op.create_index("idx_platform_vendor_published", "content_pages", ["platform_id", "vendor_id", "is_published"])
|
||||
op.create_index("idx_platform_slug_published", "content_pages", ["platform_id", "slug", "is_published"])
|
||||
|
||||
# =========================================================================
|
||||
# 8. Update subscription_tiers with CMS limits
|
||||
# =========================================================================
|
||||
for tier_code, limits in CMS_TIER_LIMITS.items():
|
||||
cms_pages = limits["cms_pages_limit"] if limits["cms_pages_limit"] is not None else "NULL"
|
||||
cms_custom = limits["cms_custom_pages_limit"] if limits["cms_custom_pages_limit"] is not None else "NULL"
|
||||
conn.execute(
|
||||
sa.text(f"""
|
||||
UPDATE subscription_tiers
|
||||
SET cms_pages_limit = {cms_pages},
|
||||
cms_custom_pages_limit = {cms_custom}
|
||||
WHERE code = '{tier_code}'
|
||||
""")
|
||||
)
|
||||
|
||||
# Add CMS features to each tier
|
||||
for tier_code, cms_features in CMS_TIER_FEATURES.items():
|
||||
# Get current features
|
||||
result = conn.execute(
|
||||
sa.text(f"SELECT features FROM subscription_tiers WHERE code = '{tier_code}'")
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row and row[0]:
|
||||
current_features = json.loads(row[0]) if isinstance(row[0], str) else row[0]
|
||||
else:
|
||||
current_features = []
|
||||
|
||||
# Add CMS features that aren't already present
|
||||
for feature in cms_features:
|
||||
if feature not in current_features:
|
||||
current_features.append(feature)
|
||||
|
||||
# Update features
|
||||
features_json = json.dumps(current_features)
|
||||
conn.execute(
|
||||
sa.text(f"UPDATE subscription_tiers SET features = '{features_json}' WHERE code = '{tier_code}'")
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 9. Create vendor_platforms entries for existing vendors
|
||||
# =========================================================================
|
||||
# Get all vendors with their subscription tier_id
|
||||
conn.execute(
|
||||
sa.text(f"""
|
||||
INSERT INTO vendor_platforms (vendor_id, platform_id, tier_id, is_active, is_primary,
|
||||
joined_at, created_at, updated_at)
|
||||
SELECT v.id, {oms_platform_id}, vs.tier_id, v.is_active, true,
|
||||
v.created_at, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM vendors v
|
||||
LEFT JOIN vendor_subscriptions vs ON vs.vendor_id = v.id
|
||||
""")
|
||||
)
|
||||
|
||||
# =========================================================================
|
||||
# 10. Add CMS feature records to features table
|
||||
# =========================================================================
|
||||
# Get minimum tier IDs for CMS features
|
||||
tier_ids = {}
|
||||
result = conn.execute(sa.text("SELECT id, code FROM subscription_tiers"))
|
||||
for row in result:
|
||||
tier_ids[row[1]] = row[0]
|
||||
|
||||
cms_features = [
|
||||
("cms", "cms_basic", "Basic CMS", "Override default pages with custom content", "settings", "document-text", None, tier_ids.get("essential"), 1),
|
||||
("cms", "cms_custom_pages", "Custom Pages", "Create custom pages beyond defaults", "settings", "document-add", None, tier_ids.get("professional"), 2),
|
||||
("cms", "cms_unlimited_pages", "Unlimited Pages", "No page limit", "settings", "documents", None, tier_ids.get("enterprise"), 3),
|
||||
("cms", "cms_templates", "Page Templates", "Access to page templates", "settings", "template", None, tier_ids.get("business"), 4),
|
||||
("cms", "cms_seo", "Advanced SEO", "SEO metadata and optimization", "settings", "search", None, tier_ids.get("professional"), 5),
|
||||
("cms", "cms_scheduling", "Page Scheduling", "Schedule page publish/unpublish", "settings", "clock", None, tier_ids.get("enterprise"), 6),
|
||||
]
|
||||
|
||||
for category, code, name, description, ui_location, ui_icon, ui_route, minimum_tier_id, display_order in cms_features:
|
||||
min_tier_val = minimum_tier_id if minimum_tier_id else "NULL"
|
||||
conn.execute(
|
||||
sa.text(f"""
|
||||
INSERT INTO features (code, name, description, category, ui_location, ui_icon, ui_route,
|
||||
minimum_tier_id, is_active, is_visible, display_order, created_at, updated_at)
|
||||
VALUES ('{code}', '{name}', '{description}', '{category}', '{ui_location}', '{ui_icon}', NULL,
|
||||
{min_tier_val}, true, true, {display_order}, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT (code) DO NOTHING
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# Remove CMS features from features table
|
||||
conn.execute(
|
||||
sa.text("""
|
||||
DELETE FROM features
|
||||
WHERE code IN ('cms_basic', 'cms_custom_pages', 'cms_unlimited_pages',
|
||||
'cms_templates', 'cms_seo', 'cms_scheduling')
|
||||
""")
|
||||
)
|
||||
|
||||
# Remove CMS features from subscription_tiers
|
||||
result = conn.execute(sa.text("SELECT id, code, features FROM subscription_tiers"))
|
||||
for row in result:
|
||||
tier_id, tier_code, features_data = row
|
||||
if features_data:
|
||||
current_features = json.loads(features_data) if isinstance(features_data, str) else features_data
|
||||
# Remove CMS features
|
||||
updated_features = [f for f in current_features if not f.startswith("cms_")]
|
||||
features_json = json.dumps(updated_features)
|
||||
conn.execute(
|
||||
sa.text(f"UPDATE subscription_tiers SET features = '{features_json}' WHERE id = {tier_id}")
|
||||
)
|
||||
|
||||
# Drop vendor_platforms table
|
||||
op.drop_table("vendor_platforms")
|
||||
|
||||
# Restore old content_pages constraints
|
||||
op.drop_constraint("uq_platform_vendor_slug", "content_pages", type_="unique")
|
||||
op.drop_index("idx_platform_vendor_published", table_name="content_pages")
|
||||
op.drop_index("idx_platform_slug_published", table_name="content_pages")
|
||||
op.drop_index("idx_platform_page_type", table_name="content_pages")
|
||||
op.drop_constraint("fk_content_pages_platform_id", "content_pages", type_="foreignkey")
|
||||
op.drop_index("ix_content_pages_platform_id", table_name="content_pages")
|
||||
op.drop_column("content_pages", "is_platform_page")
|
||||
op.drop_column("content_pages", "platform_id")
|
||||
op.create_unique_constraint("uq_vendor_slug", "content_pages", ["vendor_id", "slug"])
|
||||
op.create_index("idx_vendor_published", "content_pages", ["vendor_id", "is_published"])
|
||||
op.create_index("idx_slug_published", "content_pages", ["slug", "is_published"])
|
||||
|
||||
# Remove subscription_tiers platform columns
|
||||
op.drop_index("idx_tier_platform_active", table_name="subscription_tiers")
|
||||
op.drop_constraint("fk_subscription_tiers_platform_id", "subscription_tiers", type_="foreignkey")
|
||||
op.drop_index("ix_subscription_tiers_platform_id", table_name="subscription_tiers")
|
||||
op.drop_column("subscription_tiers", "cms_custom_pages_limit")
|
||||
op.drop_column("subscription_tiers", "cms_pages_limit")
|
||||
op.drop_column("subscription_tiers", "platform_id")
|
||||
|
||||
# Drop platforms table
|
||||
op.drop_index("idx_platform_public", table_name="platforms")
|
||||
op.drop_index("idx_platform_active", table_name="platforms")
|
||||
op.drop_index("ix_platforms_path_prefix", table_name="platforms")
|
||||
op.drop_index("ix_platforms_domain", table_name="platforms")
|
||||
op.drop_index("ix_platforms_code", table_name="platforms")
|
||||
op.drop_table("platforms")
|
||||
Reference in New Issue
Block a user