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:
@@ -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
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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 ###
|
||||
131
models/database/content_page.py
Normal file
131
models/database/content_page.py
Normal 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")
|
||||
@@ -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}')>"
|
||||
|
||||
Reference in New Issue
Block a user