From c219f5b5f8d1f412453630a7df2882b2a4da2f7b Mon Sep 17 00:00:00 2001 From: Samir Boulahtit Date: Sat, 22 Nov 2025 15:54:29 +0100 Subject: [PATCH] feat: add CMS database model and migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- alembic/env.py | 10 ++ ...07_ensure_content_pages_table_with_all_.py | 63 +++++++++ ...20ce8b4_add_content_pages_table_for_cms.py | 34 +++++ models/database/content_page.py | 131 ++++++++++++++++++ models/database/vendor.py | 7 + 5 files changed, 245 insertions(+) create mode 100644 alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py create mode 100644 alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py create mode 100644 models/database/content_page.py diff --git a/alembic/env.py b/alembic/env.py index 2e1dbbe2..7888d490 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -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 # ---------------------------------------------------------------------------- diff --git a/alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py b/alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py new file mode 100644 index 00000000..23e274db --- /dev/null +++ b/alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py @@ -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 ### diff --git a/alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py b/alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py new file mode 100644 index 00000000..0c895879 --- /dev/null +++ b/alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py @@ -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 ### diff --git a/models/database/content_page.py b/models/database/content_page.py new file mode 100644 index 00000000..3a74f549 --- /dev/null +++ b/models/database/content_page.py @@ -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"" + + @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") diff --git a/models/database/vendor.py b/models/database/vendor.py index f1c997f2..813db3ce 100644 --- a/models/database/vendor.py +++ b/models/database/vendor.py @@ -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""