feat: add CMS database model and migrations

Implement Content Management System database layer:

Database Model:
- ContentPage model with two-tier architecture
- Platform defaults (vendor_id=NULL)
- Vendor-specific overrides (vendor_id=123)
- SEO fields (meta_description, meta_keywords)
- Publishing workflow (is_published, published_at)
- Navigation flags (show_in_footer, show_in_header)
- Display ordering and timestamps

Migrations:
- Create content_pages table with all columns
- Add indexes for performance (vendor_id, slug, published status)
- Add unique constraint on (vendor_id, slug)
- Add foreign key relationships with cascade delete

Model Registration:
- Add ContentPage to Vendor relationship
- Import model in alembic/env.py for migration detection

This provides the foundation for managing static content pages
(About, FAQ, Contact, etc.) with platform defaults and vendor overrides.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-22 15:54:29 +01:00
parent 2dfda3e312
commit c219f5b5f8
5 changed files with 245 additions and 0 deletions

View File

@@ -93,6 +93,16 @@ try:
except ImportError as e:
print(f" ✗ VendorTheme model failed: {e}")
# ----------------------------------------------------------------------------
# CONTENT PAGE MODEL (CMS)
# ----------------------------------------------------------------------------
try:
from models.database.content_page import ContentPage
print(" ✓ ContentPage model imported")
except ImportError as e:
print(f" ✗ ContentPage model failed: {e}")
# ----------------------------------------------------------------------------
# PRODUCT MODELS
# ----------------------------------------------------------------------------

View File

@@ -0,0 +1,63 @@
"""Ensure content_pages table with all columns
Revision ID: 72aa309d4007
Revises: fef1d20ce8b4
Create Date: 2025-11-22 15:16:13.213613
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '72aa309d4007'
down_revision: Union[str, None] = 'fef1d20ce8b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('content_pages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('vendor_id', sa.Integer(), nullable=True),
sa.Column('slug', sa.String(length=100), nullable=False),
sa.Column('title', sa.String(length=200), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('content_format', sa.String(length=20), nullable=True),
sa.Column('meta_description', sa.String(length=300), nullable=True),
sa.Column('meta_keywords', sa.String(length=300), nullable=True),
sa.Column('is_published', sa.Boolean(), nullable=False),
sa.Column('published_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('display_order', sa.Integer(), nullable=True),
sa.Column('show_in_footer', sa.Boolean(), nullable=True),
sa.Column('show_in_header', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=True),
sa.Column('updated_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('vendor_id', 'slug', name='uq_vendor_slug')
)
op.create_index('idx_slug_published', 'content_pages', ['slug', 'is_published'], unique=False)
op.create_index('idx_vendor_published', 'content_pages', ['vendor_id', 'is_published'], unique=False)
op.create_index(op.f('ix_content_pages_id'), 'content_pages', ['id'], unique=False)
op.create_index(op.f('ix_content_pages_slug'), 'content_pages', ['slug'], unique=False)
op.create_index(op.f('ix_content_pages_vendor_id'), 'content_pages', ['vendor_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_content_pages_vendor_id'), table_name='content_pages')
op.drop_index(op.f('ix_content_pages_slug'), table_name='content_pages')
op.drop_index(op.f('ix_content_pages_id'), table_name='content_pages')
op.drop_index('idx_vendor_published', table_name='content_pages')
op.drop_index('idx_slug_published', table_name='content_pages')
op.drop_table('content_pages')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Add content_pages table for CMS
Revision ID: fef1d20ce8b4
Revises: fa7d4d10e358
Create Date: 2025-11-22 13:41:18.069674
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'fef1d20ce8b4'
down_revision: Union[str, None] = 'fa7d4d10e358'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_roles_vendor_name', table_name='roles')
op.drop_index('idx_vendor_users_invitation_token', table_name='vendor_users')
op.create_index(op.f('ix_vendor_users_invitation_token'), 'vendor_users', ['invitation_token'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_vendor_users_invitation_token'), table_name='vendor_users')
op.create_index('idx_vendor_users_invitation_token', 'vendor_users', ['invitation_token'], unique=False)
op.create_index('idx_roles_vendor_name', 'roles', ['vendor_id', 'name'], unique=False)
# ### end Alembic commands ###

View File

@@ -0,0 +1,131 @@
# models/database/content_page.py
"""
Content Page Model
Manages static content pages (About, FAQ, Contact, Shipping, Returns, etc.)
with platform-level defaults and vendor-specific overrides.
Features:
- Platform-level default content
- Vendor-specific overrides
- Rich text content (HTML/Markdown)
- SEO metadata
- Published/Draft status
- Version history support
"""
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, Index
from sqlalchemy.orm import relationship
from app.core.database import Base
class ContentPage(Base):
"""
Content pages for shops (About, FAQ, Contact, etc.)
Two-tier system:
1. Platform-level defaults (vendor_id=NULL)
2. Vendor-specific overrides (vendor_id=123)
Lookup logic:
1. Check for vendor-specific page (vendor_id + slug)
2. If not found, use platform default (slug only)
3. If neither exists, show 404 or default template
"""
__tablename__ = "content_pages"
id = Column(Integer, primary_key=True, index=True)
# Vendor association (NULL = platform default)
vendor_id = Column(Integer, ForeignKey("vendors.id", ondelete="CASCADE"), nullable=True, index=True)
# Page identification
slug = Column(String(100), nullable=False, index=True) # about, faq, contact, shipping, returns, etc.
title = Column(String(200), nullable=False)
# Content
content = Column(Text, nullable=False) # HTML or Markdown
content_format = Column(String(20), default="html") # html, markdown
# SEO
meta_description = Column(String(300), nullable=True)
meta_keywords = Column(String(300), nullable=True)
# Publishing
is_published = Column(Boolean, default=False, nullable=False)
published_at = Column(DateTime(timezone=True), nullable=True)
# Ordering (for menus, footers)
display_order = Column(Integer, default=0)
show_in_footer = Column(Boolean, default=True)
show_in_header = Column(Boolean, default=False)
# Timestamps
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
# Author tracking (admin or vendor user who created/updated)
created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
# Relationships
vendor = relationship("Vendor", back_populates="content_pages")
creator = relationship("User", foreign_keys=[created_by])
updater = relationship("User", foreign_keys=[updated_by])
# Constraints
__table_args__ = (
# Unique combination: vendor can only have one page per slug
# Platform defaults (vendor_id=NULL) can only have one page per slug
UniqueConstraint('vendor_id', 'slug', name='uq_vendor_slug'),
# Indexes for performance
Index('idx_vendor_published', 'vendor_id', 'is_published'),
Index('idx_slug_published', 'slug', 'is_published'),
)
def __repr__(self):
vendor_name = self.vendor.name if self.vendor else "PLATFORM"
return f"<ContentPage(id={self.id}, vendor={vendor_name}, slug={self.slug}, title={self.title})>"
@property
def is_platform_default(self):
"""Check if this is a platform-level default page."""
return self.vendor_id is None
@property
def is_vendor_override(self):
"""Check if this is a vendor-specific override."""
return self.vendor_id is not None
def to_dict(self):
"""Convert to dictionary for API responses."""
return {
"id": self.id,
"vendor_id": self.vendor_id,
"vendor_name": self.vendor.name if self.vendor else None,
"slug": self.slug,
"title": self.title,
"content": self.content,
"content_format": self.content_format,
"meta_description": self.meta_description,
"meta_keywords": self.meta_keywords,
"is_published": self.is_published,
"published_at": self.published_at.isoformat() if self.published_at else None,
"display_order": self.display_order,
"show_in_footer": self.show_in_footer,
"show_in_header": self.show_in_header,
"is_platform_default": self.is_platform_default,
"is_vendor_override": self.is_vendor_override,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
"created_by": self.created_by,
"updated_by": self.updated_by,
}
# Add relationship to Vendor model
# This should be added to models/database/vendor.py:
# content_pages = relationship("ContentPage", back_populates="vendor", cascade="all, delete-orphan")

View File

@@ -80,6 +80,13 @@ class Vendor(Base, TimestampMixin):
cascade="all, delete-orphan"
) # Relationship with VendorTheme model for the active theme of the vendor
# Content pages relationship (vendor can override platform default pages)
content_pages = relationship(
"ContentPage",
back_populates="vendor",
cascade="all, delete-orphan"
) # Relationship with ContentPage model for vendor-specific content pages
def __repr__(self):
"""String representation of the Vendor object."""
return f"<Vendor(id={self.id}, vendor_code='{self.vendor_code}', name='{self.name}', subdomain='{self.subdomain}')>"