diff --git a/Makefile b/Makefile index 48c48306..94ff6f54 100644 --- a/Makefile +++ b/Makefile @@ -162,8 +162,8 @@ db-setup: migrate-up init-prod seed-demo db-reset: @echo "⚠️ WARNING: This will DELETE ALL existing data!" - @echo "Rolling back all migrations..." - $(PYTHON) -m alembic downgrade base || true + @echo "Dropping and recreating public schema..." + $(PYTHON) -c "from app.core.config import settings; from sqlalchemy import create_engine, text; e=create_engine(settings.database_url); c=e.connect(); c.execute(text('DROP SCHEMA public CASCADE')); c.execute(text('CREATE SCHEMA public')); c.commit(); c.close()" @echo "Applying all migrations..." $(PYTHON) -m alembic upgrade head @echo "Initializing production data..." diff --git a/alembic.ini b/alembic.ini index 39759137..a596a09e 100644 --- a/alembic.ini +++ b/alembic.ini @@ -3,7 +3,7 @@ script_location = alembic prepend_sys_path = . version_path_separator = space -version_locations = alembic/versions app/modules/loyalty/migrations/versions +version_locations = alembic/versions app/modules/billing/migrations/versions app/modules/cart/migrations/versions app/modules/catalog/migrations/versions app/modules/cms/migrations/versions app/modules/customers/migrations/versions app/modules/dev_tools/migrations/versions app/modules/inventory/migrations/versions app/modules/loyalty/migrations/versions app/modules/marketplace/migrations/versions app/modules/messaging/migrations/versions app/modules/orders/migrations/versions # This will be overridden by alembic\env.py using settings.database_url sqlalchemy.url = # for PROD: sqlalchemy.url = postgresql://username:password@localhost:5432/ecommerce_db diff --git a/alembic/env.py b/alembic/env.py index 94fc7adf..cc88e911 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -29,170 +29,251 @@ from models.database.base import Base # ============================================================================ # CRITICAL: Every model must be imported here so Alembic can detect tables! # If a model is not imported, Alembic will not create/update its table. -# -# Models list must match: models/database/__init__.py # ============================================================================ print("[ALEMBIC] Importing database models...") print("=" * 70) +_import_errors = [] + # ---------------------------------------------------------------------------- -# ADMIN MODELS +# CORE MODULE (1 model) # ---------------------------------------------------------------------------- try: - from app.modules.tenancy.models import ( + from app.modules.core.models import AdminMenuConfig # noqa: F401 + + print(" ✓ Core models (1)") +except ImportError as e: + _import_errors.append(f"core: {e}") + print(f" ✗ Core models failed: {e}") + +# ---------------------------------------------------------------------------- +# TENANCY MODULE (15 models) +# ---------------------------------------------------------------------------- +try: + from app.modules.tenancy.models import ( # noqa: F401 AdminAuditLog, - AdminNotification, + AdminPlatform, AdminSession, AdminSetting, + ApplicationLog, + Merchant, + Platform, PlatformAlert, + PlatformModule, + Role, + Store, + StoreDomain, + StorePlatform, + StoreUser, + User, ) - print(" ✓ Admin models imported (5 models)") - print(" - AdminAuditLog") - print(" - AdminNotification") - print(" - AdminSetting") - print(" - PlatformAlert") - print(" - AdminSession") + print(" ✓ Tenancy models (15)") except ImportError as e: - print(f" ✗ Admin models failed: {e}") + _import_errors.append(f"tenancy: {e}") + print(f" ✗ Tenancy models failed: {e}") # ---------------------------------------------------------------------------- -# USER MODEL +# BILLING MODULE (9 models) # ---------------------------------------------------------------------------- try: - from app.modules.tenancy.models import User + from app.modules.billing.models import ( # noqa: F401 + AddOnProduct, + BillingHistory, + CapacitySnapshot, + MerchantFeatureOverride, + MerchantSubscription, + StoreAddOn, + StripeWebhookEvent, + SubscriptionTier, + TierFeatureLimit, + ) - print(" ✓ User model imported") + print(" ✓ Billing models (9)") except ImportError as e: - print(f" ✗ User model failed: {e}") + _import_errors.append(f"billing: {e}") + print(f" ✗ Billing models failed: {e}") # ---------------------------------------------------------------------------- -# STORE MODELS +# CATALOG MODULE (3 models) # ---------------------------------------------------------------------------- try: - from app.modules.tenancy.models import Role, Store, StoreUser + from app.modules.catalog.models import ( # noqa: F401 + Product, + ProductMedia, + ProductTranslation, + ) - print(" ✓ Store models imported (3 models)") - print(" - Store") - print(" - StoreUser") - print(" - Role") + print(" ✓ Catalog models (3)") except ImportError as e: - print(f" ✗ Store models failed: {e}") - -try: - from app.modules.tenancy.models import StoreDomain - - print(" ✓ StoreDomain model imported") -except ImportError as e: - print(f" ✗ StoreDomain model failed: {e}") - -try: - from app.modules.cms.models import StoreTheme - - print(" ✓ StoreTheme model imported") -except ImportError as e: - print(f" ✗ StoreTheme model failed: {e}") + _import_errors.append(f"catalog: {e}") + print(f" ✗ Catalog models failed: {e}") # ---------------------------------------------------------------------------- -# CONTENT PAGE MODEL (CMS Module) +# MARKETPLACE MODULE (10 models) # ---------------------------------------------------------------------------- try: - from app.modules.cms.models import ContentPage + from app.modules.marketplace.models import ( # noqa: F401 + LetzshopFulfillmentQueue, + LetzshopHistoricalImportJob, + LetzshopStoreCache, + LetzshopSyncLog, + MarketplaceImportError, + MarketplaceImportJob, + MarketplaceProduct, + MarketplaceProductTranslation, + StoreLetzshopCredentials, + StoreOnboarding, + ) - print(" ✓ ContentPage model imported (from CMS module)") + print(" ✓ Marketplace models (10)") except ImportError as e: - print(f" ✗ ContentPage model failed: {e}") + _import_errors.append(f"marketplace: {e}") + print(f" ✗ Marketplace models failed: {e}") # ---------------------------------------------------------------------------- -# PRODUCT MODELS +# CMS MODULE (3 models) # ---------------------------------------------------------------------------- try: - from app.modules.catalog.models import Product + from app.modules.cms.models import ( # noqa: F401 + ContentPage, + MediaFile, + StoreTheme, + ) - print(" ✓ Product model imported") + print(" ✓ CMS models (3)") except ImportError as e: - print(f" ✗ Product model failed: {e}") - -try: - from app.modules.marketplace.models import MarketplaceProduct - - print(" ✓ MarketplaceProduct model imported") -except ImportError as e: - print(f" ✗ MarketplaceProduct model failed: {e}") + _import_errors.append(f"cms: {e}") + print(f" ✗ CMS models failed: {e}") # ---------------------------------------------------------------------------- -# INVENTORY MODEL +# CUSTOMERS MODULE (3 models) # ---------------------------------------------------------------------------- try: - from app.modules.inventory.models import Inventory + from app.modules.customers.models import ( # noqa: F401 + Customer, + CustomerAddress, + PasswordResetToken, + ) - print(" ✓ Inventory model imported") + print(" ✓ Customers models (3)") except ImportError as e: - print(f" ✗ Inventory model failed: {e}") + _import_errors.append(f"customers: {e}") + print(f" ✗ Customers models failed: {e}") # ---------------------------------------------------------------------------- -# MARKETPLACE IMPORT +# ORDERS MODULE (5 models) # ---------------------------------------------------------------------------- try: - from app.modules.marketplace.models import MarketplaceImportJob + from app.modules.orders.models import ( # noqa: F401 + Invoice, + Order, + OrderItem, + OrderItemException, + StoreInvoiceSettings, + ) - print(" ✓ MarketplaceImportJob model imported") + print(" ✓ Orders models (5)") except ImportError as e: - print(f" ✗ MarketplaceImportJob model failed: {e}") + _import_errors.append(f"orders: {e}") + print(f" ✗ Orders models failed: {e}") # ---------------------------------------------------------------------------- -# CUSTOMER MODELS +# INVENTORY MODULE (2 models) # ---------------------------------------------------------------------------- try: - from app.modules.customers.models.customer import Customer, CustomerAddress + from app.modules.inventory.models import ( # noqa: F401 + Inventory, + InventoryTransaction, + ) - print(" ✓ Customer models imported (2 models)") - print(" - Customer") - print(" - CustomerAddress") + print(" ✓ Inventory models (2)") except ImportError as e: - print(f" ✗ Customer models failed: {e}") + _import_errors.append(f"inventory: {e}") + print(f" ✗ Inventory models failed: {e}") # ---------------------------------------------------------------------------- -# CART MODELS +# CART MODULE (1 model) # ---------------------------------------------------------------------------- try: - from app.modules.cart.models import CartItem + from app.modules.cart.models import CartItem # noqa: F401 - print(" ✓ Cart models imported (1 model)") - print(" - CartItem") + print(" ✓ Cart models (1)") except ImportError as e: + _import_errors.append(f"cart: {e}") print(f" ✗ Cart models failed: {e}") # ---------------------------------------------------------------------------- -# ORDER MODELS +# MESSAGING MODULE (9 models) # ---------------------------------------------------------------------------- try: - from app.modules.orders.models import Order, OrderItem + from app.modules.messaging.models import ( # noqa: F401 + AdminNotification, + Conversation, + ConversationParticipant, + EmailLog, + EmailTemplate, + Message, + MessageAttachment, + StoreEmailSettings, + StoreEmailTemplate, + ) - print(" ✓ Order models imported (2 models)") - print(" - Order") - print(" - OrderItem") + print(" ✓ Messaging models (9)") except ImportError as e: - print(f" ✗ Order models failed: {e}") + _import_errors.append(f"messaging: {e}") + print(f" ✗ Messaging models failed: {e}") + +# ---------------------------------------------------------------------------- +# LOYALTY MODULE (6 models) +# ---------------------------------------------------------------------------- +try: + from app.modules.loyalty.models import ( # noqa: F401 + AppleDeviceRegistration, + LoyaltyCard, + LoyaltyProgram, + LoyaltyTransaction, + MerchantLoyaltySettings, + StaffPin, + ) + + print(" ✓ Loyalty models (6)") +except ImportError as e: + _import_errors.append(f"loyalty: {e}") + print(f" ✗ Loyalty models failed: {e}") + +# ---------------------------------------------------------------------------- +# DEV_TOOLS MODULE (8 models) +# ---------------------------------------------------------------------------- +try: + from app.modules.dev_tools.models import ( # noqa: F401 + ArchitectureRule, + ArchitectureScan, + ArchitectureViolation, + TestCollection, + TestResult, + TestRun, + ViolationAssignment, + ViolationComment, + ) + + print(" ✓ Dev Tools models (8)") +except ImportError as e: + _import_errors.append(f"dev_tools: {e}") + print(f" ✗ Dev Tools models failed: {e}") # ============================================================================ # SUMMARY # ============================================================================ print("=" * 70) -print("[ALEMBIC] Model import completed") -print("[ALEMBIC] Tables detected in metadata:") -print("=" * 70) - -if Base.metadata.tables: - for i, table_name in enumerate(sorted(Base.metadata.tables.keys()), 1): - print(f" {i:2d}. {table_name}") +if _import_errors: + print(f"[ALEMBIC] WARNING: {len(_import_errors)} import error(s):") + for err in _import_errors: + print(f" - {err}") print("=" * 70) - print(f"[ALEMBIC] Total tables: {len(Base.metadata.tables)}") -else: - print(" ⚠️ WARNING: No tables found in metadata!") - print(" This usually means models are not being imported correctly.") +print(f"[ALEMBIC] Total tables in metadata: {len(Base.metadata.tables)}") print("=" * 70) print() diff --git a/alembic/versions/09d84a46530f_add_celery_task_id_to_job_tables.py b/alembic/versions/09d84a46530f_add_celery_task_id_to_job_tables.py deleted file mode 100644 index 4e693903..00000000 --- a/alembic/versions/09d84a46530f_add_celery_task_id_to_job_tables.py +++ /dev/null @@ -1,55 +0,0 @@ -"""add celery_task_id to job tables - -Revision ID: 09d84a46530f -Revises: y3d4e5f6g7h8 -Create Date: 2026-01-11 16:44:59.070110 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = '09d84a46530f' -down_revision: Union[str, None] = 'y3d4e5f6g7h8' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Add celery_task_id column to job tracking tables for Celery integration.""" - # MarketplaceImportJob - op.add_column('marketplace_import_jobs', sa.Column('celery_task_id', sa.String(length=255), nullable=True)) - op.create_index(op.f('ix_marketplace_import_jobs_celery_task_id'), 'marketplace_import_jobs', ['celery_task_id'], unique=False) - - # LetzshopHistoricalImportJob - op.add_column('letzshop_historical_import_jobs', sa.Column('celery_task_id', sa.String(length=255), nullable=True)) - op.create_index(op.f('ix_letzshop_historical_import_jobs_celery_task_id'), 'letzshop_historical_import_jobs', ['celery_task_id'], unique=False) - - # ArchitectureScan - op.add_column('architecture_scans', sa.Column('celery_task_id', sa.String(length=255), nullable=True)) - op.create_index(op.f('ix_architecture_scans_celery_task_id'), 'architecture_scans', ['celery_task_id'], unique=False) - - # TestRun - op.add_column('test_runs', sa.Column('celery_task_id', sa.String(length=255), nullable=True)) - op.create_index(op.f('ix_test_runs_celery_task_id'), 'test_runs', ['celery_task_id'], unique=False) - - -def downgrade() -> None: - """Remove celery_task_id column from job tracking tables.""" - # TestRun - op.drop_index(op.f('ix_test_runs_celery_task_id'), table_name='test_runs') - op.drop_column('test_runs', 'celery_task_id') - - # ArchitectureScan - op.drop_index(op.f('ix_architecture_scans_celery_task_id'), table_name='architecture_scans') - op.drop_column('architecture_scans', 'celery_task_id') - - # LetzshopHistoricalImportJob - op.drop_index(op.f('ix_letzshop_historical_import_jobs_celery_task_id'), table_name='letzshop_historical_import_jobs') - op.drop_column('letzshop_historical_import_jobs', 'celery_task_id') - - # MarketplaceImportJob - op.drop_index(op.f('ix_marketplace_import_jobs_celery_task_id'), table_name='marketplace_import_jobs') - op.drop_column('marketplace_import_jobs', 'celery_task_id') diff --git a/alembic/versions/0bd9ffaaced1_add_application_logs_table_for_hybrid_.py b/alembic/versions/0bd9ffaaced1_add_application_logs_table_for_hybrid_.py deleted file mode 100644 index 3d091068..00000000 --- a/alembic/versions/0bd9ffaaced1_add_application_logs_table_for_hybrid_.py +++ /dev/null @@ -1,68 +0,0 @@ -"""add application_logs table for hybrid logging - -Revision ID: 0bd9ffaaced1 -Revises: 7a7ce92593d5 -Create Date: 2025-11-29 12:44:55.427245 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '0bd9ffaaced1' -down_revision: Union[str, None] = '7a7ce92593d5' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create application_logs table - op.create_table( - 'application_logs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('timestamp', sa.DateTime(), nullable=False), - sa.Column('level', sa.String(length=20), nullable=False), - sa.Column('logger_name', sa.String(length=200), nullable=False), - sa.Column('module', sa.String(length=200), nullable=True), - sa.Column('function_name', sa.String(length=100), nullable=True), - sa.Column('line_number', sa.Integer(), nullable=True), - sa.Column('message', sa.Text(), nullable=False), - sa.Column('exception_type', sa.String(length=200), nullable=True), - sa.Column('exception_message', sa.Text(), nullable=True), - sa.Column('stack_trace', sa.Text(), nullable=True), - sa.Column('request_id', sa.String(length=100), nullable=True), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('vendor_id', sa.Integer(), nullable=True), - sa.Column('context', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - - # Create indexes for better query performance - op.create_index(op.f('ix_application_logs_id'), 'application_logs', ['id'], unique=False) - op.create_index(op.f('ix_application_logs_timestamp'), 'application_logs', ['timestamp'], unique=False) - op.create_index(op.f('ix_application_logs_level'), 'application_logs', ['level'], unique=False) - op.create_index(op.f('ix_application_logs_logger_name'), 'application_logs', ['logger_name'], unique=False) - op.create_index(op.f('ix_application_logs_request_id'), 'application_logs', ['request_id'], unique=False) - op.create_index(op.f('ix_application_logs_user_id'), 'application_logs', ['user_id'], unique=False) - op.create_index(op.f('ix_application_logs_vendor_id'), 'application_logs', ['vendor_id'], unique=False) - - -def downgrade() -> None: - # Drop indexes - op.drop_index(op.f('ix_application_logs_vendor_id'), table_name='application_logs') - op.drop_index(op.f('ix_application_logs_user_id'), table_name='application_logs') - op.drop_index(op.f('ix_application_logs_request_id'), table_name='application_logs') - op.drop_index(op.f('ix_application_logs_logger_name'), table_name='application_logs') - op.drop_index(op.f('ix_application_logs_level'), table_name='application_logs') - op.drop_index(op.f('ix_application_logs_timestamp'), table_name='application_logs') - op.drop_index(op.f('ix_application_logs_id'), table_name='application_logs') - - # Drop table - op.drop_table('application_logs') diff --git a/alembic/versions/1b398cf45e85_add_letzshop_vendor_cache_table.py b/alembic/versions/1b398cf45e85_add_letzshop_vendor_cache_table.py deleted file mode 100644 index b113d400..00000000 --- a/alembic/versions/1b398cf45e85_add_letzshop_vendor_cache_table.py +++ /dev/null @@ -1,367 +0,0 @@ -"""add letzshop_vendor_cache table - -Revision ID: 1b398cf45e85 -Revises: 09d84a46530f -Create Date: 2026-01-13 19:38:45.423378 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql -from sqlalchemy.dialects import sqlite - -# revision identifiers, used by Alembic. -revision: str = '1b398cf45e85' -down_revision: Union[str, None] = '09d84a46530f' -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('letzshop_vendor_cache', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('letzshop_id', sa.String(length=50), nullable=False), - sa.Column('slug', sa.String(length=200), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('company_name', sa.String(length=255), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('description_en', sa.Text(), nullable=True), - sa.Column('description_fr', sa.Text(), nullable=True), - sa.Column('description_de', sa.Text(), nullable=True), - sa.Column('email', sa.String(length=255), nullable=True), - sa.Column('phone', sa.String(length=50), nullable=True), - sa.Column('fax', sa.String(length=50), nullable=True), - sa.Column('website', sa.String(length=500), nullable=True), - sa.Column('street', sa.String(length=255), nullable=True), - sa.Column('street_number', sa.String(length=50), nullable=True), - sa.Column('city', sa.String(length=100), nullable=True), - sa.Column('zipcode', sa.String(length=20), nullable=True), - sa.Column('country_iso', sa.String(length=5), nullable=True), - sa.Column('latitude', sa.String(length=20), nullable=True), - sa.Column('longitude', sa.String(length=20), nullable=True), - sa.Column('categories', sqlite.JSON(), nullable=True), - sa.Column('background_image_url', sa.String(length=500), nullable=True), - sa.Column('social_media_links', sqlite.JSON(), nullable=True), - sa.Column('opening_hours_en', sa.Text(), nullable=True), - sa.Column('opening_hours_fr', sa.Text(), nullable=True), - sa.Column('opening_hours_de', sa.Text(), nullable=True), - sa.Column('representative_name', sa.String(length=255), nullable=True), - sa.Column('representative_title', sa.String(length=100), nullable=True), - sa.Column('claimed_by_vendor_id', sa.Integer(), nullable=True), - sa.Column('claimed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('raw_data', sqlite.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['claimed_by_vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_vendor_cache_active', 'letzshop_vendor_cache', ['is_active'], unique=False) - op.create_index('idx_vendor_cache_city', 'letzshop_vendor_cache', ['city'], unique=False) - op.create_index('idx_vendor_cache_claimed', 'letzshop_vendor_cache', ['claimed_by_vendor_id'], unique=False) - op.create_index(op.f('ix_letzshop_vendor_cache_claimed_by_vendor_id'), 'letzshop_vendor_cache', ['claimed_by_vendor_id'], unique=False) - op.create_index(op.f('ix_letzshop_vendor_cache_id'), 'letzshop_vendor_cache', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_vendor_cache_letzshop_id'), 'letzshop_vendor_cache', ['letzshop_id'], unique=True) - op.create_index(op.f('ix_letzshop_vendor_cache_slug'), 'letzshop_vendor_cache', ['slug'], unique=True) - op.drop_constraint('architecture_rules_rule_id_key', 'architecture_rules', type_='unique') - op.alter_column('capacity_snapshots', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('capacity_snapshots', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.create_index(op.f('ix_features_id'), 'features', ['id'], unique=False) - op.create_index(op.f('ix_features_minimum_tier_id'), 'features', ['minimum_tier_id'], unique=False) - op.create_index('idx_inv_tx_order', 'inventory_transactions', ['order_id'], unique=False) - op.alter_column('invoices', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('invoices', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_fulfillment_queue', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_fulfillment_queue', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_sync_logs', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_sync_logs', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('media_files', 'created_at', - existing_type=postgresql.TIMESTAMP(), - nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('media_files', 'updated_at', - existing_type=postgresql.TIMESTAMP(), - nullable=False) - op.alter_column('order_item_exceptions', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_item_exceptions', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_items', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_items', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('orders', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('orders', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_index('ix_password_reset_tokens_customer_id', table_name='password_reset_tokens') - op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False) - op.alter_column('product_media', 'created_at', - existing_type=postgresql.TIMESTAMP(), - nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('product_media', 'updated_at', - existing_type=postgresql.TIMESTAMP(), - nullable=False) - op.alter_column('products', 'is_digital', - existing_type=sa.BOOLEAN(), - nullable=True, - existing_server_default=sa.text('false')) - op.alter_column('products', 'product_type', - existing_type=sa.VARCHAR(length=20), - nullable=True, - existing_server_default=sa.text("'physical'::character varying")) - op.drop_index('idx_product_is_digital', table_name='products') - op.create_index(op.f('ix_products_is_digital'), 'products', ['is_digital'], unique=False) - op.drop_constraint('uq_vendor_email_settings_vendor_id', 'vendor_email_settings', type_='unique') - op.drop_index('ix_vendor_email_templates_lookup', table_name='vendor_email_templates') - op.create_index(op.f('ix_vendor_email_templates_id'), 'vendor_email_templates', ['id'], unique=False) - op.alter_column('vendor_invoice_settings', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('vendor_invoice_settings', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_constraint('vendor_invoice_settings_vendor_id_key', 'vendor_invoice_settings', type_='unique') - op.alter_column('vendor_letzshop_credentials', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('vendor_letzshop_credentials', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_constraint('vendor_letzshop_credentials_vendor_id_key', 'vendor_letzshop_credentials', type_='unique') - op.alter_column('vendor_subscriptions', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('vendor_subscriptions', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_constraint('vendor_subscriptions_vendor_id_key', 'vendor_subscriptions', type_='unique') - op.drop_constraint('fk_vendor_subscriptions_tier_id', 'vendor_subscriptions', type_='foreignkey') - op.create_foreign_key(None, 'vendor_subscriptions', 'subscription_tiers', ['tier_id'], ['id']) - op.alter_column('vendors', 'storefront_locale', - existing_type=sa.VARCHAR(length=10), - comment=None, - existing_comment='Currency/number formatting locale (NULL = inherit from platform)', - existing_nullable=True) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.alter_column('vendors', 'storefront_locale', - existing_type=sa.VARCHAR(length=10), - comment='Currency/number formatting locale (NULL = inherit from platform)', - existing_nullable=True) - op.drop_constraint(None, 'vendor_subscriptions', type_='foreignkey') - op.create_foreign_key('fk_vendor_subscriptions_tier_id', 'vendor_subscriptions', 'subscription_tiers', ['tier_id'], ['id'], ondelete='SET NULL') - op.create_unique_constraint('vendor_subscriptions_vendor_id_key', 'vendor_subscriptions', ['vendor_id']) - op.alter_column('vendor_subscriptions', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('vendor_subscriptions', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.create_unique_constraint('vendor_letzshop_credentials_vendor_id_key', 'vendor_letzshop_credentials', ['vendor_id']) - op.alter_column('vendor_letzshop_credentials', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('vendor_letzshop_credentials', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.create_unique_constraint('vendor_invoice_settings_vendor_id_key', 'vendor_invoice_settings', ['vendor_id']) - op.alter_column('vendor_invoice_settings', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('vendor_invoice_settings', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_index(op.f('ix_vendor_email_templates_id'), table_name='vendor_email_templates') - op.create_index('ix_vendor_email_templates_lookup', 'vendor_email_templates', ['vendor_id', 'template_code', 'language'], unique=False) - op.create_unique_constraint('uq_vendor_email_settings_vendor_id', 'vendor_email_settings', ['vendor_id']) - op.drop_index(op.f('ix_products_is_digital'), table_name='products') - op.create_index('idx_product_is_digital', 'products', ['is_digital'], unique=False) - op.alter_column('products', 'product_type', - existing_type=sa.VARCHAR(length=20), - nullable=False, - existing_server_default=sa.text("'physical'::character varying")) - op.alter_column('products', 'is_digital', - existing_type=sa.BOOLEAN(), - nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('product_media', 'updated_at', - existing_type=postgresql.TIMESTAMP(), - nullable=True) - op.alter_column('product_media', 'created_at', - existing_type=postgresql.TIMESTAMP(), - nullable=True, - existing_server_default=sa.text('now()')) - op.drop_index(op.f('ix_password_reset_tokens_id'), table_name='password_reset_tokens') - op.create_index('ix_password_reset_tokens_customer_id', 'password_reset_tokens', ['customer_id'], unique=False) - op.alter_column('orders', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('orders', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_items', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_items', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_item_exceptions', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('order_item_exceptions', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('media_files', 'updated_at', - existing_type=postgresql.TIMESTAMP(), - nullable=True) - op.alter_column('media_files', 'created_at', - existing_type=postgresql.TIMESTAMP(), - nullable=True, - existing_server_default=sa.text('now()')) - op.alter_column('letzshop_sync_logs', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_sync_logs', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_fulfillment_queue', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('letzshop_fulfillment_queue', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('invoices', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.alter_column('invoices', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('CURRENT_TIMESTAMP')) - op.drop_index('idx_inv_tx_order', table_name='inventory_transactions') - op.drop_index(op.f('ix_features_minimum_tier_id'), table_name='features') - op.drop_index(op.f('ix_features_id'), table_name='features') - op.alter_column('capacity_snapshots', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('capacity_snapshots', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.create_unique_constraint('architecture_rules_rule_id_key', 'architecture_rules', ['rule_id']) - op.drop_index(op.f('ix_letzshop_vendor_cache_slug'), table_name='letzshop_vendor_cache') - op.drop_index(op.f('ix_letzshop_vendor_cache_letzshop_id'), table_name='letzshop_vendor_cache') - op.drop_index(op.f('ix_letzshop_vendor_cache_id'), table_name='letzshop_vendor_cache') - op.drop_index(op.f('ix_letzshop_vendor_cache_claimed_by_vendor_id'), table_name='letzshop_vendor_cache') - op.drop_index('idx_vendor_cache_claimed', table_name='letzshop_vendor_cache') - op.drop_index('idx_vendor_cache_city', table_name='letzshop_vendor_cache') - op.drop_index('idx_vendor_cache_active', table_name='letzshop_vendor_cache') - op.drop_table('letzshop_vendor_cache') - # ### end Alembic commands ### diff --git a/alembic/versions/204273a59d73_add_letzshop_historical_import_jobs_.py b/alembic/versions/204273a59d73_add_letzshop_historical_import_jobs_.py deleted file mode 100644 index 44e6fe65..00000000 --- a/alembic/versions/204273a59d73_add_letzshop_historical_import_jobs_.py +++ /dev/null @@ -1,57 +0,0 @@ -"""add_letzshop_historical_import_jobs_table - -Revision ID: 204273a59d73 -Revises: cb88bc9b5f86 -Create Date: 2025-12-19 05:40:53.463341 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -# Removed: from sqlalchemy.dialects import sqlite (using sa.JSON for PostgreSQL) - -# revision identifiers, used by Alembic. -revision: str = '204273a59d73' -down_revision: Union[str, None] = 'cb88bc9b5f86' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table('letzshop_historical_import_jobs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('user_id', sa.Integer(), nullable=False), - sa.Column('status', sa.String(length=50), nullable=False), - sa.Column('current_phase', sa.String(length=20), nullable=True), - sa.Column('current_page', sa.Integer(), nullable=True), - sa.Column('total_pages', sa.Integer(), nullable=True), - sa.Column('shipments_fetched', sa.Integer(), nullable=True), - sa.Column('orders_processed', sa.Integer(), nullable=True), - sa.Column('orders_imported', sa.Integer(), nullable=True), - sa.Column('orders_updated', sa.Integer(), nullable=True), - sa.Column('orders_skipped', sa.Integer(), nullable=True), - sa.Column('products_matched', sa.Integer(), nullable=True), - sa.Column('products_not_found', sa.Integer(), nullable=True), - sa.Column('confirmed_stats', sa.JSON(), nullable=True), - sa.Column('declined_stats', sa.JSON(), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_historical_import_vendor', 'letzshop_historical_import_jobs', ['vendor_id', 'status'], unique=False) - op.create_index(op.f('ix_letzshop_historical_import_jobs_id'), 'letzshop_historical_import_jobs', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_historical_import_jobs_vendor_id'), 'letzshop_historical_import_jobs', ['vendor_id'], unique=False) - - -def downgrade() -> None: - op.drop_index(op.f('ix_letzshop_historical_import_jobs_vendor_id'), table_name='letzshop_historical_import_jobs') - op.drop_index(op.f('ix_letzshop_historical_import_jobs_id'), table_name='letzshop_historical_import_jobs') - op.drop_index('idx_historical_import_vendor', table_name='letzshop_historical_import_jobs') - op.drop_table('letzshop_historical_import_jobs') diff --git a/alembic/versions/2362c2723a93_add_order_date_to_letzshop_orders.py b/alembic/versions/2362c2723a93_add_order_date_to_letzshop_orders.py deleted file mode 100644 index c96cc7e1..00000000 --- a/alembic/versions/2362c2723a93_add_order_date_to_letzshop_orders.py +++ /dev/null @@ -1,27 +0,0 @@ -"""add_order_date_to_letzshop_orders - -Revision ID: 2362c2723a93 -Revises: 204273a59d73 -Create Date: 2025-12-19 08:46:23.731912 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '2362c2723a93' -down_revision: Union[str, None] = '204273a59d73' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add order_date column to letzshop_orders table - op.add_column('letzshop_orders', sa.Column('order_date', sa.DateTime(timezone=True), nullable=True)) - - -def downgrade() -> None: - op.drop_column('letzshop_orders', 'order_date') diff --git a/alembic/versions/28d44d503cac_add_contact_fields_to_vendor.py b/alembic/versions/28d44d503cac_add_contact_fields_to_vendor.py deleted file mode 100644 index efe0a18d..00000000 --- a/alembic/versions/28d44d503cac_add_contact_fields_to_vendor.py +++ /dev/null @@ -1,37 +0,0 @@ -"""add contact fields to vendor - -Revision ID: 28d44d503cac -Revises: 9f3a25ea4991 -Create Date: 2025-12-03 22:26:02.161087 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '28d44d503cac' -down_revision: Union[str, None] = '9f3a25ea4991' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add nullable contact fields to vendor table - # These allow vendor-specific branding/identity, overriding company defaults - op.add_column('vendors', sa.Column('contact_email', sa.String(255), nullable=True)) - op.add_column('vendors', sa.Column('contact_phone', sa.String(50), nullable=True)) - op.add_column('vendors', sa.Column('website', sa.String(255), nullable=True)) - op.add_column('vendors', sa.Column('business_address', sa.Text(), nullable=True)) - op.add_column('vendors', sa.Column('tax_number', sa.String(100), nullable=True)) - - -def downgrade() -> None: - # Remove contact fields from vendor table - op.drop_column('vendors', 'tax_number') - op.drop_column('vendors', 'business_address') - op.drop_column('vendors', 'website') - op.drop_column('vendors', 'contact_phone') - op.drop_column('vendors', 'contact_email') diff --git a/alembic/versions/2953ed10d22c_add_subscription_billing_tables.py b/alembic/versions/2953ed10d22c_add_subscription_billing_tables.py deleted file mode 100644 index 346270fd..00000000 --- a/alembic/versions/2953ed10d22c_add_subscription_billing_tables.py +++ /dev/null @@ -1,419 +0,0 @@ -"""add_subscription_billing_tables - -Revision ID: 2953ed10d22c -Revises: e1bfb453fbe9 -Create Date: 2025-12-25 18:29:34.167773 - -""" -from datetime import datetime -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -# Removed: from sqlalchemy.dialects import sqlite (using sa.JSON for PostgreSQL) - -# revision identifiers, used by Alembic. -revision: str = '2953ed10d22c' -down_revision: Union[str, None] = 'e1bfb453fbe9' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ========================================================================= - # Create new subscription and billing tables - # ========================================================================= - - # subscription_tiers - Database-driven tier definitions - op.create_table('subscription_tiers', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('code', sa.String(length=30), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('price_monthly_cents', sa.Integer(), nullable=False), - sa.Column('price_annual_cents', sa.Integer(), nullable=True), - sa.Column('orders_per_month', sa.Integer(), nullable=True), - sa.Column('products_limit', sa.Integer(), nullable=True), - sa.Column('team_members', sa.Integer(), nullable=True), - sa.Column('order_history_months', sa.Integer(), nullable=True), - sa.Column('features', sa.JSON(), nullable=True), - sa.Column('stripe_product_id', sa.String(length=100), nullable=True), - sa.Column('stripe_price_monthly_id', sa.String(length=100), nullable=True), - sa.Column('stripe_price_annual_id', sa.String(length=100), nullable=True), - sa.Column('display_order', sa.Integer(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('is_public', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_subscription_tiers_code'), 'subscription_tiers', ['code'], unique=True) - op.create_index(op.f('ix_subscription_tiers_id'), 'subscription_tiers', ['id'], unique=False) - - # addon_products - Purchasable add-ons (domains, SSL, email) - op.create_table('addon_products', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('code', sa.String(length=50), nullable=False), - sa.Column('name', sa.String(length=100), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('category', sa.String(length=50), nullable=False), - sa.Column('price_cents', sa.Integer(), nullable=False), - sa.Column('billing_period', sa.String(length=20), nullable=False), - sa.Column('quantity_unit', sa.String(length=50), nullable=True), - sa.Column('quantity_value', sa.Integer(), nullable=True), - sa.Column('stripe_product_id', sa.String(length=100), nullable=True), - sa.Column('stripe_price_id', sa.String(length=100), nullable=True), - sa.Column('display_order', sa.Integer(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_addon_products_category'), 'addon_products', ['category'], unique=False) - op.create_index(op.f('ix_addon_products_code'), 'addon_products', ['code'], unique=True) - op.create_index(op.f('ix_addon_products_id'), 'addon_products', ['id'], unique=False) - - # billing_history - Invoice and payment history - op.create_table('billing_history', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('stripe_invoice_id', sa.String(length=100), nullable=True), - sa.Column('stripe_payment_intent_id', sa.String(length=100), nullable=True), - sa.Column('invoice_number', sa.String(length=50), nullable=True), - sa.Column('invoice_date', sa.DateTime(timezone=True), nullable=False), - sa.Column('due_date', sa.DateTime(timezone=True), nullable=True), - sa.Column('subtotal_cents', sa.Integer(), nullable=False), - sa.Column('tax_cents', sa.Integer(), nullable=False), - sa.Column('total_cents', sa.Integer(), nullable=False), - sa.Column('amount_paid_cents', sa.Integer(), nullable=False), - sa.Column('currency', sa.String(length=3), nullable=False), - sa.Column('status', sa.String(length=20), nullable=False), - sa.Column('invoice_pdf_url', sa.String(length=500), nullable=True), - sa.Column('hosted_invoice_url', sa.String(length=500), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('line_items', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_billing_status', 'billing_history', ['vendor_id', 'status'], unique=False) - op.create_index('idx_billing_vendor_date', 'billing_history', ['vendor_id', 'invoice_date'], unique=False) - op.create_index(op.f('ix_billing_history_id'), 'billing_history', ['id'], unique=False) - op.create_index(op.f('ix_billing_history_status'), 'billing_history', ['status'], unique=False) - op.create_index(op.f('ix_billing_history_stripe_invoice_id'), 'billing_history', ['stripe_invoice_id'], unique=True) - op.create_index(op.f('ix_billing_history_vendor_id'), 'billing_history', ['vendor_id'], unique=False) - - # vendor_addons - Add-ons purchased by vendor - op.create_table('vendor_addons', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('addon_product_id', sa.Integer(), nullable=False), - sa.Column('status', sa.String(length=20), nullable=False), - sa.Column('domain_name', sa.String(length=255), nullable=True), - sa.Column('quantity', sa.Integer(), nullable=False), - sa.Column('stripe_subscription_item_id', sa.String(length=100), nullable=True), - sa.Column('period_start', sa.DateTime(timezone=True), nullable=True), - sa.Column('period_end', sa.DateTime(timezone=True), nullable=True), - sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['addon_product_id'], ['addon_products.id'], ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_vendor_addon_product', 'vendor_addons', ['vendor_id', 'addon_product_id'], unique=False) - op.create_index('idx_vendor_addon_status', 'vendor_addons', ['vendor_id', 'status'], unique=False) - op.create_index(op.f('ix_vendor_addons_addon_product_id'), 'vendor_addons', ['addon_product_id'], unique=False) - op.create_index(op.f('ix_vendor_addons_domain_name'), 'vendor_addons', ['domain_name'], unique=False) - op.create_index(op.f('ix_vendor_addons_id'), 'vendor_addons', ['id'], unique=False) - op.create_index(op.f('ix_vendor_addons_status'), 'vendor_addons', ['status'], unique=False) - op.create_index(op.f('ix_vendor_addons_vendor_id'), 'vendor_addons', ['vendor_id'], unique=False) - - # stripe_webhook_events - Webhook idempotency tracking - op.create_table('stripe_webhook_events', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('event_id', sa.String(length=100), nullable=False), - sa.Column('event_type', sa.String(length=100), nullable=False), - sa.Column('status', sa.String(length=20), nullable=False), - sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('payload_encrypted', sa.Text(), nullable=True), - sa.Column('vendor_id', sa.Integer(), nullable=True), - sa.Column('subscription_id', sa.Integer(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['subscription_id'], ['vendor_subscriptions.id'], ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_webhook_event_type_status', 'stripe_webhook_events', ['event_type', 'status'], unique=False) - op.create_index(op.f('ix_stripe_webhook_events_event_id'), 'stripe_webhook_events', ['event_id'], unique=True) - op.create_index(op.f('ix_stripe_webhook_events_event_type'), 'stripe_webhook_events', ['event_type'], unique=False) - op.create_index(op.f('ix_stripe_webhook_events_id'), 'stripe_webhook_events', ['id'], unique=False) - op.create_index(op.f('ix_stripe_webhook_events_status'), 'stripe_webhook_events', ['status'], unique=False) - op.create_index(op.f('ix_stripe_webhook_events_subscription_id'), 'stripe_webhook_events', ['subscription_id'], unique=False) - op.create_index(op.f('ix_stripe_webhook_events_vendor_id'), 'stripe_webhook_events', ['vendor_id'], unique=False) - - # ========================================================================= - # Add new columns to vendor_subscriptions - # ========================================================================= - op.add_column('vendor_subscriptions', sa.Column('stripe_price_id', sa.String(length=100), nullable=True)) - op.add_column('vendor_subscriptions', sa.Column('stripe_payment_method_id', sa.String(length=100), nullable=True)) - op.add_column('vendor_subscriptions', sa.Column('proration_behavior', sa.String(length=50), nullable=True)) - op.add_column('vendor_subscriptions', sa.Column('scheduled_tier_change', sa.String(length=30), nullable=True)) - op.add_column('vendor_subscriptions', sa.Column('scheduled_change_at', sa.DateTime(timezone=True), nullable=True)) - op.add_column('vendor_subscriptions', sa.Column('payment_retry_count', sa.Integer(), server_default='0', nullable=False)) - op.add_column('vendor_subscriptions', sa.Column('last_payment_error', sa.Text(), nullable=True)) - - # ========================================================================= - # Seed subscription tiers - # ========================================================================= - now = datetime.utcnow() - - subscription_tiers = sa.table( - 'subscription_tiers', - sa.column('code', sa.String), - sa.column('name', sa.String), - sa.column('description', sa.Text), - sa.column('price_monthly_cents', sa.Integer), - sa.column('price_annual_cents', sa.Integer), - sa.column('orders_per_month', sa.Integer), - sa.column('products_limit', sa.Integer), - sa.column('team_members', sa.Integer), - sa.column('order_history_months', sa.Integer), - sa.column('features', sa.JSON), - sa.column('display_order', sa.Integer), - sa.column('is_active', sa.Boolean), - sa.column('is_public', sa.Boolean), - sa.column('created_at', sa.DateTime), - sa.column('updated_at', sa.DateTime), - ) - - op.bulk_insert(subscription_tiers, [ - { - 'code': 'essential', - 'name': 'Essential', - 'description': 'Perfect for solo vendors getting started with Letzshop', - 'price_monthly_cents': 4900, - 'price_annual_cents': 49000, - 'orders_per_month': 100, - 'products_limit': 200, - 'team_members': 1, - 'order_history_months': 6, - 'features': ['letzshop_sync', 'inventory_basic', 'invoice_lu', 'customer_view'], - 'display_order': 1, - 'is_active': True, - 'is_public': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'professional', - 'name': 'Professional', - 'description': 'For active multi-channel vendors shipping EU-wide', - 'price_monthly_cents': 9900, - 'price_annual_cents': 99000, - 'orders_per_month': 500, - 'products_limit': None, - 'team_members': 3, - 'order_history_months': 24, - 'features': [ - 'letzshop_sync', 'inventory_locations', 'inventory_purchase_orders', - 'invoice_lu', 'invoice_eu_vat', 'customer_view', 'customer_export' - ], - 'display_order': 2, - 'is_active': True, - 'is_public': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'business', - 'name': 'Business', - 'description': 'For high-volume vendors with teams and data-driven operations', - 'price_monthly_cents': 19900, - 'price_annual_cents': 199000, - 'orders_per_month': 2000, - 'products_limit': None, - 'team_members': 10, - 'order_history_months': None, - 'features': [ - 'letzshop_sync', 'inventory_locations', 'inventory_purchase_orders', - 'invoice_lu', 'invoice_eu_vat', 'invoice_bulk', 'customer_view', - 'customer_export', 'analytics_dashboard', 'accounting_export', - 'api_access', 'automation_rules', 'team_roles' - ], - 'display_order': 3, - 'is_active': True, - 'is_public': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'enterprise', - 'name': 'Enterprise', - 'description': 'Custom solutions for large operations and agencies', - 'price_monthly_cents': 39900, - 'price_annual_cents': None, - 'orders_per_month': None, - 'products_limit': None, - 'team_members': None, - 'order_history_months': None, - 'features': [ - 'letzshop_sync', 'inventory_locations', 'inventory_purchase_orders', - 'invoice_lu', 'invoice_eu_vat', 'invoice_bulk', 'customer_view', - 'customer_export', 'analytics_dashboard', 'accounting_export', - 'api_access', 'automation_rules', 'team_roles', 'white_label', - 'multi_vendor', 'custom_integrations', 'sla_guarantee', 'dedicated_support' - ], - 'display_order': 4, - 'is_active': True, - 'is_public': False, - 'created_at': now, - 'updated_at': now, - }, - ]) - - # ========================================================================= - # Seed add-on products - # ========================================================================= - addon_products = sa.table( - 'addon_products', - sa.column('code', sa.String), - sa.column('name', sa.String), - sa.column('description', sa.Text), - sa.column('category', sa.String), - sa.column('price_cents', sa.Integer), - sa.column('billing_period', sa.String), - sa.column('quantity_unit', sa.String), - sa.column('quantity_value', sa.Integer), - sa.column('display_order', sa.Integer), - sa.column('is_active', sa.Boolean), - sa.column('created_at', sa.DateTime), - sa.column('updated_at', sa.DateTime), - ) - - op.bulk_insert(addon_products, [ - { - 'code': 'domain', - 'name': 'Custom Domain', - 'description': 'Connect your own domain with SSL certificate included', - 'category': 'domain', - 'price_cents': 1500, - 'billing_period': 'annual', - 'quantity_unit': None, - 'quantity_value': None, - 'display_order': 1, - 'is_active': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'email_5', - 'name': '5 Email Addresses', - 'description': 'Professional email addresses on your domain', - 'category': 'email', - 'price_cents': 500, - 'billing_period': 'monthly', - 'quantity_unit': 'emails', - 'quantity_value': 5, - 'display_order': 2, - 'is_active': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'email_10', - 'name': '10 Email Addresses', - 'description': 'Professional email addresses on your domain', - 'category': 'email', - 'price_cents': 900, - 'billing_period': 'monthly', - 'quantity_unit': 'emails', - 'quantity_value': 10, - 'display_order': 3, - 'is_active': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'email_25', - 'name': '25 Email Addresses', - 'description': 'Professional email addresses on your domain', - 'category': 'email', - 'price_cents': 1900, - 'billing_period': 'monthly', - 'quantity_unit': 'emails', - 'quantity_value': 25, - 'display_order': 4, - 'is_active': True, - 'created_at': now, - 'updated_at': now, - }, - { - 'code': 'storage_10gb', - 'name': 'Additional Storage (10GB)', - 'description': 'Extra storage for product images and files', - 'category': 'storage', - 'price_cents': 500, - 'billing_period': 'monthly', - 'quantity_unit': 'GB', - 'quantity_value': 10, - 'display_order': 5, - 'is_active': True, - 'created_at': now, - 'updated_at': now, - }, - ]) - - -def downgrade() -> None: - # Remove new columns from vendor_subscriptions - op.drop_column('vendor_subscriptions', 'last_payment_error') - op.drop_column('vendor_subscriptions', 'payment_retry_count') - op.drop_column('vendor_subscriptions', 'scheduled_change_at') - op.drop_column('vendor_subscriptions', 'scheduled_tier_change') - op.drop_column('vendor_subscriptions', 'proration_behavior') - op.drop_column('vendor_subscriptions', 'stripe_payment_method_id') - op.drop_column('vendor_subscriptions', 'stripe_price_id') - - # Drop stripe_webhook_events - op.drop_index(op.f('ix_stripe_webhook_events_vendor_id'), table_name='stripe_webhook_events') - op.drop_index(op.f('ix_stripe_webhook_events_subscription_id'), table_name='stripe_webhook_events') - op.drop_index(op.f('ix_stripe_webhook_events_status'), table_name='stripe_webhook_events') - op.drop_index(op.f('ix_stripe_webhook_events_id'), table_name='stripe_webhook_events') - op.drop_index(op.f('ix_stripe_webhook_events_event_type'), table_name='stripe_webhook_events') - op.drop_index(op.f('ix_stripe_webhook_events_event_id'), table_name='stripe_webhook_events') - op.drop_index('idx_webhook_event_type_status', table_name='stripe_webhook_events') - op.drop_table('stripe_webhook_events') - - # Drop vendor_addons - op.drop_index(op.f('ix_vendor_addons_vendor_id'), table_name='vendor_addons') - op.drop_index(op.f('ix_vendor_addons_status'), table_name='vendor_addons') - op.drop_index(op.f('ix_vendor_addons_id'), table_name='vendor_addons') - op.drop_index(op.f('ix_vendor_addons_domain_name'), table_name='vendor_addons') - op.drop_index(op.f('ix_vendor_addons_addon_product_id'), table_name='vendor_addons') - op.drop_index('idx_vendor_addon_status', table_name='vendor_addons') - op.drop_index('idx_vendor_addon_product', table_name='vendor_addons') - op.drop_table('vendor_addons') - - # Drop billing_history - op.drop_index(op.f('ix_billing_history_vendor_id'), table_name='billing_history') - op.drop_index(op.f('ix_billing_history_stripe_invoice_id'), table_name='billing_history') - op.drop_index(op.f('ix_billing_history_status'), table_name='billing_history') - op.drop_index(op.f('ix_billing_history_id'), table_name='billing_history') - op.drop_index('idx_billing_vendor_date', table_name='billing_history') - op.drop_index('idx_billing_status', table_name='billing_history') - op.drop_table('billing_history') - - # Drop addon_products - op.drop_index(op.f('ix_addon_products_id'), table_name='addon_products') - op.drop_index(op.f('ix_addon_products_code'), table_name='addon_products') - op.drop_index(op.f('ix_addon_products_category'), table_name='addon_products') - op.drop_table('addon_products') - - # Drop subscription_tiers - op.drop_index(op.f('ix_subscription_tiers_id'), table_name='subscription_tiers') - op.drop_index(op.f('ix_subscription_tiers_code'), table_name='subscription_tiers') - op.drop_table('subscription_tiers') diff --git a/alembic/versions/404b3e2d2865_add_letzshop_vendor_fields_and_trial_.py b/alembic/versions/404b3e2d2865_add_letzshop_vendor_fields_and_trial_.py deleted file mode 100644 index 6fa0242f..00000000 --- a/alembic/versions/404b3e2d2865_add_letzshop_vendor_fields_and_trial_.py +++ /dev/null @@ -1,44 +0,0 @@ -"""add_letzshop_vendor_fields_and_trial_tracking - -Revision ID: 404b3e2d2865 -Revises: l0a1b2c3d4e5 -Create Date: 2025-12-27 09:49:44.715243 - -Adds: -- vendors.letzshop_vendor_id - Link to Letzshop marketplace profile -- vendors.letzshop_vendor_slug - Letzshop shop URL slug -- vendor_subscriptions.card_collected_at - Track when card was collected for trial -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '404b3e2d2865' -down_revision: Union[str, None] = 'l0a1b2c3d4e5' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add Letzshop vendor identity fields to vendors table - op.add_column('vendors', sa.Column('letzshop_vendor_id', sa.String(length=100), nullable=True)) - op.add_column('vendors', sa.Column('letzshop_vendor_slug', sa.String(length=200), nullable=True)) - op.create_index(op.f('ix_vendors_letzshop_vendor_id'), 'vendors', ['letzshop_vendor_id'], unique=True) - op.create_index(op.f('ix_vendors_letzshop_vendor_slug'), 'vendors', ['letzshop_vendor_slug'], unique=False) - - # Add card collection tracking to vendor_subscriptions - op.add_column('vendor_subscriptions', sa.Column('card_collected_at', sa.DateTime(timezone=True), nullable=True)) - - -def downgrade() -> None: - # Remove card collection tracking from vendor_subscriptions - op.drop_column('vendor_subscriptions', 'card_collected_at') - - # Remove Letzshop vendor identity fields from vendors - op.drop_index(op.f('ix_vendors_letzshop_vendor_slug'), table_name='vendors') - op.drop_index(op.f('ix_vendors_letzshop_vendor_id'), table_name='vendors') - op.drop_column('vendors', 'letzshop_vendor_slug') - op.drop_column('vendors', 'letzshop_vendor_id') diff --git a/alembic/versions/4951b2e50581_initial_migration_all_tables.py b/alembic/versions/4951b2e50581_initial_migration_all_tables.py deleted file mode 100644 index bdf730a1..00000000 --- a/alembic/versions/4951b2e50581_initial_migration_all_tables.py +++ /dev/null @@ -1,908 +0,0 @@ -"""Initial migration - all tables - -Revision ID: 4951b2e50581 -Revises: -Create Date: 2025-10-27 22:28:33.137564 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "4951b2e50581" -down_revision: Union[str, None] = None -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( - "marketplace_products", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("marketplace_product_id", sa.String(), nullable=False), - sa.Column("title", sa.String(), nullable=False), - sa.Column("description", sa.String(), nullable=True), - sa.Column("link", sa.String(), nullable=True), - sa.Column("image_link", sa.String(), nullable=True), - sa.Column("availability", sa.String(), nullable=True), - sa.Column("price", sa.String(), nullable=True), - sa.Column("brand", sa.String(), nullable=True), - sa.Column("gtin", sa.String(), nullable=True), - sa.Column("mpn", sa.String(), nullable=True), - sa.Column("condition", sa.String(), nullable=True), - sa.Column("adult", sa.String(), nullable=True), - sa.Column("multipack", sa.Integer(), nullable=True), - sa.Column("is_bundle", sa.String(), nullable=True), - sa.Column("age_group", sa.String(), nullable=True), - sa.Column("color", sa.String(), nullable=True), - sa.Column("gender", sa.String(), nullable=True), - sa.Column("material", sa.String(), nullable=True), - sa.Column("pattern", sa.String(), nullable=True), - sa.Column("size", sa.String(), nullable=True), - sa.Column("size_type", sa.String(), nullable=True), - sa.Column("size_system", sa.String(), nullable=True), - sa.Column("item_group_id", sa.String(), nullable=True), - sa.Column("google_product_category", sa.String(), nullable=True), - sa.Column("product_type", sa.String(), nullable=True), - sa.Column("custom_label_0", sa.String(), nullable=True), - sa.Column("custom_label_1", sa.String(), nullable=True), - sa.Column("custom_label_2", sa.String(), nullable=True), - sa.Column("custom_label_3", sa.String(), nullable=True), - sa.Column("custom_label_4", sa.String(), nullable=True), - sa.Column("additional_image_link", sa.String(), nullable=True), - sa.Column("sale_price", sa.String(), nullable=True), - sa.Column("unit_pricing_measure", sa.String(), nullable=True), - sa.Column("unit_pricing_base_measure", sa.String(), nullable=True), - sa.Column("identifier_exists", sa.String(), nullable=True), - sa.Column("shipping", sa.String(), nullable=True), - sa.Column("currency", sa.String(), nullable=True), - sa.Column("marketplace", sa.String(), nullable=True), - sa.Column("vendor_name", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "idx_marketplace_brand", - "marketplace_products", - ["marketplace", "brand"], - unique=False, - ) - op.create_index( - "idx_marketplace_vendor", - "marketplace_products", - ["marketplace", "vendor_name"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_products_availability"), - "marketplace_products", - ["availability"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_products_brand"), - "marketplace_products", - ["brand"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_products_google_product_category"), - "marketplace_products", - ["google_product_category"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_products_gtin"), - "marketplace_products", - ["gtin"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_products_id"), "marketplace_products", ["id"], unique=False - ) - op.create_index( - op.f("ix_marketplace_products_marketplace"), - "marketplace_products", - ["marketplace"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_products_marketplace_product_id"), - "marketplace_products", - ["marketplace_product_id"], - unique=True, - ) - op.create_index( - op.f("ix_marketplace_products_vendor_name"), - "marketplace_products", - ["vendor_name"], - unique=False, - ) - op.create_table( - "users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("email", sa.String(), nullable=False), - sa.Column("username", sa.String(), nullable=False), - sa.Column("first_name", sa.String(), nullable=True), - sa.Column("last_name", sa.String(), nullable=True), - sa.Column("hashed_password", sa.String(), nullable=False), - sa.Column("role", sa.String(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("last_login", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) - op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) - op.create_index(op.f("ix_users_username"), "users", ["username"], unique=True) - op.create_table( - "admin_audit_logs", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("admin_user_id", sa.Integer(), nullable=False), - sa.Column("action", sa.String(length=100), nullable=False), - sa.Column("target_type", sa.String(length=50), nullable=False), - sa.Column("target_id", sa.String(length=100), nullable=False), - sa.Column("details", sa.JSON(), nullable=True), - sa.Column("ip_address", sa.String(length=45), nullable=True), - sa.Column("user_agent", sa.Text(), nullable=True), - sa.Column("request_id", sa.String(length=100), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["admin_user_id"], - ["users.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_admin_audit_logs_action"), "admin_audit_logs", ["action"], unique=False - ) - op.create_index( - op.f("ix_admin_audit_logs_admin_user_id"), - "admin_audit_logs", - ["admin_user_id"], - unique=False, - ) - op.create_index( - op.f("ix_admin_audit_logs_id"), "admin_audit_logs", ["id"], unique=False - ) - op.create_index( - op.f("ix_admin_audit_logs_target_id"), - "admin_audit_logs", - ["target_id"], - unique=False, - ) - op.create_index( - op.f("ix_admin_audit_logs_target_type"), - "admin_audit_logs", - ["target_type"], - unique=False, - ) - op.create_table( - "admin_notifications", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("type", sa.String(length=50), nullable=False), - sa.Column("priority", sa.String(length=20), nullable=True), - sa.Column("title", sa.String(length=200), nullable=False), - sa.Column("message", sa.Text(), nullable=False), - sa.Column("is_read", sa.Boolean(), nullable=True), - sa.Column("read_at", sa.DateTime(), nullable=True), - sa.Column("read_by_user_id", sa.Integer(), nullable=True), - sa.Column("action_required", sa.Boolean(), nullable=True), - sa.Column("action_url", sa.String(length=500), nullable=True), - sa.Column("notification_metadata", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["read_by_user_id"], - ["users.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_admin_notifications_action_required"), - "admin_notifications", - ["action_required"], - unique=False, - ) - op.create_index( - op.f("ix_admin_notifications_id"), "admin_notifications", ["id"], unique=False - ) - op.create_index( - op.f("ix_admin_notifications_is_read"), - "admin_notifications", - ["is_read"], - unique=False, - ) - op.create_index( - op.f("ix_admin_notifications_priority"), - "admin_notifications", - ["priority"], - unique=False, - ) - op.create_index( - op.f("ix_admin_notifications_type"), - "admin_notifications", - ["type"], - unique=False, - ) - op.create_table( - "admin_sessions", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("admin_user_id", sa.Integer(), nullable=False), - sa.Column("session_token", sa.String(length=255), nullable=False), - sa.Column("ip_address", sa.String(length=45), nullable=False), - sa.Column("user_agent", sa.Text(), nullable=True), - sa.Column("login_at", sa.DateTime(), nullable=False), - sa.Column("last_activity_at", sa.DateTime(), nullable=False), - sa.Column("logout_at", sa.DateTime(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=True), - sa.Column("logout_reason", sa.String(length=50), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["admin_user_id"], - ["users.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_admin_sessions_admin_user_id"), - "admin_sessions", - ["admin_user_id"], - unique=False, - ) - op.create_index( - op.f("ix_admin_sessions_id"), "admin_sessions", ["id"], unique=False - ) - op.create_index( - op.f("ix_admin_sessions_is_active"), - "admin_sessions", - ["is_active"], - unique=False, - ) - op.create_index( - op.f("ix_admin_sessions_login_at"), "admin_sessions", ["login_at"], unique=False - ) - op.create_index( - op.f("ix_admin_sessions_session_token"), - "admin_sessions", - ["session_token"], - unique=True, - ) - op.create_table( - "admin_settings", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("key", sa.String(length=100), nullable=False), - sa.Column("value", sa.Text(), nullable=False), - sa.Column("value_type", sa.String(length=20), nullable=True), - sa.Column("category", sa.String(length=50), nullable=True), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("is_encrypted", sa.Boolean(), nullable=True), - sa.Column("is_public", sa.Boolean(), nullable=True), - sa.Column("last_modified_by_user_id", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["last_modified_by_user_id"], - ["users.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_admin_settings_category"), "admin_settings", ["category"], unique=False - ) - op.create_index( - op.f("ix_admin_settings_id"), "admin_settings", ["id"], unique=False - ) - op.create_index( - op.f("ix_admin_settings_key"), "admin_settings", ["key"], unique=True - ) - op.create_table( - "platform_alerts", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("alert_type", sa.String(length=50), nullable=False), - sa.Column("severity", sa.String(length=20), nullable=False), - sa.Column("title", sa.String(length=200), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("affected_vendors", sa.JSON(), nullable=True), - sa.Column("affected_systems", sa.JSON(), nullable=True), - sa.Column("is_resolved", sa.Boolean(), nullable=True), - sa.Column("resolved_at", sa.DateTime(), nullable=True), - sa.Column("resolved_by_user_id", sa.Integer(), nullable=True), - sa.Column("resolution_notes", sa.Text(), nullable=True), - sa.Column("auto_generated", sa.Boolean(), nullable=True), - sa.Column("occurrence_count", sa.Integer(), nullable=True), - sa.Column("first_occurred_at", sa.DateTime(), nullable=False), - sa.Column("last_occurred_at", sa.DateTime(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["resolved_by_user_id"], - ["users.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_platform_alerts_alert_type"), - "platform_alerts", - ["alert_type"], - unique=False, - ) - op.create_index( - op.f("ix_platform_alerts_id"), "platform_alerts", ["id"], unique=False - ) - op.create_index( - op.f("ix_platform_alerts_is_resolved"), - "platform_alerts", - ["is_resolved"], - unique=False, - ) - op.create_index( - op.f("ix_platform_alerts_severity"), - "platform_alerts", - ["severity"], - unique=False, - ) - op.create_table( - "vendors", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_code", sa.String(), nullable=False), - sa.Column("subdomain", sa.String(length=100), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("owner_user_id", sa.Integer(), nullable=False), - sa.Column("contact_email", sa.String(), nullable=True), - sa.Column("contact_phone", sa.String(), nullable=True), - sa.Column("website", sa.String(), nullable=True), - sa.Column("letzshop_csv_url_fr", sa.String(), nullable=True), - sa.Column("letzshop_csv_url_en", sa.String(), nullable=True), - sa.Column("letzshop_csv_url_de", sa.String(), nullable=True), - sa.Column("business_address", sa.Text(), nullable=True), - sa.Column("tax_number", sa.String(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=True), - sa.Column("is_verified", sa.Boolean(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["owner_user_id"], - ["users.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_vendors_id"), "vendors", ["id"], unique=False) - op.create_index(op.f("ix_vendors_subdomain"), "vendors", ["subdomain"], unique=True) - op.create_index( - op.f("ix_vendors_vendor_code"), "vendors", ["vendor_code"], unique=True - ) - op.create_table( - "customers", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("email", sa.String(length=255), nullable=False), - sa.Column("hashed_password", sa.String(length=255), nullable=False), - sa.Column("first_name", sa.String(length=100), nullable=True), - sa.Column("last_name", sa.String(length=100), nullable=True), - sa.Column("phone", sa.String(length=50), nullable=True), - sa.Column("customer_number", sa.String(length=100), nullable=False), - sa.Column("preferences", sa.JSON(), nullable=True), - sa.Column("marketing_consent", sa.Boolean(), nullable=True), - sa.Column("last_order_date", sa.DateTime(), nullable=True), - sa.Column("total_orders", sa.Integer(), nullable=True), - sa.Column("total_spent", sa.Numeric(precision=10, scale=2), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_customers_customer_number"), - "customers", - ["customer_number"], - unique=False, - ) - op.create_index(op.f("ix_customers_email"), "customers", ["email"], unique=False) - op.create_index(op.f("ix_customers_id"), "customers", ["id"], unique=False) - op.create_table( - "marketplace_import_jobs", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("marketplace", sa.String(), nullable=False), - sa.Column("source_url", sa.String(), nullable=False), - sa.Column("status", sa.String(), nullable=False), - sa.Column("imported_count", sa.Integer(), nullable=True), - sa.Column("updated_count", sa.Integer(), nullable=True), - sa.Column("error_count", sa.Integer(), nullable=True), - sa.Column("total_processed", sa.Integer(), nullable=True), - sa.Column("error_message", sa.Text(), nullable=True), - sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "idx_import_user_marketplace", - "marketplace_import_jobs", - ["user_id", "marketplace"], - unique=False, - ) - op.create_index( - "idx_import_vendor_created", - "marketplace_import_jobs", - ["vendor_id", "created_at"], - unique=False, - ) - op.create_index( - "idx_import_vendor_status", - "marketplace_import_jobs", - ["vendor_id", "status"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_import_jobs_id"), - "marketplace_import_jobs", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_import_jobs_marketplace"), - "marketplace_import_jobs", - ["marketplace"], - unique=False, - ) - op.create_index( - op.f("ix_marketplace_import_jobs_vendor_id"), - "marketplace_import_jobs", - ["vendor_id"], - unique=False, - ) - op.create_table( - "products", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("marketplace_product_id", sa.Integer(), nullable=False), - sa.Column("product_id", sa.String(), nullable=True), - sa.Column("price", sa.Float(), nullable=True), - sa.Column("sale_price", sa.Float(), nullable=True), - sa.Column("currency", sa.String(), nullable=True), - sa.Column("availability", sa.String(), nullable=True), - sa.Column("condition", sa.String(), nullable=True), - sa.Column("is_featured", sa.Boolean(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=True), - sa.Column("display_order", sa.Integer(), nullable=True), - sa.Column("min_quantity", sa.Integer(), nullable=True), - sa.Column("max_quantity", sa.Integer(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["marketplace_product_id"], - ["marketplace_products.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("vendor_id", "marketplace_product_id", name="uq_product"), - ) - op.create_index( - "idx_product_active", "products", ["vendor_id", "is_active"], unique=False - ) - op.create_index( - "idx_product_featured", "products", ["vendor_id", "is_featured"], unique=False - ) - op.create_index(op.f("ix_products_id"), "products", ["id"], unique=False) - op.create_table( - "roles", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(length=100), nullable=False), - sa.Column("permissions", sa.JSON(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_roles_id"), "roles", ["id"], unique=False) - op.create_table( - "vendor_domains", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("domain", sa.String(length=255), nullable=False), - sa.Column("is_primary", sa.Boolean(), nullable=False), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("ssl_status", sa.String(length=50), nullable=True), - sa.Column("ssl_verified_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("verification_token", sa.String(length=100), nullable=True), - sa.Column("is_verified", sa.Boolean(), nullable=False), - sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["vendor_id"], ["vendors.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("vendor_id", "domain", name="uq_vendor_domain"), - sa.UniqueConstraint("verification_token"), - ) - op.create_index( - "idx_domain_active", "vendor_domains", ["domain", "is_active"], unique=False - ) - op.create_index( - "idx_vendor_primary", - "vendor_domains", - ["vendor_id", "is_primary"], - unique=False, - ) - op.create_index( - op.f("ix_vendor_domains_domain"), "vendor_domains", ["domain"], unique=True - ) - op.create_index( - op.f("ix_vendor_domains_id"), "vendor_domains", ["id"], unique=False - ) - op.create_table( - "vendor_themes", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("theme_name", sa.String(length=100), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=True), - sa.Column("colors", sa.JSON(), nullable=True), - sa.Column("font_family_heading", sa.String(length=100), nullable=True), - sa.Column("font_family_body", sa.String(length=100), nullable=True), - sa.Column("logo_url", sa.String(length=500), nullable=True), - sa.Column("logo_dark_url", sa.String(length=500), nullable=True), - sa.Column("favicon_url", sa.String(length=500), nullable=True), - sa.Column("banner_url", sa.String(length=500), nullable=True), - sa.Column("layout_style", sa.String(length=50), nullable=True), - sa.Column("header_style", sa.String(length=50), nullable=True), - sa.Column("product_card_style", sa.String(length=50), nullable=True), - sa.Column("custom_css", sa.Text(), nullable=True), - sa.Column("social_links", sa.JSON(), nullable=True), - sa.Column("meta_title_template", sa.String(length=200), nullable=True), - sa.Column("meta_description", sa.Text(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["vendor_id"], ["vendors.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("vendor_id"), - ) - op.create_index(op.f("ix_vendor_themes_id"), "vendor_themes", ["id"], unique=False) - op.create_table( - "customer_addresses", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("customer_id", sa.Integer(), nullable=False), - sa.Column("address_type", sa.String(length=50), nullable=False), - sa.Column("first_name", sa.String(length=100), nullable=False), - sa.Column("last_name", sa.String(length=100), nullable=False), - sa.Column("company", sa.String(length=200), nullable=True), - sa.Column("address_line_1", sa.String(length=255), nullable=False), - sa.Column("address_line_2", sa.String(length=255), nullable=True), - sa.Column("city", sa.String(length=100), nullable=False), - sa.Column("postal_code", sa.String(length=20), nullable=False), - sa.Column("country", sa.String(length=100), nullable=False), - sa.Column("is_default", sa.Boolean(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["customer_id"], - ["customers.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_customer_addresses_id"), "customer_addresses", ["id"], unique=False - ) - op.create_table( - "inventory", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("product_id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("location", sa.String(), nullable=False), - sa.Column("quantity", sa.Integer(), nullable=False), - sa.Column("reserved_quantity", sa.Integer(), nullable=True), - sa.Column("gtin", sa.String(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["product_id"], - ["products.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "product_id", "location", name="uq_inventory_product_location" - ), - ) - op.create_index( - "idx_inventory_product_location", - "inventory", - ["product_id", "location"], - unique=False, - ) - op.create_index( - "idx_inventory_vendor_product", - "inventory", - ["vendor_id", "product_id"], - unique=False, - ) - op.create_index(op.f("ix_inventory_gtin"), "inventory", ["gtin"], unique=False) - op.create_index(op.f("ix_inventory_id"), "inventory", ["id"], unique=False) - op.create_index( - op.f("ix_inventory_location"), "inventory", ["location"], unique=False - ) - op.create_index( - op.f("ix_inventory_product_id"), "inventory", ["product_id"], unique=False - ) - op.create_index( - op.f("ix_inventory_vendor_id"), "inventory", ["vendor_id"], unique=False - ) - op.create_table( - "vendor_users", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("role_id", sa.Integer(), nullable=False), - sa.Column("invited_by", sa.Integer(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["invited_by"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["role_id"], - ["roles.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_vendor_users_id"), "vendor_users", ["id"], unique=False) - op.create_table( - "orders", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("customer_id", sa.Integer(), nullable=False), - sa.Column("order_number", sa.String(), nullable=False), - sa.Column("status", sa.String(), nullable=False), - sa.Column("subtotal", sa.Float(), nullable=False), - sa.Column("tax_amount", sa.Float(), nullable=True), - sa.Column("shipping_amount", sa.Float(), nullable=True), - sa.Column("discount_amount", sa.Float(), nullable=True), - sa.Column("total_amount", sa.Float(), nullable=False), - sa.Column("currency", sa.String(), nullable=True), - sa.Column("shipping_address_id", sa.Integer(), nullable=False), - sa.Column("billing_address_id", sa.Integer(), nullable=False), - sa.Column("shipping_method", sa.String(), nullable=True), - sa.Column("tracking_number", sa.String(), nullable=True), - sa.Column("customer_notes", sa.Text(), nullable=True), - sa.Column("internal_notes", sa.Text(), nullable=True), - sa.Column("paid_at", sa.DateTime(), nullable=True), - sa.Column("shipped_at", sa.DateTime(), nullable=True), - sa.Column("delivered_at", sa.DateTime(), nullable=True), - sa.Column("cancelled_at", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["billing_address_id"], - ["customer_addresses.id"], - ), - sa.ForeignKeyConstraint( - ["customer_id"], - ["customers.id"], - ), - sa.ForeignKeyConstraint( - ["shipping_address_id"], - ["customer_addresses.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_orders_customer_id"), "orders", ["customer_id"], unique=False - ) - op.create_index(op.f("ix_orders_id"), "orders", ["id"], unique=False) - op.create_index( - op.f("ix_orders_order_number"), "orders", ["order_number"], unique=True - ) - op.create_index(op.f("ix_orders_status"), "orders", ["status"], unique=False) - op.create_index(op.f("ix_orders_vendor_id"), "orders", ["vendor_id"], unique=False) - op.create_table( - "order_items", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("order_id", sa.Integer(), nullable=False), - sa.Column("product_id", sa.Integer(), nullable=False), - sa.Column("product_name", sa.String(), nullable=False), - sa.Column("product_sku", sa.String(), nullable=True), - sa.Column("quantity", sa.Integer(), nullable=False), - sa.Column("unit_price", sa.Float(), nullable=False), - sa.Column("total_price", sa.Float(), nullable=False), - sa.Column("inventory_reserved", sa.Boolean(), nullable=True), - sa.Column("inventory_fulfilled", sa.Boolean(), nullable=True), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["order_id"], - ["orders.id"], - ), - sa.ForeignKeyConstraint( - ["product_id"], - ["products.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_order_items_id"), "order_items", ["id"], unique=False) - op.create_index( - op.f("ix_order_items_order_id"), "order_items", ["order_id"], unique=False - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f("ix_order_items_order_id"), table_name="order_items") - op.drop_index(op.f("ix_order_items_id"), table_name="order_items") - op.drop_table("order_items") - op.drop_index(op.f("ix_orders_vendor_id"), table_name="orders") - op.drop_index(op.f("ix_orders_status"), table_name="orders") - op.drop_index(op.f("ix_orders_order_number"), table_name="orders") - op.drop_index(op.f("ix_orders_id"), table_name="orders") - op.drop_index(op.f("ix_orders_customer_id"), table_name="orders") - op.drop_table("orders") - op.drop_index(op.f("ix_vendor_users_id"), table_name="vendor_users") - op.drop_table("vendor_users") - op.drop_index(op.f("ix_inventory_vendor_id"), table_name="inventory") - op.drop_index(op.f("ix_inventory_product_id"), table_name="inventory") - op.drop_index(op.f("ix_inventory_location"), table_name="inventory") - op.drop_index(op.f("ix_inventory_id"), table_name="inventory") - op.drop_index(op.f("ix_inventory_gtin"), table_name="inventory") - op.drop_index("idx_inventory_vendor_product", table_name="inventory") - op.drop_index("idx_inventory_product_location", table_name="inventory") - op.drop_table("inventory") - op.drop_index(op.f("ix_customer_addresses_id"), table_name="customer_addresses") - op.drop_table("customer_addresses") - op.drop_index(op.f("ix_vendor_themes_id"), table_name="vendor_themes") - op.drop_table("vendor_themes") - op.drop_index(op.f("ix_vendor_domains_id"), table_name="vendor_domains") - op.drop_index(op.f("ix_vendor_domains_domain"), table_name="vendor_domains") - op.drop_index("idx_vendor_primary", table_name="vendor_domains") - op.drop_index("idx_domain_active", table_name="vendor_domains") - op.drop_table("vendor_domains") - op.drop_index(op.f("ix_roles_id"), table_name="roles") - op.drop_table("roles") - op.drop_index(op.f("ix_products_id"), table_name="products") - op.drop_index("idx_product_featured", table_name="products") - op.drop_index("idx_product_active", table_name="products") - op.drop_table("products") - op.drop_index( - op.f("ix_marketplace_import_jobs_vendor_id"), - table_name="marketplace_import_jobs", - ) - op.drop_index( - op.f("ix_marketplace_import_jobs_marketplace"), - table_name="marketplace_import_jobs", - ) - op.drop_index( - op.f("ix_marketplace_import_jobs_id"), table_name="marketplace_import_jobs" - ) - op.drop_index("idx_import_vendor_status", table_name="marketplace_import_jobs") - op.drop_index("idx_import_vendor_created", table_name="marketplace_import_jobs") - op.drop_index("idx_import_user_marketplace", table_name="marketplace_import_jobs") - op.drop_table("marketplace_import_jobs") - op.drop_index(op.f("ix_customers_id"), table_name="customers") - op.drop_index(op.f("ix_customers_email"), table_name="customers") - op.drop_index(op.f("ix_customers_customer_number"), table_name="customers") - op.drop_table("customers") - op.drop_index(op.f("ix_vendors_vendor_code"), table_name="vendors") - op.drop_index(op.f("ix_vendors_subdomain"), table_name="vendors") - op.drop_index(op.f("ix_vendors_id"), table_name="vendors") - op.drop_table("vendors") - op.drop_index(op.f("ix_platform_alerts_severity"), table_name="platform_alerts") - op.drop_index(op.f("ix_platform_alerts_is_resolved"), table_name="platform_alerts") - op.drop_index(op.f("ix_platform_alerts_id"), table_name="platform_alerts") - op.drop_index(op.f("ix_platform_alerts_alert_type"), table_name="platform_alerts") - op.drop_table("platform_alerts") - op.drop_index(op.f("ix_admin_settings_key"), table_name="admin_settings") - op.drop_index(op.f("ix_admin_settings_id"), table_name="admin_settings") - op.drop_index(op.f("ix_admin_settings_category"), table_name="admin_settings") - op.drop_table("admin_settings") - op.drop_index(op.f("ix_admin_sessions_session_token"), table_name="admin_sessions") - op.drop_index(op.f("ix_admin_sessions_login_at"), table_name="admin_sessions") - op.drop_index(op.f("ix_admin_sessions_is_active"), table_name="admin_sessions") - op.drop_index(op.f("ix_admin_sessions_id"), table_name="admin_sessions") - op.drop_index(op.f("ix_admin_sessions_admin_user_id"), table_name="admin_sessions") - op.drop_table("admin_sessions") - op.drop_index(op.f("ix_admin_notifications_type"), table_name="admin_notifications") - op.drop_index( - op.f("ix_admin_notifications_priority"), table_name="admin_notifications" - ) - op.drop_index( - op.f("ix_admin_notifications_is_read"), table_name="admin_notifications" - ) - op.drop_index(op.f("ix_admin_notifications_id"), table_name="admin_notifications") - op.drop_index( - op.f("ix_admin_notifications_action_required"), table_name="admin_notifications" - ) - op.drop_table("admin_notifications") - op.drop_index( - op.f("ix_admin_audit_logs_target_type"), table_name="admin_audit_logs" - ) - op.drop_index(op.f("ix_admin_audit_logs_target_id"), table_name="admin_audit_logs") - op.drop_index(op.f("ix_admin_audit_logs_id"), table_name="admin_audit_logs") - op.drop_index( - op.f("ix_admin_audit_logs_admin_user_id"), table_name="admin_audit_logs" - ) - op.drop_index(op.f("ix_admin_audit_logs_action"), table_name="admin_audit_logs") - op.drop_table("admin_audit_logs") - op.drop_index(op.f("ix_users_username"), table_name="users") - op.drop_index(op.f("ix_users_id"), table_name="users") - op.drop_index(op.f("ix_users_email"), table_name="users") - op.drop_table("users") - op.drop_index( - op.f("ix_marketplace_products_vendor_name"), table_name="marketplace_products" - ) - op.drop_index( - op.f("ix_marketplace_products_marketplace_product_id"), - table_name="marketplace_products", - ) - op.drop_index( - op.f("ix_marketplace_products_marketplace"), table_name="marketplace_products" - ) - op.drop_index(op.f("ix_marketplace_products_id"), table_name="marketplace_products") - op.drop_index( - op.f("ix_marketplace_products_gtin"), table_name="marketplace_products" - ) - op.drop_index( - op.f("ix_marketplace_products_google_product_category"), - table_name="marketplace_products", - ) - op.drop_index( - op.f("ix_marketplace_products_brand"), table_name="marketplace_products" - ) - op.drop_index( - op.f("ix_marketplace_products_availability"), table_name="marketplace_products" - ) - op.drop_index("idx_marketplace_vendor", table_name="marketplace_products") - op.drop_index("idx_marketplace_brand", table_name="marketplace_products") - op.drop_table("marketplace_products") - # ### end Alembic commands ### diff --git a/alembic/versions/55b92e155566_add_order_tracking_fields.py b/alembic/versions/55b92e155566_add_order_tracking_fields.py deleted file mode 100644 index 8e80682d..00000000 --- a/alembic/versions/55b92e155566_add_order_tracking_fields.py +++ /dev/null @@ -1,31 +0,0 @@ -"""add_order_tracking_fields - -Revision ID: 55b92e155566 -Revises: d2e3f4a5b6c7 -Create Date: 2025-12-20 18:07:51.144136 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '55b92e155566' -down_revision: Union[str, None] = 'd2e3f4a5b6c7' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add new tracking fields to orders table - op.add_column('orders', sa.Column('tracking_url', sa.String(length=500), nullable=True)) - op.add_column('orders', sa.Column('shipment_number', sa.String(length=100), nullable=True)) - op.add_column('orders', sa.Column('shipping_carrier', sa.String(length=50), nullable=True)) - - -def downgrade() -> None: - op.drop_column('orders', 'shipping_carrier') - op.drop_column('orders', 'shipment_number') - op.drop_column('orders', 'tracking_url') diff --git a/alembic/versions/5818330181a5_make_vendor_owner_user_id_nullable_for_.py b/alembic/versions/5818330181a5_make_vendor_owner_user_id_nullable_for_.py deleted file mode 100644 index cb3a74bc..00000000 --- a/alembic/versions/5818330181a5_make_vendor_owner_user_id_nullable_for_.py +++ /dev/null @@ -1,48 +0,0 @@ -"""make_vendor_owner_user_id_nullable_for_company_ownership - -Revision ID: 5818330181a5 -Revises: d0325d7c0f25 -Create Date: 2025-12-01 20:30:06.158027 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '5818330181a5' -down_revision: Union[str, None] = 'd0325d7c0f25' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """ - Make vendor.owner_user_id nullable to support company-level ownership. - - Architecture Change: - - OLD: Each vendor has its own owner (vendor.owner_user_id) - - NEW: Vendors belong to a company, company has one owner (company.owner_user_id) - - This allows one company owner to manage multiple vendor brands. - """ - # Use batch operations for SQLite compatibility - with op.batch_alter_table('vendors', schema=None) as batch_op: - batch_op.alter_column('owner_user_id', - existing_type=sa.INTEGER(), - nullable=True) - - -def downgrade() -> None: - """ - Revert vendor.owner_user_id to non-nullable. - - WARNING: This will fail if there are vendors without owner_user_id! - """ - # Use batch operations for SQLite compatibility - with op.batch_alter_table('vendors', schema=None) as batch_op: - batch_op.alter_column('owner_user_id', - existing_type=sa.INTEGER(), - nullable=False) diff --git a/alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py b/alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py deleted file mode 100644 index 24a6d43d..00000000 --- a/alembic/versions/72aa309d4007_ensure_content_pages_table_with_all_.py +++ /dev/null @@ -1,76 +0,0 @@ -"""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 - -import sqlalchemy as sa - -from alembic import op - -# 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/7a7ce92593d5_add_architecture_quality_tracking_tables.py b/alembic/versions/7a7ce92593d5_add_architecture_quality_tracking_tables.py deleted file mode 100644 index f5e60325..00000000 --- a/alembic/versions/7a7ce92593d5_add_architecture_quality_tracking_tables.py +++ /dev/null @@ -1,291 +0,0 @@ -"""add_architecture_quality_tracking_tables - -Revision ID: 7a7ce92593d5 -Revises: a2064e1dfcd4 -Create Date: 2025-11-28 09:21:16.545203 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "7a7ce92593d5" -down_revision: Union[str, None] = "a2064e1dfcd4" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create architecture_scans table - op.create_table( - "architecture_scans", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "timestamp", - sa.DateTime(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - sa.Column("total_files", sa.Integer(), nullable=True), - sa.Column("total_violations", sa.Integer(), nullable=True), - sa.Column("errors", sa.Integer(), nullable=True), - sa.Column("warnings", sa.Integer(), nullable=True), - sa.Column("duration_seconds", sa.Float(), nullable=True), - sa.Column("triggered_by", sa.String(length=100), nullable=True), - sa.Column("git_commit_hash", sa.String(length=40), nullable=True), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_architecture_scans_id"), "architecture_scans", ["id"], unique=False - ) - op.create_index( - op.f("ix_architecture_scans_timestamp"), - "architecture_scans", - ["timestamp"], - unique=False, - ) - - # Create architecture_rules table - op.create_table( - "architecture_rules", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("rule_id", sa.String(length=20), nullable=False), - sa.Column("category", sa.String(length=50), nullable=False), - sa.Column("name", sa.String(length=200), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("severity", sa.String(length=10), nullable=False), - sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"), - sa.Column("custom_config", sa.JSON(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("rule_id"), - ) - op.create_index( - op.f("ix_architecture_rules_id"), "architecture_rules", ["id"], unique=False - ) - op.create_index( - op.f("ix_architecture_rules_rule_id"), - "architecture_rules", - ["rule_id"], - unique=True, - ) - - # Create architecture_violations table - op.create_table( - "architecture_violations", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("scan_id", sa.Integer(), nullable=False), - sa.Column("rule_id", sa.String(length=20), nullable=False), - sa.Column("rule_name", sa.String(length=200), nullable=False), - sa.Column("severity", sa.String(length=10), nullable=False), - sa.Column("file_path", sa.String(length=500), nullable=False), - sa.Column("line_number", sa.Integer(), nullable=False), - sa.Column("message", sa.Text(), nullable=False), - sa.Column("context", sa.Text(), nullable=True), - sa.Column("suggestion", sa.Text(), nullable=True), - sa.Column("status", sa.String(length=20), server_default="open", nullable=True), - sa.Column("assigned_to", sa.Integer(), nullable=True), - sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("resolved_by", sa.Integer(), nullable=True), - sa.Column("resolution_note", sa.Text(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["assigned_to"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["resolved_by"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["scan_id"], - ["architecture_scans.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_architecture_violations_file_path"), - "architecture_violations", - ["file_path"], - unique=False, - ) - op.create_index( - op.f("ix_architecture_violations_id"), - "architecture_violations", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_architecture_violations_rule_id"), - "architecture_violations", - ["rule_id"], - unique=False, - ) - op.create_index( - op.f("ix_architecture_violations_scan_id"), - "architecture_violations", - ["scan_id"], - unique=False, - ) - op.create_index( - op.f("ix_architecture_violations_severity"), - "architecture_violations", - ["severity"], - unique=False, - ) - op.create_index( - op.f("ix_architecture_violations_status"), - "architecture_violations", - ["status"], - unique=False, - ) - - # Create violation_assignments table - op.create_table( - "violation_assignments", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("violation_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column( - "assigned_at", - sa.DateTime(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - sa.Column("assigned_by", sa.Integer(), nullable=True), - sa.Column("due_date", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "priority", sa.String(length=10), server_default="medium", nullable=True - ), - sa.ForeignKeyConstraint( - ["assigned_by"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["violation_id"], - ["architecture_violations.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_violation_assignments_id"), - "violation_assignments", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_violation_assignments_violation_id"), - "violation_assignments", - ["violation_id"], - unique=False, - ) - - # Create violation_comments table - op.create_table( - "violation_comments", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("violation_id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("comment", sa.Text(), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("CURRENT_TIMESTAMP"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ), - sa.ForeignKeyConstraint( - ["violation_id"], - ["architecture_violations.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_violation_comments_id"), "violation_comments", ["id"], unique=False - ) - op.create_index( - op.f("ix_violation_comments_violation_id"), - "violation_comments", - ["violation_id"], - unique=False, - ) - - -def downgrade() -> None: - # Drop tables in reverse order (to respect foreign key constraints) - op.drop_index( - op.f("ix_violation_comments_violation_id"), table_name="violation_comments" - ) - op.drop_index(op.f("ix_violation_comments_id"), table_name="violation_comments") - op.drop_table("violation_comments") - - op.drop_index( - op.f("ix_violation_assignments_violation_id"), - table_name="violation_assignments", - ) - op.drop_index( - op.f("ix_violation_assignments_id"), table_name="violation_assignments" - ) - op.drop_table("violation_assignments") - - op.drop_index( - op.f("ix_architecture_violations_status"), table_name="architecture_violations" - ) - op.drop_index( - op.f("ix_architecture_violations_severity"), - table_name="architecture_violations", - ) - op.drop_index( - op.f("ix_architecture_violations_scan_id"), table_name="architecture_violations" - ) - op.drop_index( - op.f("ix_architecture_violations_rule_id"), table_name="architecture_violations" - ) - op.drop_index( - op.f("ix_architecture_violations_id"), table_name="architecture_violations" - ) - op.drop_index( - op.f("ix_architecture_violations_file_path"), - table_name="architecture_violations", - ) - op.drop_table("architecture_violations") - - op.drop_index( - op.f("ix_architecture_rules_rule_id"), table_name="architecture_rules" - ) - op.drop_index(op.f("ix_architecture_rules_id"), table_name="architecture_rules") - op.drop_table("architecture_rules") - - op.drop_index( - op.f("ix_architecture_scans_timestamp"), table_name="architecture_scans" - ) - op.drop_index(op.f("ix_architecture_scans_id"), table_name="architecture_scans") - op.drop_table("architecture_scans") diff --git a/alembic/versions/82ea1b4a3ccb_add_test_run_tables.py b/alembic/versions/82ea1b4a3ccb_add_test_run_tables.py deleted file mode 100644 index 2c9f6986..00000000 --- a/alembic/versions/82ea1b4a3ccb_add_test_run_tables.py +++ /dev/null @@ -1,103 +0,0 @@ -"""add_test_run_tables - -Revision ID: 82ea1b4a3ccb -Revises: b4c5d6e7f8a9 -Create Date: 2025-12-12 22:48:09.501172 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '82ea1b4a3ccb' -down_revision: Union[str, None] = 'b4c5d6e7f8a9' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create test_collections table - op.create_table('test_collections', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('total_tests', sa.Integer(), nullable=True), - sa.Column('total_files', sa.Integer(), nullable=True), - sa.Column('total_classes', sa.Integer(), nullable=True), - sa.Column('unit_tests', sa.Integer(), nullable=True), - sa.Column('integration_tests', sa.Integer(), nullable=True), - sa.Column('performance_tests', sa.Integer(), nullable=True), - sa.Column('system_tests', sa.Integer(), nullable=True), - sa.Column('test_files', sa.JSON(), nullable=True), - sa.Column('collected_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_test_collections_id'), 'test_collections', ['id'], unique=False) - - # Create test_runs table - op.create_table('test_runs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('timestamp', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('total_tests', sa.Integer(), nullable=True), - sa.Column('passed', sa.Integer(), nullable=True), - sa.Column('failed', sa.Integer(), nullable=True), - sa.Column('errors', sa.Integer(), nullable=True), - sa.Column('skipped', sa.Integer(), nullable=True), - sa.Column('xfailed', sa.Integer(), nullable=True), - sa.Column('xpassed', sa.Integer(), nullable=True), - sa.Column('coverage_percent', sa.Float(), nullable=True), - sa.Column('duration_seconds', sa.Float(), nullable=True), - sa.Column('triggered_by', sa.String(length=100), nullable=True), - sa.Column('git_commit_hash', sa.String(length=40), nullable=True), - sa.Column('git_branch', sa.String(length=100), nullable=True), - sa.Column('test_path', sa.String(length=500), nullable=True), - sa.Column('pytest_args', sa.String(length=500), nullable=True), - sa.Column('status', sa.String(length=20), nullable=True), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_test_runs_id'), 'test_runs', ['id'], unique=False) - op.create_index(op.f('ix_test_runs_status'), 'test_runs', ['status'], unique=False) - op.create_index(op.f('ix_test_runs_timestamp'), 'test_runs', ['timestamp'], unique=False) - - # Create test_results table - op.create_table('test_results', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('run_id', sa.Integer(), nullable=False), - sa.Column('node_id', sa.String(length=500), nullable=False), - sa.Column('test_name', sa.String(length=200), nullable=False), - sa.Column('test_file', sa.String(length=300), nullable=False), - sa.Column('test_class', sa.String(length=200), nullable=True), - sa.Column('outcome', sa.String(length=20), nullable=False), - sa.Column('duration_seconds', sa.Float(), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('traceback', sa.Text(), nullable=True), - sa.Column('markers', sa.JSON(), nullable=True), - sa.Column('parameters', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['run_id'], ['test_runs.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_test_results_id'), 'test_results', ['id'], unique=False) - op.create_index(op.f('ix_test_results_node_id'), 'test_results', ['node_id'], unique=False) - op.create_index(op.f('ix_test_results_outcome'), 'test_results', ['outcome'], unique=False) - op.create_index(op.f('ix_test_results_run_id'), 'test_results', ['run_id'], unique=False) - - -def downgrade() -> None: - # Drop test_results table first (has foreign key to test_runs) - op.drop_index(op.f('ix_test_results_run_id'), table_name='test_results') - op.drop_index(op.f('ix_test_results_outcome'), table_name='test_results') - op.drop_index(op.f('ix_test_results_node_id'), table_name='test_results') - op.drop_index(op.f('ix_test_results_id'), table_name='test_results') - op.drop_table('test_results') - - # Drop test_runs table - op.drop_index(op.f('ix_test_runs_timestamp'), table_name='test_runs') - op.drop_index(op.f('ix_test_runs_status'), table_name='test_runs') - op.drop_index(op.f('ix_test_runs_id'), table_name='test_runs') - op.drop_table('test_runs') - - # Drop test_collections table - op.drop_index(op.f('ix_test_collections_id'), table_name='test_collections') - op.drop_table('test_collections') diff --git a/alembic/versions/91d02647efae_add_marketplace_import_errors_table.py b/alembic/versions/91d02647efae_add_marketplace_import_errors_table.py deleted file mode 100644 index 29db8b03..00000000 --- a/alembic/versions/91d02647efae_add_marketplace_import_errors_table.py +++ /dev/null @@ -1,44 +0,0 @@ -"""add marketplace import errors table - -Revision ID: 91d02647efae -Revises: 987b4ecfa503 -Create Date: 2025-12-13 13:13:46.969503 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = '91d02647efae' -down_revision: Union[str, None] = '987b4ecfa503' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create marketplace_import_errors table to store detailed import error information - op.create_table('marketplace_import_errors', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('import_job_id', sa.Integer(), nullable=False), - sa.Column('row_number', sa.Integer(), nullable=False), - sa.Column('identifier', sa.String(), nullable=True), - sa.Column('error_type', sa.String(length=50), nullable=False), - sa.Column('error_message', sa.Text(), nullable=False), - sa.Column('row_data', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['import_job_id'], ['marketplace_import_jobs.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_import_error_job_id', 'marketplace_import_errors', ['import_job_id'], unique=False) - op.create_index('idx_import_error_type', 'marketplace_import_errors', ['error_type'], unique=False) - op.create_index(op.f('ix_marketplace_import_errors_id'), 'marketplace_import_errors', ['id'], unique=False) - - -def downgrade() -> None: - op.drop_index(op.f('ix_marketplace_import_errors_id'), table_name='marketplace_import_errors') - op.drop_index('idx_import_error_type', table_name='marketplace_import_errors') - op.drop_index('idx_import_error_job_id', table_name='marketplace_import_errors') - op.drop_table('marketplace_import_errors') diff --git a/alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py b/alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py deleted file mode 100644 index 54605a38..00000000 --- a/alembic/versions/987b4ecfa503_add_letzshop_integration_tables.py +++ /dev/null @@ -1,179 +0,0 @@ -"""add_letzshop_integration_tables - -Revision ID: 987b4ecfa503 -Revises: 82ea1b4a3ccb -Create Date: 2025-12-13 - -This migration adds: -- vendor_letzshop_credentials: Per-vendor encrypted API key storage -- letzshop_orders: Track imported orders with external IDs -- letzshop_fulfillment_queue: Queue outbound operations with retry -- letzshop_sync_logs: Audit trail for sync operations -- Adds channel fields to orders table for multi-marketplace support -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '987b4ecfa503' -down_revision: Union[str, None] = '82ea1b4a3ccb' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add channel fields to orders table - op.add_column('orders', sa.Column('channel', sa.String(length=50), nullable=True, server_default='direct')) - op.add_column('orders', sa.Column('external_order_id', sa.String(length=100), nullable=True)) - op.add_column('orders', sa.Column('external_channel_data', sa.JSON(), nullable=True)) - op.create_index(op.f('ix_orders_channel'), 'orders', ['channel'], unique=False) - op.create_index(op.f('ix_orders_external_order_id'), 'orders', ['external_order_id'], unique=False) - - # Create vendor_letzshop_credentials table - op.create_table('vendor_letzshop_credentials', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('api_key_encrypted', sa.Text(), nullable=False), - sa.Column('api_endpoint', sa.String(length=255), server_default='https://letzshop.lu/graphql', nullable=True), - sa.Column('auto_sync_enabled', sa.Boolean(), server_default='0', nullable=True), - sa.Column('sync_interval_minutes', sa.Integer(), server_default='15', nullable=True), - sa.Column('last_sync_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('last_sync_status', sa.String(length=50), nullable=True), - sa.Column('last_sync_error', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('vendor_id') - ) - op.create_index(op.f('ix_vendor_letzshop_credentials_id'), 'vendor_letzshop_credentials', ['id'], unique=False) - op.create_index(op.f('ix_vendor_letzshop_credentials_vendor_id'), 'vendor_letzshop_credentials', ['vendor_id'], unique=True) - - # Create letzshop_orders table - op.create_table('letzshop_orders', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('letzshop_order_id', sa.String(length=100), nullable=False), - sa.Column('letzshop_shipment_id', sa.String(length=100), nullable=True), - sa.Column('letzshop_order_number', sa.String(length=100), nullable=True), - sa.Column('local_order_id', sa.Integer(), nullable=True), - sa.Column('letzshop_state', sa.String(length=50), nullable=True), - sa.Column('customer_email', sa.String(length=255), nullable=True), - sa.Column('customer_name', sa.String(length=255), nullable=True), - sa.Column('total_amount', sa.String(length=50), nullable=True), - sa.Column('currency', sa.String(length=10), server_default='EUR', nullable=True), - sa.Column('raw_order_data', sa.JSON(), nullable=True), - sa.Column('inventory_units', sa.JSON(), nullable=True), - sa.Column('sync_status', sa.String(length=50), server_default='pending', nullable=True), - sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('sync_error', sa.Text(), nullable=True), - sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('rejected_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('tracking_set_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('tracking_number', sa.String(length=100), nullable=True), - sa.Column('tracking_carrier', sa.String(length=100), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['local_order_id'], ['orders.id'], ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_letzshop_orders_id'), 'letzshop_orders', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_orders_letzshop_order_id'), 'letzshop_orders', ['letzshop_order_id'], unique=False) - op.create_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), 'letzshop_orders', ['letzshop_shipment_id'], unique=False) - op.create_index(op.f('ix_letzshop_orders_vendor_id'), 'letzshop_orders', ['vendor_id'], unique=False) - op.create_index('idx_letzshop_order_vendor', 'letzshop_orders', ['vendor_id', 'letzshop_order_id'], unique=False) - op.create_index('idx_letzshop_order_state', 'letzshop_orders', ['vendor_id', 'letzshop_state'], unique=False) - op.create_index('idx_letzshop_order_sync', 'letzshop_orders', ['vendor_id', 'sync_status'], unique=False) - - # Create letzshop_fulfillment_queue table - op.create_table('letzshop_fulfillment_queue', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('letzshop_order_id', sa.Integer(), nullable=False), - sa.Column('operation', sa.String(length=50), nullable=False), - sa.Column('payload', sa.JSON(), nullable=False), - sa.Column('status', sa.String(length=50), server_default='pending', nullable=True), - sa.Column('attempts', sa.Integer(), server_default='0', nullable=True), - sa.Column('max_attempts', sa.Integer(), server_default='3', nullable=True), - sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('response_data', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['letzshop_order_id'], ['letzshop_orders.id'], ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_letzshop_fulfillment_queue_id'), 'letzshop_fulfillment_queue', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), 'letzshop_fulfillment_queue', ['vendor_id'], unique=False) - op.create_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue', ['status', 'vendor_id'], unique=False) - op.create_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue', ['status', 'next_retry_at'], unique=False) - - # Create letzshop_sync_logs table - op.create_table('letzshop_sync_logs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('operation_type', sa.String(length=50), nullable=False), - sa.Column('direction', sa.String(length=10), nullable=False), - sa.Column('status', sa.String(length=50), nullable=False), - sa.Column('records_processed', sa.Integer(), server_default='0', nullable=True), - sa.Column('records_succeeded', sa.Integer(), server_default='0', nullable=True), - sa.Column('records_failed', sa.Integer(), server_default='0', nullable=True), - sa.Column('error_details', sa.JSON(), nullable=True), - sa.Column('started_at', sa.DateTime(timezone=True), nullable=False), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('duration_seconds', sa.Integer(), nullable=True), - sa.Column('triggered_by', sa.String(length=100), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_letzshop_sync_logs_id'), 'letzshop_sync_logs', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_sync_logs_vendor_id'), 'letzshop_sync_logs', ['vendor_id'], unique=False) - op.create_index('idx_sync_log_vendor_type', 'letzshop_sync_logs', ['vendor_id', 'operation_type'], unique=False) - op.create_index('idx_sync_log_vendor_date', 'letzshop_sync_logs', ['vendor_id', 'started_at'], unique=False) - - -def downgrade() -> None: - # Drop letzshop_sync_logs table - op.drop_index('idx_sync_log_vendor_date', table_name='letzshop_sync_logs') - op.drop_index('idx_sync_log_vendor_type', table_name='letzshop_sync_logs') - op.drop_index(op.f('ix_letzshop_sync_logs_vendor_id'), table_name='letzshop_sync_logs') - op.drop_index(op.f('ix_letzshop_sync_logs_id'), table_name='letzshop_sync_logs') - op.drop_table('letzshop_sync_logs') - - # Drop letzshop_fulfillment_queue table - op.drop_index('idx_fulfillment_queue_retry', table_name='letzshop_fulfillment_queue') - op.drop_index('idx_fulfillment_queue_status', table_name='letzshop_fulfillment_queue') - op.drop_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), table_name='letzshop_fulfillment_queue') - op.drop_index(op.f('ix_letzshop_fulfillment_queue_id'), table_name='letzshop_fulfillment_queue') - op.drop_table('letzshop_fulfillment_queue') - - # Drop letzshop_orders table - op.drop_index('idx_letzshop_order_sync', table_name='letzshop_orders') - op.drop_index('idx_letzshop_order_state', table_name='letzshop_orders') - op.drop_index('idx_letzshop_order_vendor', table_name='letzshop_orders') - op.drop_index(op.f('ix_letzshop_orders_vendor_id'), table_name='letzshop_orders') - op.drop_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), table_name='letzshop_orders') - op.drop_index(op.f('ix_letzshop_orders_letzshop_order_id'), table_name='letzshop_orders') - op.drop_index(op.f('ix_letzshop_orders_id'), table_name='letzshop_orders') - op.drop_table('letzshop_orders') - - # Drop vendor_letzshop_credentials table - op.drop_index(op.f('ix_vendor_letzshop_credentials_vendor_id'), table_name='vendor_letzshop_credentials') - op.drop_index(op.f('ix_vendor_letzshop_credentials_id'), table_name='vendor_letzshop_credentials') - op.drop_table('vendor_letzshop_credentials') - - # Drop channel fields from orders table - op.drop_index(op.f('ix_orders_external_order_id'), table_name='orders') - op.drop_index(op.f('ix_orders_channel'), table_name='orders') - op.drop_column('orders', 'external_channel_data') - op.drop_column('orders', 'external_order_id') - op.drop_column('orders', 'channel') diff --git a/alembic/versions/9f3a25ea4991_remove_vendor_owner_user_id_column.py b/alembic/versions/9f3a25ea4991_remove_vendor_owner_user_id_column.py deleted file mode 100644 index 98c9ffa6..00000000 --- a/alembic/versions/9f3a25ea4991_remove_vendor_owner_user_id_column.py +++ /dev/null @@ -1,60 +0,0 @@ -"""remove_vendor_owner_user_id_column - -Revision ID: 9f3a25ea4991 -Revises: 5818330181a5 -Create Date: 2025-12-02 17:58:45.663338 - -This migration removes the owner_user_id column from the vendors table. - -Architecture Change: -- OLD: Each vendor had its own owner (vendor.owner_user_id) -- NEW: Vendors belong to a company, company has one owner (company.owner_user_id) - -The vendor ownership is now determined via the company relationship: -- vendor.company.owner_user_id contains the owner -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = '9f3a25ea4991' -down_revision: Union[str, None] = '5818330181a5' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """ - Remove owner_user_id column from vendors table. - - Ownership is now determined via the company relationship. - - Note: SQLite batch mode recreates the table without the column, - so we don't need to explicitly drop constraints. - """ - with op.batch_alter_table('vendors', schema=None) as batch_op: - # Drop the column - batch mode handles constraints automatically - batch_op.drop_column('owner_user_id') - - -def downgrade() -> None: - """ - Re-add owner_user_id column to vendors table. - - WARNING: This will add the column back but NOT restore the data. - You will need to manually populate owner_user_id from company.owner_user_id - if reverting this migration. - """ - with op.batch_alter_table('vendors', schema=None) as batch_op: - batch_op.add_column( - sa.Column('owner_user_id', sa.Integer(), nullable=True) - ) - batch_op.create_foreign_key( - 'vendors_owner_user_id_fkey', - 'users', - ['owner_user_id'], - ['id'] - ) diff --git a/alembic/versions/a2064e1dfcd4_add_cart_items_table.py b/alembic/versions/a2064e1dfcd4_add_cart_items_table.py deleted file mode 100644 index 3eca3ff3..00000000 --- a/alembic/versions/a2064e1dfcd4_add_cart_items_table.py +++ /dev/null @@ -1,67 +0,0 @@ -"""add cart_items table - -Revision ID: a2064e1dfcd4 -Revises: f68d8da5315a -Create Date: 2025-11-23 19:52:40.509538 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "a2064e1dfcd4" -down_revision: Union[str, None] = "f68d8da5315a" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create cart_items table - op.create_table( - "cart_items", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("product_id", sa.Integer(), nullable=False), - sa.Column("session_id", sa.String(length=255), nullable=False), - sa.Column("quantity", sa.Integer(), nullable=False), - sa.Column("price_at_add", sa.Float(), nullable=False), - sa.Column("created_at", sa.DateTime(), nullable=True), - sa.Column("updated_at", sa.DateTime(), nullable=True), - sa.ForeignKeyConstraint( - ["product_id"], - ["products.id"], - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "vendor_id", "session_id", "product_id", name="uq_cart_item" - ), - ) - - # Create indexes - op.create_index( - "idx_cart_session", "cart_items", ["vendor_id", "session_id"], unique=False - ) - op.create_index("idx_cart_created", "cart_items", ["created_at"], unique=False) - op.create_index(op.f("ix_cart_items_id"), "cart_items", ["id"], unique=False) - op.create_index( - op.f("ix_cart_items_session_id"), "cart_items", ["session_id"], unique=False - ) - - -def downgrade() -> None: - # Drop indexes - op.drop_index(op.f("ix_cart_items_session_id"), table_name="cart_items") - op.drop_index(op.f("ix_cart_items_id"), table_name="cart_items") - op.drop_index("idx_cart_created", table_name="cart_items") - op.drop_index("idx_cart_session", table_name="cart_items") - - # Drop table - op.drop_table("cart_items") diff --git a/alembic/versions/a3b4c5d6e7f8_add_product_override_fields.py b/alembic/versions/a3b4c5d6e7f8_add_product_override_fields.py deleted file mode 100644 index 2f402609..00000000 --- a/alembic/versions/a3b4c5d6e7f8_add_product_override_fields.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Add override fields to products table - -Revision ID: a3b4c5d6e7f8 -Revises: f2b3c4d5e6f7 -Create Date: 2025-12-11 - -This migration: -- Renames 'product_id' to 'vendor_sku' for clarity -- Adds new override fields (brand, images, digital delivery) -- Adds vendor-specific digital fulfillment fields -- Changes relationship from one-to-one to one-to-many (same marketplace product - can be in multiple vendor catalogs) - -The override pattern: NULL value means "inherit from marketplace_product". -Setting a value creates a vendor-specific override. -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "a3b4c5d6e7f8" -down_revision: Union[str, None] = "f2b3c4d5e6f7" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Use batch mode for SQLite compatibility - with op.batch_alter_table("products", schema=None) as batch_op: - # Rename product_id to vendor_sku for clarity - batch_op.alter_column( - "product_id", - new_column_name="vendor_sku", - ) - - # Add new override fields - op.add_column( - "products", - sa.Column("brand", sa.String(), nullable=True), - ) - op.add_column( - "products", - sa.Column("primary_image_url", sa.String(), nullable=True), - ) - op.add_column( - "products", - sa.Column("additional_images", sa.JSON(), nullable=True), - ) - - # Add digital product override fields - op.add_column( - "products", - sa.Column("download_url", sa.String(), nullable=True), - ) - op.add_column( - "products", - sa.Column("license_type", sa.String(50), nullable=True), - ) - - # Add vendor-specific digital fulfillment settings - op.add_column( - "products", - sa.Column("fulfillment_email_template", sa.String(), nullable=True), - ) - - # Add supplier tracking (for products sourced from CodesWholesale, etc.) - op.add_column( - "products", - sa.Column("supplier", sa.String(50), nullable=True), - ) - op.add_column( - "products", - sa.Column("supplier_product_id", sa.String(), nullable=True), - ) - op.add_column( - "products", - sa.Column("supplier_cost", sa.Float(), nullable=True), - ) - - # Add margin/markup tracking - op.add_column( - "products", - sa.Column("margin_percent", sa.Float(), nullable=True), - ) - - # Create index for vendor_sku - op.create_index( - "idx_product_vendor_sku", - "products", - ["vendor_id", "vendor_sku"], - ) - - # Create index for supplier queries - op.create_index( - "idx_product_supplier", - "products", - ["supplier", "supplier_product_id"], - ) - - -def downgrade() -> None: - # Drop indexes - op.drop_index("idx_product_supplier", table_name="products") - op.drop_index("idx_product_vendor_sku", table_name="products") - - # Drop new columns - op.drop_column("products", "margin_percent") - op.drop_column("products", "supplier_cost") - op.drop_column("products", "supplier_product_id") - op.drop_column("products", "supplier") - op.drop_column("products", "fulfillment_email_template") - op.drop_column("products", "license_type") - op.drop_column("products", "download_url") - op.drop_column("products", "additional_images") - op.drop_column("products", "primary_image_url") - op.drop_column("products", "brand") - - # Use batch mode for SQLite compatibility - with op.batch_alter_table("products", schema=None) as batch_op: - # Rename vendor_sku back to product_id - batch_op.alter_column( - "vendor_sku", - new_column_name="product_id", - ) diff --git a/alembic/versions/a9a86cef6cca_add_letzshop_order_locale_and_country_.py b/alembic/versions/a9a86cef6cca_add_letzshop_order_locale_and_country_.py deleted file mode 100644 index 731d6ae6..00000000 --- a/alembic/versions/a9a86cef6cca_add_letzshop_order_locale_and_country_.py +++ /dev/null @@ -1,31 +0,0 @@ -"""add_letzshop_order_locale_and_country_fields - -Revision ID: a9a86cef6cca -Revises: fcfdc02d5138 -Create Date: 2025-12-17 20:55:41.477848 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'a9a86cef6cca' -down_revision: Union[str, None] = 'fcfdc02d5138' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add new columns to letzshop_orders for customer locale and country - op.add_column('letzshop_orders', sa.Column('customer_locale', sa.String(length=10), nullable=True)) - op.add_column('letzshop_orders', sa.Column('shipping_country_iso', sa.String(length=5), nullable=True)) - op.add_column('letzshop_orders', sa.Column('billing_country_iso', sa.String(length=5), nullable=True)) - - -def downgrade() -> None: - op.drop_column('letzshop_orders', 'billing_country_iso') - op.drop_column('letzshop_orders', 'shipping_country_iso') - op.drop_column('letzshop_orders', 'customer_locale') diff --git a/alembic/versions/b412e0b49c2e_add_language_column_to_marketplace_.py b/alembic/versions/b412e0b49c2e_add_language_column_to_marketplace_.py deleted file mode 100644 index 1c1ca208..00000000 --- a/alembic/versions/b412e0b49c2e_add_language_column_to_marketplace_.py +++ /dev/null @@ -1,30 +0,0 @@ -"""add language column to marketplace_import_jobs - -Revision ID: b412e0b49c2e -Revises: 91d02647efae -Create Date: 2025-12-13 13:35:46.524893 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'b412e0b49c2e' -down_revision: Union[str, None] = '91d02647efae' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add language column with default value for existing rows - op.add_column( - 'marketplace_import_jobs', - sa.Column('language', sa.String(length=5), nullable=False, server_default='en') - ) - - -def downgrade() -> None: - op.drop_column('marketplace_import_jobs', 'language') diff --git a/alembic/versions/b4c5d6e7f8a9_migrate_product_data_to_translations.py b/alembic/versions/b4c5d6e7f8a9_migrate_product_data_to_translations.py deleted file mode 100644 index a597de9e..00000000 --- a/alembic/versions/b4c5d6e7f8a9_migrate_product_data_to_translations.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Migrate existing product data to translation tables - -Revision ID: b4c5d6e7f8a9 -Revises: a3b4c5d6e7f8 -Create Date: 2025-12-11 - -This migration: -1. Copies existing title/description from marketplace_products to - marketplace_product_translations (default language: 'en') -2. Parses existing price strings to numeric values -3. Removes the old title/description columns from marketplace_products - -Since we're not live yet, we can safely remove the old columns -after migrating the data to the new structure. -""" - -import re -from typing import Sequence, Union - -import sqlalchemy as sa -from sqlalchemy import text - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "b4c5d6e7f8a9" -down_revision: Union[str, None] = "a3b4c5d6e7f8" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def parse_price(price_str: str) -> float | None: - """Parse price string like '19.99 EUR' to float.""" - if not price_str: - return None - - # Extract numeric value - numbers = re.findall(r"[\d.,]+", str(price_str)) - if numbers: - num_str = numbers[0].replace(",", ".") - try: - return float(num_str) - except ValueError: - pass - return None - - -def upgrade() -> None: - conn = op.get_bind() - - # Step 1: Migrate existing title/description to translations table - # Default language is 'en' for existing data - conn.execute( - text(""" - INSERT INTO marketplace_product_translations - (marketplace_product_id, language, title, description, created_at, updated_at) - SELECT - id, - 'en', - title, - description, - created_at, - updated_at - FROM marketplace_products - WHERE title IS NOT NULL - """) - ) - - # Step 2: Parse prices to numeric values - # Get all marketplace products with prices - result = conn.execute( - text("SELECT id, price, sale_price FROM marketplace_products") - ) - - for row in result: - price_numeric = parse_price(row.price) if row.price else None - sale_price_numeric = parse_price(row.sale_price) if row.sale_price else None - - if price_numeric is not None or sale_price_numeric is not None: - conn.execute( - text(""" - UPDATE marketplace_products - SET price_numeric = :price_numeric, - sale_price_numeric = :sale_price_numeric - WHERE id = :id - """), - { - "id": row.id, - "price_numeric": price_numeric, - "sale_price_numeric": sale_price_numeric, - }, - ) - - # Step 3: Since we're not live, remove the old title/description columns - # from marketplace_products (data is now in translations table) - op.drop_column("marketplace_products", "title") - op.drop_column("marketplace_products", "description") - - -def downgrade() -> None: - # Re-add title and description columns - op.add_column( - "marketplace_products", - sa.Column("title", sa.String(), nullable=True), - ) - op.add_column( - "marketplace_products", - sa.Column("description", sa.String(), nullable=True), - ) - - # Copy data back from translations (only 'en' translations) - conn = op.get_bind() - conn.execute( - text(""" - UPDATE marketplace_products - SET title = ( - SELECT title FROM marketplace_product_translations - WHERE marketplace_product_translations.marketplace_product_id = marketplace_products.id - AND marketplace_product_translations.language = 'en' - ), - description = ( - SELECT description FROM marketplace_product_translations - WHERE marketplace_product_translations.marketplace_product_id = marketplace_products.id - AND marketplace_product_translations.language = 'en' - ) - """) - ) - - # Delete the migrated translations - conn.execute( - text("DELETE FROM marketplace_product_translations WHERE language = 'en'") - ) diff --git a/alembic/versions/ba2c0ce78396_add_show_in_legal_to_content_pages.py b/alembic/versions/ba2c0ce78396_add_show_in_legal_to_content_pages.py deleted file mode 100644 index bd241679..00000000 --- a/alembic/versions/ba2c0ce78396_add_show_in_legal_to_content_pages.py +++ /dev/null @@ -1,41 +0,0 @@ -"""add show_in_legal to content_pages - -Revision ID: ba2c0ce78396 -Revises: m1b2c3d4e5f6 -Create Date: 2025-12-28 20:00:24.263518 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'ba2c0ce78396' -down_revision: Union[str, None] = 'm1b2c3d4e5f6' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Add show_in_legal column to content_pages table. - - This column controls whether a page appears in the bottom bar - alongside the copyright notice (e.g., Privacy Policy, Terms of Service). - """ - op.add_column( - 'content_pages', - sa.Column('show_in_legal', sa.Boolean(), nullable=True, default=False) - ) - - # Set default value for existing rows (PostgreSQL uses true/false for boolean) - op.execute("UPDATE content_pages SET show_in_legal = false WHERE show_in_legal IS NULL") - - # Set privacy and terms pages to show in legal by default - op.execute("UPDATE content_pages SET show_in_legal = true WHERE slug IN ('privacy', 'terms')") - - -def downgrade() -> None: - """Remove show_in_legal column from content_pages table.""" - op.drop_column('content_pages', 'show_in_legal') diff --git a/alembic/versions/c00d2985701f_add_letzshop_credentials_carrier_fields.py b/alembic/versions/c00d2985701f_add_letzshop_credentials_carrier_fields.py deleted file mode 100644 index c4a9bab2..00000000 --- a/alembic/versions/c00d2985701f_add_letzshop_credentials_carrier_fields.py +++ /dev/null @@ -1,35 +0,0 @@ -"""add_letzshop_credentials_carrier_fields - -Revision ID: c00d2985701f -Revises: 55b92e155566 -Create Date: 2025-12-20 18:49:53.432904 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'c00d2985701f' -down_revision: Union[str, None] = '55b92e155566' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add carrier settings and test mode to vendor_letzshop_credentials - op.add_column('vendor_letzshop_credentials', sa.Column('test_mode_enabled', sa.Boolean(), nullable=True, server_default='0')) - op.add_column('vendor_letzshop_credentials', sa.Column('default_carrier', sa.String(length=50), nullable=True)) - op.add_column('vendor_letzshop_credentials', sa.Column('carrier_greco_label_url', sa.String(length=500), nullable=True, server_default='https://dispatchweb.fr/Tracky/Home/')) - op.add_column('vendor_letzshop_credentials', sa.Column('carrier_colissimo_label_url', sa.String(length=500), nullable=True)) - op.add_column('vendor_letzshop_credentials', sa.Column('carrier_xpresslogistics_label_url', sa.String(length=500), nullable=True)) - - -def downgrade() -> None: - op.drop_column('vendor_letzshop_credentials', 'carrier_xpresslogistics_label_url') - op.drop_column('vendor_letzshop_credentials', 'carrier_colissimo_label_url') - op.drop_column('vendor_letzshop_credentials', 'carrier_greco_label_url') - op.drop_column('vendor_letzshop_credentials', 'default_carrier') - op.drop_column('vendor_letzshop_credentials', 'test_mode_enabled') diff --git a/alembic/versions/c1d2e3f4a5b6_unified_order_schema.py b/alembic/versions/c1d2e3f4a5b6_unified_order_schema.py deleted file mode 100644 index 0f70e8fb..00000000 --- a/alembic/versions/c1d2e3f4a5b6_unified_order_schema.py +++ /dev/null @@ -1,452 +0,0 @@ -"""unified_order_schema - -Revision ID: c1d2e3f4a5b6 -Revises: 2362c2723a93 -Create Date: 2025-12-19 - -This migration implements the unified order schema: -- Removes the separate letzshop_orders table -- Enhances the orders table with: - - Customer/address snapshots (preserved at order time) - - External marketplace references - - Tracking provider field -- Enhances order_items with: - - GTIN fields - - External item references - - Item state for marketplace confirmation flow -- Updates letzshop_fulfillment_queue to reference orders table directly - -Design principles: -- Single orders table for all channels (direct, letzshop, etc.) -- Customer/address data snapshotted at order time -- Products must exist in catalog (enforced by FK) -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -# revision identifiers, used by Alembic. -revision: str = 'c1d2e3f4a5b6' -down_revision: Union[str, None] = '2362c2723a93' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def table_exists(table_name: str) -> bool: - """Check if a table exists in the database.""" - bind = op.get_bind() - inspector = inspect(bind) - return table_name in inspector.get_table_names() - - -def index_exists(index_name: str, table_name: str) -> bool: - """Check if an index exists on a table.""" - bind = op.get_bind() - inspector = inspect(bind) - try: - indexes = inspector.get_indexes(table_name) - return any(idx['name'] == index_name for idx in indexes) - except Exception: - return False - - -def safe_drop_index(index_name: str, table_name: str) -> None: - """Drop an index if it exists.""" - if index_exists(index_name, table_name): - op.drop_index(index_name, table_name=table_name) - - -def safe_drop_table(table_name: str) -> None: - """Drop a table if it exists.""" - if table_exists(table_name): - op.drop_table(table_name) - - -def upgrade() -> None: - # ========================================================================= - # Step 1: Drop old tables that will be replaced (if they exist) - # ========================================================================= - - # Drop letzshop_fulfillment_queue (references letzshop_orders) - if table_exists('letzshop_fulfillment_queue'): - safe_drop_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue') - safe_drop_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue') - safe_drop_index('ix_letzshop_fulfillment_queue_vendor_id', 'letzshop_fulfillment_queue') - safe_drop_index('ix_letzshop_fulfillment_queue_id', 'letzshop_fulfillment_queue') - op.drop_table('letzshop_fulfillment_queue') - - # Drop letzshop_orders table (replaced by unified orders) - if table_exists('letzshop_orders'): - safe_drop_index('idx_letzshop_order_sync', 'letzshop_orders') - safe_drop_index('idx_letzshop_order_state', 'letzshop_orders') - safe_drop_index('idx_letzshop_order_vendor', 'letzshop_orders') - safe_drop_index('ix_letzshop_orders_vendor_id', 'letzshop_orders') - safe_drop_index('ix_letzshop_orders_letzshop_shipment_id', 'letzshop_orders') - safe_drop_index('ix_letzshop_orders_letzshop_order_id', 'letzshop_orders') - safe_drop_index('ix_letzshop_orders_id', 'letzshop_orders') - op.drop_table('letzshop_orders') - - # Drop order_items (references orders) - if table_exists('order_items'): - safe_drop_index('ix_order_items_id', 'order_items') - safe_drop_index('ix_order_items_order_id', 'order_items') - op.drop_table('order_items') - - # Drop old orders table - if table_exists('orders'): - safe_drop_index('ix_orders_external_order_id', 'orders') - safe_drop_index('ix_orders_channel', 'orders') - safe_drop_index('ix_orders_vendor_id', 'orders') - safe_drop_index('ix_orders_status', 'orders') - safe_drop_index('ix_orders_order_number', 'orders') - safe_drop_index('ix_orders_id', 'orders') - safe_drop_index('ix_orders_customer_id', 'orders') - op.drop_table('orders') - - # ========================================================================= - # Step 2: Create new unified orders table - # ========================================================================= - op.create_table('orders', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('customer_id', sa.Integer(), nullable=False), - sa.Column('order_number', sa.String(length=100), nullable=False), - - # Channel/Source - sa.Column('channel', sa.String(length=50), nullable=False, server_default='direct'), - - # External references (for marketplace orders) - sa.Column('external_order_id', sa.String(length=100), nullable=True), - sa.Column('external_shipment_id', sa.String(length=100), nullable=True), - sa.Column('external_order_number', sa.String(length=100), nullable=True), - sa.Column('external_data', sa.JSON(), nullable=True), - - # Status - sa.Column('status', sa.String(length=50), nullable=False, server_default='pending'), - - # Financials - sa.Column('subtotal', sa.Float(), nullable=True), - sa.Column('tax_amount', sa.Float(), nullable=True), - sa.Column('shipping_amount', sa.Float(), nullable=True), - sa.Column('discount_amount', sa.Float(), nullable=True), - sa.Column('total_amount', sa.Float(), nullable=False), - sa.Column('currency', sa.String(length=10), server_default='EUR', nullable=True), - - # Customer snapshot - sa.Column('customer_first_name', sa.String(length=100), nullable=False), - sa.Column('customer_last_name', sa.String(length=100), nullable=False), - sa.Column('customer_email', sa.String(length=255), nullable=False), - sa.Column('customer_phone', sa.String(length=50), nullable=True), - sa.Column('customer_locale', sa.String(length=10), nullable=True), - - # Shipping address snapshot - sa.Column('ship_first_name', sa.String(length=100), nullable=False), - sa.Column('ship_last_name', sa.String(length=100), nullable=False), - sa.Column('ship_company', sa.String(length=200), nullable=True), - sa.Column('ship_address_line_1', sa.String(length=255), nullable=False), - sa.Column('ship_address_line_2', sa.String(length=255), nullable=True), - sa.Column('ship_city', sa.String(length=100), nullable=False), - sa.Column('ship_postal_code', sa.String(length=20), nullable=False), - sa.Column('ship_country_iso', sa.String(length=5), nullable=False), - - # Billing address snapshot - sa.Column('bill_first_name', sa.String(length=100), nullable=False), - sa.Column('bill_last_name', sa.String(length=100), nullable=False), - sa.Column('bill_company', sa.String(length=200), nullable=True), - sa.Column('bill_address_line_1', sa.String(length=255), nullable=False), - sa.Column('bill_address_line_2', sa.String(length=255), nullable=True), - sa.Column('bill_city', sa.String(length=100), nullable=False), - sa.Column('bill_postal_code', sa.String(length=20), nullable=False), - sa.Column('bill_country_iso', sa.String(length=5), nullable=False), - - # Tracking - sa.Column('shipping_method', sa.String(length=100), nullable=True), - sa.Column('tracking_number', sa.String(length=100), nullable=True), - sa.Column('tracking_provider', sa.String(length=100), nullable=True), - - # Notes - sa.Column('customer_notes', sa.Text(), nullable=True), - sa.Column('internal_notes', sa.Text(), nullable=True), - - # Timestamps - sa.Column('order_date', sa.DateTime(timezone=True), nullable=False), - sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('shipped_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('delivered_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('cancelled_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - - # Foreign keys - sa.ForeignKeyConstraint(['customer_id'], ['customers.id']), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.PrimaryKeyConstraint('id') - ) - - # Indexes for orders - op.create_index(op.f('ix_orders_id'), 'orders', ['id'], unique=False) - op.create_index(op.f('ix_orders_vendor_id'), 'orders', ['vendor_id'], unique=False) - op.create_index(op.f('ix_orders_customer_id'), 'orders', ['customer_id'], unique=False) - op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=True) - op.create_index(op.f('ix_orders_channel'), 'orders', ['channel'], unique=False) - op.create_index(op.f('ix_orders_status'), 'orders', ['status'], unique=False) - op.create_index(op.f('ix_orders_external_order_id'), 'orders', ['external_order_id'], unique=False) - op.create_index(op.f('ix_orders_external_shipment_id'), 'orders', ['external_shipment_id'], unique=False) - op.create_index('idx_order_vendor_status', 'orders', ['vendor_id', 'status'], unique=False) - op.create_index('idx_order_vendor_channel', 'orders', ['vendor_id', 'channel'], unique=False) - op.create_index('idx_order_vendor_date', 'orders', ['vendor_id', 'order_date'], unique=False) - - # ========================================================================= - # Step 3: Create new order_items table - # ========================================================================= - op.create_table('order_items', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('order_id', sa.Integer(), nullable=False), - sa.Column('product_id', sa.Integer(), nullable=False), - - # Product snapshot - sa.Column('product_name', sa.String(length=255), nullable=False), - sa.Column('product_sku', sa.String(length=100), nullable=True), - sa.Column('gtin', sa.String(length=50), nullable=True), - sa.Column('gtin_type', sa.String(length=20), nullable=True), - - # Pricing - sa.Column('quantity', sa.Integer(), nullable=False), - sa.Column('unit_price', sa.Float(), nullable=False), - sa.Column('total_price', sa.Float(), nullable=False), - - # External references (for marketplace items) - sa.Column('external_item_id', sa.String(length=100), nullable=True), - sa.Column('external_variant_id', sa.String(length=100), nullable=True), - - # Item state (for marketplace confirmation flow) - sa.Column('item_state', sa.String(length=50), nullable=True), - - # Inventory tracking - sa.Column('inventory_reserved', sa.Boolean(), server_default='0', nullable=True), - sa.Column('inventory_fulfilled', sa.Boolean(), server_default='0', nullable=True), - - # Timestamps - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - - # Foreign keys - sa.ForeignKeyConstraint(['order_id'], ['orders.id']), - sa.ForeignKeyConstraint(['product_id'], ['products.id']), - sa.PrimaryKeyConstraint('id') - ) - - # Indexes for order_items - op.create_index(op.f('ix_order_items_id'), 'order_items', ['id'], unique=False) - op.create_index(op.f('ix_order_items_order_id'), 'order_items', ['order_id'], unique=False) - op.create_index(op.f('ix_order_items_product_id'), 'order_items', ['product_id'], unique=False) - op.create_index(op.f('ix_order_items_gtin'), 'order_items', ['gtin'], unique=False) - - # ========================================================================= - # Step 4: Create updated letzshop_fulfillment_queue (references orders) - # ========================================================================= - op.create_table('letzshop_fulfillment_queue', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('order_id', sa.Integer(), nullable=False), - - # Operation type - sa.Column('operation', sa.String(length=50), nullable=False), - - # Operation payload - sa.Column('payload', sa.JSON(), nullable=False), - - # Status and retry - sa.Column('status', sa.String(length=50), server_default='pending', nullable=True), - sa.Column('attempts', sa.Integer(), server_default='0', nullable=True), - sa.Column('max_attempts', sa.Integer(), server_default='3', nullable=True), - sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - - # Response from Letzshop - sa.Column('response_data', sa.JSON(), nullable=True), - - # Timestamps - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - - # Foreign keys - sa.ForeignKeyConstraint(['order_id'], ['orders.id']), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.PrimaryKeyConstraint('id') - ) - - # Indexes for letzshop_fulfillment_queue - op.create_index(op.f('ix_letzshop_fulfillment_queue_id'), 'letzshop_fulfillment_queue', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), 'letzshop_fulfillment_queue', ['vendor_id'], unique=False) - op.create_index(op.f('ix_letzshop_fulfillment_queue_order_id'), 'letzshop_fulfillment_queue', ['order_id'], unique=False) - op.create_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue', ['status', 'vendor_id'], unique=False) - op.create_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue', ['status', 'next_retry_at'], unique=False) - op.create_index('idx_fulfillment_queue_order', 'letzshop_fulfillment_queue', ['order_id'], unique=False) - - -def downgrade() -> None: - # Drop new letzshop_fulfillment_queue - safe_drop_index('idx_fulfillment_queue_order', 'letzshop_fulfillment_queue') - safe_drop_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue') - safe_drop_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue') - safe_drop_index('ix_letzshop_fulfillment_queue_order_id', 'letzshop_fulfillment_queue') - safe_drop_index('ix_letzshop_fulfillment_queue_vendor_id', 'letzshop_fulfillment_queue') - safe_drop_index('ix_letzshop_fulfillment_queue_id', 'letzshop_fulfillment_queue') - safe_drop_table('letzshop_fulfillment_queue') - - # Drop new order_items - safe_drop_index('ix_order_items_gtin', 'order_items') - safe_drop_index('ix_order_items_product_id', 'order_items') - safe_drop_index('ix_order_items_order_id', 'order_items') - safe_drop_index('ix_order_items_id', 'order_items') - safe_drop_table('order_items') - - # Drop new orders - safe_drop_index('idx_order_vendor_date', 'orders') - safe_drop_index('idx_order_vendor_channel', 'orders') - safe_drop_index('idx_order_vendor_status', 'orders') - safe_drop_index('ix_orders_external_shipment_id', 'orders') - safe_drop_index('ix_orders_external_order_id', 'orders') - safe_drop_index('ix_orders_status', 'orders') - safe_drop_index('ix_orders_channel', 'orders') - safe_drop_index('ix_orders_order_number', 'orders') - safe_drop_index('ix_orders_customer_id', 'orders') - safe_drop_index('ix_orders_vendor_id', 'orders') - safe_drop_index('ix_orders_id', 'orders') - safe_drop_table('orders') - - # Recreate old orders table - op.create_table('orders', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('customer_id', sa.Integer(), nullable=False), - sa.Column('order_number', sa.String(), nullable=False), - sa.Column('channel', sa.String(length=50), nullable=True, server_default='direct'), - sa.Column('external_order_id', sa.String(length=100), nullable=True), - sa.Column('external_channel_data', sa.JSON(), nullable=True), - sa.Column('status', sa.String(), nullable=False), - sa.Column('subtotal', sa.Float(), nullable=False), - sa.Column('tax_amount', sa.Float(), nullable=True), - sa.Column('shipping_amount', sa.Float(), nullable=True), - sa.Column('discount_amount', sa.Float(), nullable=True), - sa.Column('total_amount', sa.Float(), nullable=False), - sa.Column('currency', sa.String(), nullable=True), - sa.Column('shipping_address_id', sa.Integer(), nullable=False), - sa.Column('billing_address_id', sa.Integer(), nullable=False), - sa.Column('shipping_method', sa.String(), nullable=True), - sa.Column('tracking_number', sa.String(), nullable=True), - sa.Column('customer_notes', sa.Text(), nullable=True), - sa.Column('internal_notes', sa.Text(), nullable=True), - sa.Column('paid_at', sa.DateTime(), nullable=True), - sa.Column('shipped_at', sa.DateTime(), nullable=True), - sa.Column('delivered_at', sa.DateTime(), nullable=True), - sa.Column('cancelled_at', sa.DateTime(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['billing_address_id'], ['customer_addresses.id']), - sa.ForeignKeyConstraint(['customer_id'], ['customers.id']), - sa.ForeignKeyConstraint(['shipping_address_id'], ['customer_addresses.id']), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_orders_customer_id'), 'orders', ['customer_id'], unique=False) - op.create_index(op.f('ix_orders_id'), 'orders', ['id'], unique=False) - op.create_index(op.f('ix_orders_order_number'), 'orders', ['order_number'], unique=True) - op.create_index(op.f('ix_orders_status'), 'orders', ['status'], unique=False) - op.create_index(op.f('ix_orders_vendor_id'), 'orders', ['vendor_id'], unique=False) - op.create_index(op.f('ix_orders_channel'), 'orders', ['channel'], unique=False) - op.create_index(op.f('ix_orders_external_order_id'), 'orders', ['external_order_id'], unique=False) - - # Recreate old order_items table - op.create_table('order_items', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('order_id', sa.Integer(), nullable=False), - sa.Column('product_id', sa.Integer(), nullable=False), - sa.Column('product_name', sa.String(), nullable=False), - sa.Column('product_sku', sa.String(), nullable=True), - sa.Column('quantity', sa.Integer(), nullable=False), - sa.Column('unit_price', sa.Float(), nullable=False), - sa.Column('total_price', sa.Float(), nullable=False), - sa.Column('inventory_reserved', sa.Boolean(), nullable=True), - sa.Column('inventory_fulfilled', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['order_id'], ['orders.id']), - sa.ForeignKeyConstraint(['product_id'], ['products.id']), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_order_items_id'), 'order_items', ['id'], unique=False) - op.create_index(op.f('ix_order_items_order_id'), 'order_items', ['order_id'], unique=False) - - # Recreate old letzshop_orders table - op.create_table('letzshop_orders', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('letzshop_order_id', sa.String(length=100), nullable=False), - sa.Column('letzshop_shipment_id', sa.String(length=100), nullable=True), - sa.Column('letzshop_order_number', sa.String(length=100), nullable=True), - sa.Column('local_order_id', sa.Integer(), nullable=True), - sa.Column('letzshop_state', sa.String(length=50), nullable=True), - sa.Column('customer_email', sa.String(length=255), nullable=True), - sa.Column('customer_name', sa.String(length=255), nullable=True), - sa.Column('total_amount', sa.String(length=50), nullable=True), - sa.Column('currency', sa.String(length=10), server_default='EUR', nullable=True), - sa.Column('customer_locale', sa.String(length=10), nullable=True), - sa.Column('shipping_country_iso', sa.String(length=5), nullable=True), - sa.Column('billing_country_iso', sa.String(length=5), nullable=True), - sa.Column('order_date', sa.DateTime(timezone=True), nullable=True), - sa.Column('raw_order_data', sa.JSON(), nullable=True), - sa.Column('inventory_units', sa.JSON(), nullable=True), - sa.Column('sync_status', sa.String(length=50), server_default='pending', nullable=True), - sa.Column('last_synced_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('sync_error', sa.Text(), nullable=True), - sa.Column('confirmed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('rejected_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('tracking_set_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('tracking_number', sa.String(length=100), nullable=True), - sa.Column('tracking_carrier', sa.String(length=100), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['local_order_id'], ['orders.id']), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_letzshop_orders_id'), 'letzshop_orders', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_orders_letzshop_order_id'), 'letzshop_orders', ['letzshop_order_id'], unique=False) - op.create_index(op.f('ix_letzshop_orders_letzshop_shipment_id'), 'letzshop_orders', ['letzshop_shipment_id'], unique=False) - op.create_index(op.f('ix_letzshop_orders_vendor_id'), 'letzshop_orders', ['vendor_id'], unique=False) - op.create_index('idx_letzshop_order_vendor', 'letzshop_orders', ['vendor_id', 'letzshop_order_id'], unique=False) - op.create_index('idx_letzshop_order_state', 'letzshop_orders', ['vendor_id', 'letzshop_state'], unique=False) - op.create_index('idx_letzshop_order_sync', 'letzshop_orders', ['vendor_id', 'sync_status'], unique=False) - - # Recreate old letzshop_fulfillment_queue table - op.create_table('letzshop_fulfillment_queue', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('letzshop_order_id', sa.Integer(), nullable=False), - sa.Column('operation', sa.String(length=50), nullable=False), - sa.Column('payload', sa.JSON(), nullable=False), - sa.Column('status', sa.String(length=50), server_default='pending', nullable=True), - sa.Column('attempts', sa.Integer(), server_default='0', nullable=True), - sa.Column('max_attempts', sa.Integer(), server_default='3', nullable=True), - sa.Column('last_attempt_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('next_retry_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('response_data', sa.JSON(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False), - sa.ForeignKeyConstraint(['letzshop_order_id'], ['letzshop_orders.id']), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_letzshop_fulfillment_queue_id'), 'letzshop_fulfillment_queue', ['id'], unique=False) - op.create_index(op.f('ix_letzshop_fulfillment_queue_vendor_id'), 'letzshop_fulfillment_queue', ['vendor_id'], unique=False) - op.create_index('idx_fulfillment_queue_status', 'letzshop_fulfillment_queue', ['status', 'vendor_id'], unique=False) - op.create_index('idx_fulfillment_queue_retry', 'letzshop_fulfillment_queue', ['status', 'next_retry_at'], unique=False) diff --git a/alembic/versions/c9e22eadf533_add_tax_rate_cost_and_letzshop_settings.py b/alembic/versions/c9e22eadf533_add_tax_rate_cost_and_letzshop_settings.py deleted file mode 100644 index 6f1dcc02..00000000 --- a/alembic/versions/c9e22eadf533_add_tax_rate_cost_and_letzshop_settings.py +++ /dev/null @@ -1,64 +0,0 @@ -"""add_tax_rate_cost_and_letzshop_settings - -Revision ID: c9e22eadf533 -Revises: e1f2a3b4c5d6 -Create Date: 2025-12-20 21:13:30.709696 - -Adds: -- tax_rate_percent to products and marketplace_products (NOT NULL, default 17) -- cost_cents to products (for profit calculation) -- Letzshop feed settings to vendors (tax_rate, boost_sort, delivery_method, preorder_days) -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'c9e22eadf533' -down_revision: Union[str, None] = 'e1f2a3b4c5d6' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # === MARKETPLACE PRODUCTS: Add tax_rate_percent === - with op.batch_alter_table('marketplace_products', schema=None) as batch_op: - batch_op.add_column(sa.Column('tax_rate_percent', sa.Integer(), nullable=False, server_default='17')) - - # === PRODUCTS: Add tax_rate_percent and cost_cents, rename supplier_cost_cents === - with op.batch_alter_table('products', schema=None) as batch_op: - batch_op.add_column(sa.Column('tax_rate_percent', sa.Integer(), nullable=False, server_default='17')) - batch_op.add_column(sa.Column('cost_cents', sa.Integer(), nullable=True)) - # Drop old supplier_cost_cents column (data migrated to cost_cents if needed) - try: - batch_op.drop_column('supplier_cost_cents') - except Exception: - pass # Column may not exist - - # === VENDORS: Add Letzshop feed settings === - with op.batch_alter_table('vendors', schema=None) as batch_op: - batch_op.add_column(sa.Column('letzshop_default_tax_rate', sa.Integer(), nullable=False, server_default='17')) - batch_op.add_column(sa.Column('letzshop_boost_sort', sa.String(length=10), nullable=True, server_default='5.0')) - batch_op.add_column(sa.Column('letzshop_delivery_method', sa.String(length=100), nullable=True, server_default='package_delivery')) - batch_op.add_column(sa.Column('letzshop_preorder_days', sa.Integer(), nullable=True, server_default='1')) - - -def downgrade() -> None: - # === VENDORS: Remove Letzshop feed settings === - with op.batch_alter_table('vendors', schema=None) as batch_op: - batch_op.drop_column('letzshop_preorder_days') - batch_op.drop_column('letzshop_delivery_method') - batch_op.drop_column('letzshop_boost_sort') - batch_op.drop_column('letzshop_default_tax_rate') - - # === PRODUCTS: Remove tax_rate_percent and cost_cents === - with op.batch_alter_table('products', schema=None) as batch_op: - batch_op.drop_column('cost_cents') - batch_op.drop_column('tax_rate_percent') - batch_op.add_column(sa.Column('supplier_cost_cents', sa.Integer(), nullable=True)) - - # === MARKETPLACE PRODUCTS: Remove tax_rate_percent === - with op.batch_alter_table('marketplace_products', schema=None) as batch_op: - batch_op.drop_column('tax_rate_percent') diff --git a/alembic/versions/cb88bc9b5f86_add_gtin_columns_to_product_table.py b/alembic/versions/cb88bc9b5f86_add_gtin_columns_to_product_table.py deleted file mode 100644 index faa6a494..00000000 --- a/alembic/versions/cb88bc9b5f86_add_gtin_columns_to_product_table.py +++ /dev/null @@ -1,37 +0,0 @@ -"""add_gtin_columns_to_product_table - -Revision ID: cb88bc9b5f86 -Revises: a9a86cef6cca -Create Date: 2025-12-18 20:54:55.185857 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'cb88bc9b5f86' -down_revision: Union[str, None] = 'a9a86cef6cca' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add GTIN (EAN/UPC barcode) columns to products table for order EAN matching - # gtin: The barcode number (e.g., "0889698273022") - # gtin_type: The format type from Letzshop (e.g., "gtin13", "gtin14", "isbn13") - op.add_column('products', sa.Column('gtin', sa.String(length=50), nullable=True)) - op.add_column('products', sa.Column('gtin_type', sa.String(length=20), nullable=True)) - - # Add index for EAN lookups during order matching - op.create_index('idx_product_gtin', 'products', ['gtin'], unique=False) - op.create_index('idx_product_vendor_gtin', 'products', ['vendor_id', 'gtin'], unique=False) - - -def downgrade() -> None: - op.drop_index('idx_product_vendor_gtin', table_name='products') - op.drop_index('idx_product_gtin', table_name='products') - op.drop_column('products', 'gtin_type') - op.drop_column('products', 'gtin') diff --git a/alembic/versions/core_001_initial.py b/alembic/versions/core_001_initial.py new file mode 100644 index 00000000..2ad2d5e5 --- /dev/null +++ b/alembic/versions/core_001_initial.py @@ -0,0 +1,333 @@ +"""core initial - tenancy, platform, admin tables + +Revision ID: core_001 +Revises: +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "core_001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- platforms --- + op.create_table( + "platforms", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("code", sa.String(50), unique=True, nullable=False, index=True, comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')"), + sa.Column("name", sa.String(100), nullable=False, comment="Display name (e.g., 'Wizamart OMS')"), + sa.Column("description", sa.Text(), nullable=True, comment="Platform description for admin/marketing purposes"), + sa.Column("domain", sa.String(255), unique=True, nullable=True, index=True, comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')"), + sa.Column("path_prefix", sa.String(50), unique=True, nullable=True, index=True, comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)"), + sa.Column("logo", sa.String(500), nullable=True, comment="Logo URL for light mode"), + sa.Column("logo_dark", sa.String(500), nullable=True, comment="Logo URL for dark mode"), + sa.Column("favicon", sa.String(500), nullable=True, comment="Favicon URL"), + sa.Column("theme_config", sa.JSON(), nullable=True, server_default="{}", comment="Theme configuration (colors, fonts, etc.)"), + sa.Column("default_language", sa.String(5), nullable=False, server_default="fr", comment="Default language code (e.g., 'fr', 'en', 'de')"), + sa.Column("supported_languages", sa.JSON(), nullable=False, comment="List of supported language codes"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the platform is active and accessible"), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default="true", comment="Whether the platform is visible in public listings"), + sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific settings and feature flags"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_platform_active", "platforms", ["is_active"]) + op.create_index("idx_platform_public", "platforms", ["is_public", "is_active"]) + + # --- users --- + op.create_table( + "users", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("email", sa.String(), unique=True, nullable=False, index=True), + sa.Column("username", sa.String(), unique=True, nullable=False, index=True), + sa.Column("first_name", sa.String(), nullable=True), + sa.Column("last_name", sa.String(), nullable=True), + sa.Column("hashed_password", sa.String(), nullable=False), + sa.Column("role", sa.String(), nullable=False, server_default="store"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("is_email_verified", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("is_super_admin", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("preferred_language", sa.String(5), nullable=True), + sa.Column("last_login", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- merchants --- + op.create_table( + "merchants", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("name", sa.String(), nullable=False, index=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("owner_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("contact_email", sa.String(), nullable=False), + sa.Column("contact_phone", sa.String(), nullable=True), + sa.Column("website", sa.String(), nullable=True), + sa.Column("business_address", sa.Text(), nullable=True), + sa.Column("tax_number", sa.String(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- stores --- + op.create_table( + "stores", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=False, index=True), + sa.Column("store_code", sa.String(), unique=True, nullable=False, index=True), + sa.Column("subdomain", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("letzshop_csv_url_fr", sa.String(), nullable=True), + sa.Column("letzshop_csv_url_en", sa.String(), nullable=True), + sa.Column("letzshop_csv_url_de", sa.String(), nullable=True), + sa.Column("letzshop_store_id", sa.String(100), unique=True, nullable=True, index=True), + sa.Column("letzshop_store_slug", sa.String(200), nullable=True, index=True), + sa.Column("letzshop_default_tax_rate", sa.Integer(), nullable=False, server_default="17"), + sa.Column("letzshop_boost_sort", sa.String(10), nullable=True, server_default="5.0"), + sa.Column("letzshop_delivery_method", sa.String(100), nullable=True, server_default="package_delivery"), + sa.Column("letzshop_preorder_days", sa.Integer(), nullable=True, server_default="1"), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="true"), + sa.Column("is_verified", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("contact_email", sa.String(255), nullable=True), + sa.Column("contact_phone", sa.String(50), nullable=True), + sa.Column("website", sa.String(255), nullable=True), + sa.Column("business_address", sa.Text(), nullable=True), + sa.Column("tax_number", sa.String(100), nullable=True), + sa.Column("default_language", sa.String(5), nullable=False, server_default="fr"), + sa.Column("dashboard_language", sa.String(5), nullable=False, server_default="fr"), + sa.Column("storefront_language", sa.String(5), nullable=False, server_default="fr"), + sa.Column("storefront_languages", sa.JSON(), nullable=False), + sa.Column("storefront_locale", sa.String(10), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- roles --- + op.create_table( + "roles", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("permissions", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- store_users --- + op.create_table( + "store_users", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("user_type", sa.String(), nullable=False, server_default="member"), + sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=True), + sa.Column("invited_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("invitation_token", sa.String(), nullable=True, index=True), + sa.Column("invitation_sent_at", sa.DateTime(), nullable=True), + sa.Column("invitation_accepted_at", sa.DateTime(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- store_domains --- + op.create_table( + "store_domains", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=False), + sa.Column("domain", sa.String(255), unique=True, nullable=False, index=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("ssl_status", sa.String(50), nullable=True, server_default="pending"), + sa.Column("ssl_verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("verification_token", sa.String(100), unique=True, nullable=True), + sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("store_id", "domain", name="uq_store_domain"), + ) + op.create_index("idx_domain_active", "store_domains", ["domain", "is_active"]) + op.create_index("idx_store_domain_primary", "store_domains", ["store_id", "is_primary"]) + + # --- admin_platforms --- + op.create_table( + "admin_platforms", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the admin user"), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the admin assignment is active"), + sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the admin was assigned to this platform"), + sa.Column("assigned_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="Super admin who made this assignment"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("user_id", "platform_id", name="uq_admin_platform"), + ) + op.create_index("idx_admin_platform_active", "admin_platforms", ["user_id", "platform_id", "is_active"]) + op.create_index("idx_admin_platform_user_active", "admin_platforms", ["user_id", "is_active"]) + + # --- platform_modules --- + op.create_table( + "platform_modules", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, comment="Platform this module configuration belongs to"), + sa.Column("module_code", sa.String(50), nullable=False, comment="Module code (e.g., 'billing', 'inventory', 'orders')"), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="true", comment="Whether this module is currently enabled for the platform"), + sa.Column("enabled_at", sa.DateTime(timezone=True), nullable=True, comment="When the module was last enabled"), + sa.Column("enabled_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="User who enabled the module"), + sa.Column("disabled_at", sa.DateTime(timezone=True), nullable=True, comment="When the module was last disabled"), + sa.Column("disabled_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="User who disabled the module"), + sa.Column("config", sa.JSON(), nullable=False, server_default="{}", comment="Module-specific configuration for this platform"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("platform_id", "module_code", name="uq_platform_module"), + ) + op.create_index("idx_platform_module_platform_id", "platform_modules", ["platform_id"]) + op.create_index("idx_platform_module_code", "platform_modules", ["module_code"]) + op.create_index("idx_platform_module_enabled", "platform_modules", ["platform_id", "is_enabled"]) + + # --- admin_audit_logs --- + op.create_table( + "admin_audit_logs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("admin_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("action", sa.String(100), nullable=False, index=True), + sa.Column("target_type", sa.String(50), nullable=False, index=True), + sa.Column("target_id", sa.String(100), nullable=False, index=True), + sa.Column("details", sa.JSON(), nullable=True), + sa.Column("ip_address", sa.String(45), nullable=True), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column("request_id", sa.String(100), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- admin_settings --- + op.create_table( + "admin_settings", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("key", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("value", sa.Text(), nullable=False), + sa.Column("value_type", sa.String(20), nullable=True, server_default="string"), + sa.Column("category", sa.String(50), nullable=True, index=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("is_encrypted", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("is_public", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("last_modified_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- platform_alerts --- + op.create_table( + "platform_alerts", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("alert_type", sa.String(50), nullable=False, index=True), + sa.Column("severity", sa.String(20), nullable=False, index=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("affected_stores", sa.JSON(), nullable=True), + sa.Column("affected_systems", sa.JSON(), nullable=True), + sa.Column("is_resolved", sa.Boolean(), nullable=True, server_default="false", index=True), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.Column("resolved_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("resolution_notes", sa.Text(), nullable=True), + sa.Column("auto_generated", sa.Boolean(), nullable=True, server_default="true"), + sa.Column("occurrence_count", sa.Integer(), nullable=True, server_default="1"), + sa.Column("first_occurred_at", sa.DateTime(), nullable=False), + sa.Column("last_occurred_at", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- admin_sessions --- + op.create_table( + "admin_sessions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("admin_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False, index=True), + sa.Column("session_token", sa.String(255), unique=True, nullable=False, index=True), + sa.Column("ip_address", sa.String(45), nullable=False), + sa.Column("user_agent", sa.Text(), nullable=True), + sa.Column("login_at", sa.DateTime(), nullable=False, index=True), + sa.Column("last_activity_at", sa.DateTime(), nullable=False), + sa.Column("logout_at", sa.DateTime(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="true", index=True), + sa.Column("logout_reason", sa.String(50), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- application_logs --- + op.create_table( + "application_logs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("timestamp", sa.DateTime(), nullable=False, index=True), + sa.Column("level", sa.String(20), nullable=False, index=True), + sa.Column("logger_name", sa.String(200), nullable=False, index=True), + sa.Column("module", sa.String(200), nullable=True), + sa.Column("function_name", sa.String(100), nullable=True), + sa.Column("line_number", sa.Integer(), nullable=True), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("exception_type", sa.String(200), nullable=True), + sa.Column("exception_message", sa.Text(), nullable=True), + sa.Column("stack_trace", sa.Text(), nullable=True), + sa.Column("request_id", sa.String(100), nullable=True, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True, index=True), + sa.Column("context", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- admin_menu_configs --- + op.create_table( + "admin_menu_configs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("frontend_type", sa.Enum("platform", "admin", "store", "storefront", "merchant", name="frontendtype"), nullable=False, index=True, comment="Which frontend this config applies to (admin or store)"), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=True, index=True, comment="Platform scope - applies to users/stores of this platform"), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True, comment="User scope - applies to this specific super admin (admin frontend only)"), + sa.Column("menu_item_id", sa.String(50), nullable=False, index=True, comment="Menu item identifier from registry (e.g., 'products', 'inventory')"), + sa.Column("is_visible", sa.Boolean(), nullable=False, server_default="true", comment="Whether this menu item is visible (False = hidden)"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("frontend_type", "platform_id", "menu_item_id", name="uq_frontend_platform_menu_config"), + sa.UniqueConstraint("frontend_type", "user_id", "menu_item_id", name="uq_frontend_user_menu_config"), + sa.CheckConstraint( + "(platform_id IS NOT NULL AND user_id IS NULL) OR (platform_id IS NULL AND user_id IS NOT NULL)", + name="ck_admin_menu_config_scope", + ), + sa.CheckConstraint( + "(user_id IS NULL) OR (frontend_type = 'admin')", + name="ck_user_scope_admin_only", + ), + ) + op.create_index("idx_admin_menu_config_frontend_platform", "admin_menu_configs", ["frontend_type", "platform_id"]) + op.create_index("idx_admin_menu_config_frontend_user", "admin_menu_configs", ["frontend_type", "user_id"]) + op.create_index("idx_admin_menu_config_platform_visible", "admin_menu_configs", ["platform_id", "is_visible"]) + op.create_index("idx_admin_menu_config_user_visible", "admin_menu_configs", ["user_id", "is_visible"]) + + +def downgrade() -> None: + op.drop_table("admin_menu_configs") + op.drop_table("application_logs") + op.drop_table("admin_sessions") + op.drop_table("platform_alerts") + op.drop_table("admin_settings") + op.drop_table("admin_audit_logs") + op.drop_table("platform_modules") + op.drop_table("admin_platforms") + op.drop_table("store_domains") + op.drop_table("store_users") + op.drop_table("roles") + op.drop_table("stores") + op.drop_table("merchants") + op.drop_table("users") + op.drop_table("platforms") + sa.Enum(name="frontendtype").drop(op.get_bind(), checkfirst=True) diff --git a/alembic/versions/d0325d7c0f25_add_companies_table_and_restructure_.py b/alembic/versions/d0325d7c0f25_add_companies_table_and_restructure_.py deleted file mode 100644 index ceb0bba0..00000000 --- a/alembic/versions/d0325d7c0f25_add_companies_table_and_restructure_.py +++ /dev/null @@ -1,77 +0,0 @@ -"""add_companies_table_and_restructure_vendors - -Revision ID: d0325d7c0f25 -Revises: 0bd9ffaaced1 -Create Date: 2025-11-30 14:58:17.165142 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'd0325d7c0f25' -down_revision: Union[str, None] = '0bd9ffaaced1' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create companies table - op.create_table( - 'companies', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('name', sa.String(), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('owner_user_id', sa.Integer(), nullable=False), - sa.Column('contact_email', sa.String(), nullable=False), - sa.Column('contact_phone', sa.String(), nullable=True), - sa.Column('website', sa.String(), nullable=True), - sa.Column('business_address', sa.Text(), nullable=True), - sa.Column('tax_number', sa.String(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'), - sa.Column('is_verified', sa.Boolean(), nullable=False, server_default='false'), - sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()), - sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), - sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_companies_id'), 'companies', ['id'], unique=False) - op.create_index(op.f('ix_companies_name'), 'companies', ['name'], unique=False) - - # Use batch mode for SQLite to modify vendors table - with op.batch_alter_table('vendors', schema=None) as batch_op: - # Add company_id column - batch_op.add_column(sa.Column('company_id', sa.Integer(), nullable=True)) - batch_op.create_index(batch_op.f('ix_vendors_company_id'), ['company_id'], unique=False) - batch_op.create_foreign_key('fk_vendors_company_id', 'companies', ['company_id'], ['id']) - - # Remove old contact fields - batch_op.drop_column('contact_email') - batch_op.drop_column('contact_phone') - batch_op.drop_column('website') - batch_op.drop_column('business_address') - batch_op.drop_column('tax_number') - - -def downgrade() -> None: - # Use batch mode for SQLite to modify vendors table - with op.batch_alter_table('vendors', schema=None) as batch_op: - # Re-add contact fields to vendors - batch_op.add_column(sa.Column('tax_number', sa.String(), nullable=True)) - batch_op.add_column(sa.Column('business_address', sa.Text(), nullable=True)) - batch_op.add_column(sa.Column('website', sa.String(), nullable=True)) - batch_op.add_column(sa.Column('contact_phone', sa.String(), nullable=True)) - batch_op.add_column(sa.Column('contact_email', sa.String(), nullable=True)) - - # Remove company_id from vendors - batch_op.drop_constraint('fk_vendors_company_id', type_='foreignkey') - batch_op.drop_index(batch_op.f('ix_vendors_company_id')) - batch_op.drop_column('company_id') - - # Drop companies table - op.drop_index(op.f('ix_companies_name'), table_name='companies') - op.drop_index(op.f('ix_companies_id'), table_name='companies') - op.drop_table('companies') diff --git a/alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py b/alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py deleted file mode 100644 index 3de56663..00000000 --- a/alembic/versions/d2e3f4a5b6c7_add_order_item_exceptions.py +++ /dev/null @@ -1,179 +0,0 @@ -"""add_order_item_exceptions - -Revision ID: d2e3f4a5b6c7 -Revises: c1d2e3f4a5b6 -Create Date: 2025-12-20 - -This migration adds the Order Item Exception system: -- Adds needs_product_match column to order_items table -- Creates order_item_exceptions table for tracking unmatched products - -The exception system allows marketplace orders to be imported even when -products are not found by GTIN. Items are linked to a placeholder product -and exceptions are tracked for QC resolution. -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -# revision identifiers, used by Alembic. -revision: str = 'd2e3f4a5b6c7' -down_revision: Union[str, None] = 'c1d2e3f4a5b6' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def column_exists(table_name: str, column_name: str) -> bool: - """Check if a column exists in a table.""" - bind = op.get_bind() - inspector = inspect(bind) - columns = [col['name'] for col in inspector.get_columns(table_name)] - return column_name in columns - - -def table_exists(table_name: str) -> bool: - """Check if a table exists in the database.""" - bind = op.get_bind() - inspector = inspect(bind) - return table_name in inspector.get_table_names() - - -def index_exists(index_name: str, table_name: str) -> bool: - """Check if an index exists on a table.""" - bind = op.get_bind() - inspector = inspect(bind) - try: - indexes = inspector.get_indexes(table_name) - return any(idx['name'] == index_name for idx in indexes) - except Exception: - return False - - -def upgrade() -> None: - # ========================================================================= - # Step 1: Add needs_product_match column to order_items - # ========================================================================= - if not column_exists('order_items', 'needs_product_match'): - op.add_column( - 'order_items', - sa.Column( - 'needs_product_match', - sa.Boolean(), - server_default='0', - nullable=False - ) - ) - - if not index_exists('ix_order_items_needs_product_match', 'order_items'): - op.create_index( - 'ix_order_items_needs_product_match', - 'order_items', - ['needs_product_match'] - ) - - # ========================================================================= - # Step 2: Create order_item_exceptions table - # ========================================================================= - if not table_exists('order_item_exceptions'): - op.create_table( - 'order_item_exceptions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('order_item_id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - sa.Column('original_gtin', sa.String(length=50), nullable=True), - sa.Column('original_product_name', sa.String(length=500), nullable=True), - sa.Column('original_sku', sa.String(length=100), nullable=True), - sa.Column( - 'exception_type', - sa.String(length=50), - nullable=False, - server_default='product_not_found' - ), - sa.Column( - 'status', - sa.String(length=50), - nullable=False, - server_default='pending' - ), - sa.Column('resolved_product_id', sa.Integer(), nullable=True), - sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('resolved_by', sa.Integer(), nullable=True), - sa.Column('resolution_notes', sa.Text(), nullable=True), - sa.Column( - 'created_at', - sa.DateTime(timezone=True), - server_default=sa.text('(CURRENT_TIMESTAMP)'), - nullable=False - ), - sa.Column( - 'updated_at', - sa.DateTime(timezone=True), - server_default=sa.text('(CURRENT_TIMESTAMP)'), - nullable=False - ), - sa.ForeignKeyConstraint( - ['order_item_id'], - ['order_items.id'], - ondelete='CASCADE' - ), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.ForeignKeyConstraint(['resolved_product_id'], ['products.id']), - sa.ForeignKeyConstraint(['resolved_by'], ['users.id']), - sa.PrimaryKeyConstraint('id') - ) - - # Create indexes - op.create_index( - 'ix_order_item_exceptions_id', - 'order_item_exceptions', - ['id'] - ) - op.create_index( - 'ix_order_item_exceptions_vendor_id', - 'order_item_exceptions', - ['vendor_id'] - ) - op.create_index( - 'ix_order_item_exceptions_status', - 'order_item_exceptions', - ['status'] - ) - op.create_index( - 'idx_exception_vendor_status', - 'order_item_exceptions', - ['vendor_id', 'status'] - ) - op.create_index( - 'idx_exception_gtin', - 'order_item_exceptions', - ['vendor_id', 'original_gtin'] - ) - - # Unique constraint on order_item_id (one exception per item) - op.create_index( - 'uq_order_item_exception', - 'order_item_exceptions', - ['order_item_id'], - unique=True - ) - - -def downgrade() -> None: - # Drop order_item_exceptions table - if table_exists('order_item_exceptions'): - op.drop_index('uq_order_item_exception', table_name='order_item_exceptions') - op.drop_index('idx_exception_gtin', table_name='order_item_exceptions') - op.drop_index('idx_exception_vendor_status', table_name='order_item_exceptions') - op.drop_index('ix_order_item_exceptions_status', table_name='order_item_exceptions') - op.drop_index('ix_order_item_exceptions_vendor_id', table_name='order_item_exceptions') - op.drop_index('ix_order_item_exceptions_id', table_name='order_item_exceptions') - op.drop_table('order_item_exceptions') - - # Remove needs_product_match column from order_items - if column_exists('order_items', 'needs_product_match'): - if index_exists('ix_order_items_needs_product_match', 'order_items'): - op.drop_index('ix_order_items_needs_product_match', table_name='order_items') - op.drop_column('order_items', 'needs_product_match') diff --git a/alembic/versions/d7a4a3f06394_add_email_templates_and_logs_tables.py b/alembic/versions/d7a4a3f06394_add_email_templates_and_logs_tables.py deleted file mode 100644 index e3dfe996..00000000 --- a/alembic/versions/d7a4a3f06394_add_email_templates_and_logs_tables.py +++ /dev/null @@ -1,332 +0,0 @@ -"""add email templates and logs tables - -Revision ID: d7a4a3f06394 -Revises: 404b3e2d2865 -Create Date: 2025-12-27 20:48:00.661523 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import text - - -# revision identifiers, used by Alembic. -revision: str = 'd7a4a3f06394' -down_revision: Union[str, None] = '404b3e2d2865' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create email_templates table - op.create_table('email_templates', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('code', sa.String(length=100), nullable=False), - sa.Column('language', sa.String(length=5), nullable=False), - sa.Column('name', sa.String(length=255), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('category', sa.String(length=50), nullable=False), - sa.Column('subject', sa.String(length=500), nullable=False), - sa.Column('body_html', sa.Text(), nullable=False), - sa.Column('body_text', sa.Text(), nullable=True), - sa.Column('variables', sa.Text(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_email_templates_category'), 'email_templates', ['category'], unique=False) - op.create_index(op.f('ix_email_templates_code'), 'email_templates', ['code'], unique=False) - op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False) - - # Create email_logs table - op.create_table('email_logs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('template_code', sa.String(length=100), nullable=True), - sa.Column('template_id', sa.Integer(), nullable=True), - sa.Column('recipient_email', sa.String(length=255), nullable=False), - sa.Column('recipient_name', sa.String(length=255), nullable=True), - sa.Column('subject', sa.String(length=500), nullable=False), - sa.Column('body_html', sa.Text(), nullable=True), - sa.Column('body_text', sa.Text(), nullable=True), - sa.Column('from_email', sa.String(length=255), nullable=False), - sa.Column('from_name', sa.String(length=255), nullable=True), - sa.Column('reply_to', sa.String(length=255), nullable=True), - sa.Column('status', sa.String(length=20), nullable=False), - sa.Column('sent_at', sa.DateTime(), nullable=True), - sa.Column('delivered_at', sa.DateTime(), nullable=True), - sa.Column('opened_at', sa.DateTime(), nullable=True), - sa.Column('clicked_at', sa.DateTime(), nullable=True), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('retry_count', sa.Integer(), nullable=False), - sa.Column('provider', sa.String(length=50), nullable=True), - sa.Column('provider_message_id', sa.String(length=255), nullable=True), - sa.Column('vendor_id', sa.Integer(), nullable=True), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('related_type', sa.String(length=50), nullable=True), - sa.Column('related_id', sa.Integer(), nullable=True), - sa.Column('extra_data', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['template_id'], ['email_templates.id']), - sa.ForeignKeyConstraint(['user_id'], ['users.id']), - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id']), - sa.PrimaryKeyConstraint('id') - ) - op.create_index(op.f('ix_email_logs_id'), 'email_logs', ['id'], unique=False) - op.create_index(op.f('ix_email_logs_provider_message_id'), 'email_logs', ['provider_message_id'], unique=False) - op.create_index(op.f('ix_email_logs_recipient_email'), 'email_logs', ['recipient_email'], unique=False) - op.create_index(op.f('ix_email_logs_status'), 'email_logs', ['status'], unique=False) - op.create_index(op.f('ix_email_logs_template_code'), 'email_logs', ['template_code'], unique=False) - op.create_index(op.f('ix_email_logs_user_id'), 'email_logs', ['user_id'], unique=False) - op.create_index(op.f('ix_email_logs_vendor_id'), 'email_logs', ['vendor_id'], unique=False) - - # application_logs - alter columns - op.alter_column('application_logs', 'created_at', existing_type=sa.DATETIME(), nullable=False) - op.alter_column('application_logs', 'updated_at', existing_type=sa.DATETIME(), nullable=False) - - # capacity_snapshots indexes (PostgreSQL IF EXISTS/IF NOT EXISTS) - op.execute(text("DROP INDEX IF EXISTS ix_capacity_snapshots_date")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_capacity_snapshots_date ON capacity_snapshots (snapshot_date)")) - op.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_capacity_snapshots_snapshot_date ON capacity_snapshots (snapshot_date)")) - - # cart_items - alter columns - op.alter_column('cart_items', 'created_at', existing_type=sa.DATETIME(), nullable=False) - op.alter_column('cart_items', 'updated_at', existing_type=sa.DATETIME(), nullable=False) - - # customer_addresses index rename - op.execute(text("DROP INDEX IF EXISTS ix_customers_addresses_id")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_customer_addresses_id ON customer_addresses (id)")) - - # inventory - alter columns and constraints - op.alter_column('inventory', 'warehouse', existing_type=sa.VARCHAR(), nullable=False) - op.alter_column('inventory', 'bin_location', existing_type=sa.VARCHAR(), nullable=False) - op.alter_column('inventory', 'location', existing_type=sa.VARCHAR(), nullable=True) - op.execute(text("DROP INDEX IF EXISTS idx_inventory_product_location")) - op.execute(text("ALTER TABLE inventory DROP CONSTRAINT IF EXISTS uq_inventory_product_location")) - op.execute(text(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_inventory_product_warehouse_bin') THEN - ALTER TABLE inventory ADD CONSTRAINT uq_inventory_product_warehouse_bin UNIQUE (product_id, warehouse, bin_location); - END IF; - END $$; - """)) - - # marketplace_import_errors and translations indexes - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_import_errors_import_job_id ON marketplace_import_errors (import_job_id)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_product_translations_id ON marketplace_product_translations (id)")) - - # marketplace_products - alter columns - op.alter_column('marketplace_products', 'is_digital', existing_type=sa.BOOLEAN(), nullable=True) - op.alter_column('marketplace_products', 'is_active', existing_type=sa.BOOLEAN(), nullable=True) - - # marketplace_products indexes - op.execute(text("DROP INDEX IF EXISTS idx_mp_is_active")) - op.execute(text("DROP INDEX IF EXISTS idx_mp_platform")) - op.execute(text("DROP INDEX IF EXISTS idx_mp_sku")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_products_is_active ON marketplace_products (is_active)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_products_is_digital ON marketplace_products (is_digital)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_products_mpn ON marketplace_products (mpn)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_products_platform ON marketplace_products (platform)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_marketplace_products_sku ON marketplace_products (sku)")) - - # order_item_exceptions - constraints and indexes - op.execute(text("DROP INDEX IF EXISTS uq_order_item_exception")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_order_item_exceptions_original_gtin ON order_item_exceptions (original_gtin)")) - op.execute(text(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_order_item_exceptions_order_item_id') THEN - ALTER TABLE order_item_exceptions ADD CONSTRAINT uq_order_item_exceptions_order_item_id UNIQUE (order_item_id); - END IF; - END $$; - """)) - - # order_items - alter column - op.alter_column('order_items', 'needs_product_match', existing_type=sa.BOOLEAN(), nullable=True) - - # order_items indexes - op.execute(text("DROP INDEX IF EXISTS ix_order_items_gtin")) - op.execute(text("DROP INDEX IF EXISTS ix_order_items_product_id")) - - # product_translations index - op.execute(text("CREATE INDEX IF NOT EXISTS ix_product_translations_id ON product_translations (id)")) - - # products indexes - op.execute(text("DROP INDEX IF EXISTS idx_product_active")) - op.execute(text("DROP INDEX IF EXISTS idx_product_featured")) - op.execute(text("DROP INDEX IF EXISTS idx_product_gtin")) - op.execute(text("DROP INDEX IF EXISTS idx_product_vendor_gtin")) - - # products constraint - op.execute(text("ALTER TABLE products DROP CONSTRAINT IF EXISTS uq_product")) - op.execute(text(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_vendor_marketplace_product') THEN - ALTER TABLE products ADD CONSTRAINT uq_vendor_marketplace_product UNIQUE (vendor_id, marketplace_product_id); - END IF; - END $$; - """)) - - # products new indexes - op.execute(text("CREATE INDEX IF NOT EXISTS idx_product_vendor_active ON products (vendor_id, is_active)")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_product_vendor_featured ON products (vendor_id, is_featured)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_products_gtin ON products (gtin)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_products_vendor_sku ON products (vendor_sku)")) - - # vendor_domains indexes - op.execute(text("DROP INDEX IF EXISTS ix_vendors_domains_domain")) - op.execute(text("DROP INDEX IF EXISTS ix_vendors_domains_id")) - op.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_vendor_domains_domain ON vendor_domains (domain)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendor_domains_id ON vendor_domains (id)")) - - # vendor_subscriptions - alter column and FK - op.alter_column('vendor_subscriptions', 'payment_retry_count', existing_type=sa.INTEGER(), nullable=False) - op.execute(text(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_vendor_subscriptions_tier_id') THEN - ALTER TABLE vendor_subscriptions ADD CONSTRAINT fk_vendor_subscriptions_tier_id - FOREIGN KEY (tier_id) REFERENCES subscription_tiers(id); - END IF; - END $$; - """)) - - # vendor_themes indexes - op.execute(text("DROP INDEX IF EXISTS ix_vendors_themes_id")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendor_themes_id ON vendor_themes (id)")) - - # vendor_users indexes - op.execute(text("DROP INDEX IF EXISTS ix_vendors_users_id")) - op.execute(text("DROP INDEX IF EXISTS ix_vendors_users_invitation_token")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendor_users_id ON vendor_users (id)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendor_users_invitation_token ON vendor_users (invitation_token)")) - - # vendors - alter column - op.alter_column('vendors', 'company_id', existing_type=sa.INTEGER(), nullable=False) - - -def downgrade() -> None: - # vendors - op.alter_column('vendors', 'company_id', existing_type=sa.INTEGER(), nullable=True) - - # vendor_users indexes - op.execute(text("DROP INDEX IF EXISTS ix_vendor_users_invitation_token")) - op.execute(text("DROP INDEX IF EXISTS ix_vendor_users_id")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendors_users_invitation_token ON vendor_users (invitation_token)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendors_users_id ON vendor_users (id)")) - - # vendor_themes indexes - op.execute(text("DROP INDEX IF EXISTS ix_vendor_themes_id")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendors_themes_id ON vendor_themes (id)")) - - # vendor_subscriptions - op.execute(text("ALTER TABLE vendor_subscriptions DROP CONSTRAINT IF EXISTS fk_vendor_subscriptions_tier_id")) - op.alter_column('vendor_subscriptions', 'payment_retry_count', existing_type=sa.INTEGER(), nullable=True) - - # vendor_domains indexes - op.execute(text("DROP INDEX IF EXISTS ix_vendor_domains_id")) - op.execute(text("DROP INDEX IF EXISTS ix_vendor_domains_domain")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_vendors_domains_id ON vendor_domains (id)")) - op.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_vendors_domains_domain ON vendor_domains (domain)")) - - # products constraint and indexes - op.execute(text("ALTER TABLE products DROP CONSTRAINT IF EXISTS uq_vendor_marketplace_product")) - op.execute(text(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_product') THEN - ALTER TABLE products ADD CONSTRAINT uq_product UNIQUE (vendor_id, marketplace_product_id); - END IF; - END $$; - """)) - - op.execute(text("DROP INDEX IF EXISTS ix_products_vendor_sku")) - op.execute(text("DROP INDEX IF EXISTS ix_products_gtin")) - op.execute(text("DROP INDEX IF EXISTS idx_product_vendor_featured")) - op.execute(text("DROP INDEX IF EXISTS idx_product_vendor_active")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_product_vendor_gtin ON products (vendor_id, gtin)")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_product_gtin ON products (gtin)")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_product_featured ON products (vendor_id, is_featured)")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_product_active ON products (vendor_id, is_active)")) - - # product_translations - op.execute(text("DROP INDEX IF EXISTS ix_product_translations_id")) - - # order_items - op.execute(text("CREATE INDEX IF NOT EXISTS ix_order_items_product_id ON order_items (product_id)")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_order_items_gtin ON order_items (gtin)")) - op.alter_column('order_items', 'needs_product_match', existing_type=sa.BOOLEAN(), nullable=False) - - # order_item_exceptions - op.execute(text("ALTER TABLE order_item_exceptions DROP CONSTRAINT IF EXISTS uq_order_item_exceptions_order_item_id")) - op.execute(text("DROP INDEX IF EXISTS ix_order_item_exceptions_original_gtin")) - op.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS uq_order_item_exception ON order_item_exceptions (order_item_id)")) - - # marketplace_products indexes - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_products_sku")) - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_products_platform")) - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_products_mpn")) - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_products_is_digital")) - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_products_is_active")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_mp_sku ON marketplace_products (sku)")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_mp_platform ON marketplace_products (platform)")) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_mp_is_active ON marketplace_products (is_active)")) - - # marketplace_products columns - op.alter_column('marketplace_products', 'is_active', existing_type=sa.BOOLEAN(), nullable=False) - op.alter_column('marketplace_products', 'is_digital', existing_type=sa.BOOLEAN(), nullable=False) - - # marketplace imports - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_product_translations_id")) - op.execute(text("DROP INDEX IF EXISTS ix_marketplace_import_errors_import_job_id")) - - # inventory - op.execute(text("ALTER TABLE inventory DROP CONSTRAINT IF EXISTS uq_inventory_product_warehouse_bin")) - op.execute(text(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'uq_inventory_product_location') THEN - ALTER TABLE inventory ADD CONSTRAINT uq_inventory_product_location UNIQUE (product_id, location); - END IF; - END $$; - """)) - op.execute(text("CREATE INDEX IF NOT EXISTS idx_inventory_product_location ON inventory (product_id, location)")) - op.alter_column('inventory', 'location', existing_type=sa.VARCHAR(), nullable=False) - op.alter_column('inventory', 'bin_location', existing_type=sa.VARCHAR(), nullable=True) - op.alter_column('inventory', 'warehouse', existing_type=sa.VARCHAR(), nullable=True) - - # customer_addresses - op.execute(text("DROP INDEX IF EXISTS ix_customer_addresses_id")) - op.execute(text("CREATE INDEX IF NOT EXISTS ix_customers_addresses_id ON customer_addresses (id)")) - - # cart_items - op.alter_column('cart_items', 'updated_at', existing_type=sa.DATETIME(), nullable=True) - op.alter_column('cart_items', 'created_at', existing_type=sa.DATETIME(), nullable=True) - - # capacity_snapshots - op.execute(text("DROP INDEX IF EXISTS ix_capacity_snapshots_snapshot_date")) - op.execute(text("DROP INDEX IF EXISTS ix_capacity_snapshots_date")) - op.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_capacity_snapshots_date ON capacity_snapshots (snapshot_date)")) - - # application_logs - op.alter_column('application_logs', 'updated_at', existing_type=sa.DATETIME(), nullable=True) - op.alter_column('application_logs', 'created_at', existing_type=sa.DATETIME(), nullable=True) - - # Drop email tables - op.drop_index(op.f('ix_email_logs_vendor_id'), table_name='email_logs') - op.drop_index(op.f('ix_email_logs_user_id'), table_name='email_logs') - op.drop_index(op.f('ix_email_logs_template_code'), table_name='email_logs') - op.drop_index(op.f('ix_email_logs_status'), table_name='email_logs') - op.drop_index(op.f('ix_email_logs_recipient_email'), table_name='email_logs') - op.drop_index(op.f('ix_email_logs_provider_message_id'), table_name='email_logs') - op.drop_index(op.f('ix_email_logs_id'), table_name='email_logs') - op.drop_table('email_logs') - op.drop_index(op.f('ix_email_templates_id'), table_name='email_templates') - op.drop_index(op.f('ix_email_templates_code'), table_name='email_templates') - op.drop_index(op.f('ix_email_templates_category'), table_name='email_templates') - op.drop_table('email_templates') diff --git a/alembic/versions/e1a2b3c4d5e6_add_product_type_and_digital_fields.py b/alembic/versions/e1a2b3c4d5e6_add_product_type_and_digital_fields.py deleted file mode 100644 index 6b728a8a..00000000 --- a/alembic/versions/e1a2b3c4d5e6_add_product_type_and_digital_fields.py +++ /dev/null @@ -1,204 +0,0 @@ -"""Add product type and digital fields to marketplace_products - -Revision ID: e1a2b3c4d5e6 -Revises: 28d44d503cac -Create Date: 2025-12-11 - -This migration adds support for: -- Product type classification (physical, digital, service, subscription) -- Digital product fields (delivery method, platform, region restrictions) -- Numeric price fields for filtering/sorting -- Additional images as JSON array -- Source URL tracking -- Flexible attributes JSON column -- Active status flag - -It also renames 'product_type' to 'product_type_raw' to preserve the original -Google Shopping feed value while using 'product_type' for the new enum. -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "e1a2b3c4d5e6" -down_revision: Union[str, None] = "28d44d503cac" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Rename existing product_type column to product_type_raw - # to preserve the original Google Shopping feed value - op.alter_column( - "marketplace_products", - "product_type", - new_column_name="product_type_raw", - ) - - # Add new product classification columns - op.add_column( - "marketplace_products", - sa.Column( - "product_type_enum", - sa.String(20), - nullable=False, - server_default="physical", - ), - ) - op.add_column( - "marketplace_products", - sa.Column( - "is_digital", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - ) - - # Add digital product specific fields - op.add_column( - "marketplace_products", - sa.Column("digital_delivery_method", sa.String(20), nullable=True), - ) - op.add_column( - "marketplace_products", - sa.Column("platform", sa.String(50), nullable=True), - ) - op.add_column( - "marketplace_products", - sa.Column("region_restrictions", sa.JSON(), nullable=True), - ) - op.add_column( - "marketplace_products", - sa.Column("license_type", sa.String(50), nullable=True), - ) - - # Add source tracking - op.add_column( - "marketplace_products", - sa.Column("source_url", sa.String(), nullable=True), - ) - - # Add numeric price fields for filtering/sorting - op.add_column( - "marketplace_products", - sa.Column("price_numeric", sa.Float(), nullable=True), - ) - op.add_column( - "marketplace_products", - sa.Column("sale_price_numeric", sa.Float(), nullable=True), - ) - - # Add flexible attributes JSON column - op.add_column( - "marketplace_products", - sa.Column("attributes", sa.JSON(), nullable=True), - ) - - # Add additional images as JSON array (complements existing additional_image_link) - op.add_column( - "marketplace_products", - sa.Column("additional_images", sa.JSON(), nullable=True), - ) - - # Add active status flag - op.add_column( - "marketplace_products", - sa.Column( - "is_active", - sa.Boolean(), - nullable=False, - server_default=sa.text("true"), - ), - ) - - # Add SKU field for internal reference - op.add_column( - "marketplace_products", - sa.Column("sku", sa.String(), nullable=True), - ) - - # Add weight fields for physical products - op.add_column( - "marketplace_products", - sa.Column("weight", sa.Float(), nullable=True), - ) - op.add_column( - "marketplace_products", - sa.Column("weight_unit", sa.String(10), nullable=True, server_default="kg"), - ) - op.add_column( - "marketplace_products", - sa.Column("dimensions", sa.JSON(), nullable=True), - ) - - # Add category_path for normalized hierarchy - op.add_column( - "marketplace_products", - sa.Column("category_path", sa.String(), nullable=True), - ) - - # Create indexes for new columns - op.create_index( - "idx_mp_product_type", - "marketplace_products", - ["product_type_enum", "is_digital"], - ) - op.create_index( - "idx_mp_is_active", - "marketplace_products", - ["is_active"], - ) - op.create_index( - "idx_mp_platform", - "marketplace_products", - ["platform"], - ) - op.create_index( - "idx_mp_sku", - "marketplace_products", - ["sku"], - ) - op.create_index( - "idx_mp_gtin_marketplace", - "marketplace_products", - ["gtin", "marketplace"], - ) - - -def downgrade() -> None: - # Drop indexes - op.drop_index("idx_mp_gtin_marketplace", table_name="marketplace_products") - op.drop_index("idx_mp_sku", table_name="marketplace_products") - op.drop_index("idx_mp_platform", table_name="marketplace_products") - op.drop_index("idx_mp_is_active", table_name="marketplace_products") - op.drop_index("idx_mp_product_type", table_name="marketplace_products") - - # Drop new columns - op.drop_column("marketplace_products", "category_path") - op.drop_column("marketplace_products", "dimensions") - op.drop_column("marketplace_products", "weight_unit") - op.drop_column("marketplace_products", "weight") - op.drop_column("marketplace_products", "sku") - op.drop_column("marketplace_products", "is_active") - op.drop_column("marketplace_products", "additional_images") - op.drop_column("marketplace_products", "attributes") - op.drop_column("marketplace_products", "sale_price_numeric") - op.drop_column("marketplace_products", "price_numeric") - op.drop_column("marketplace_products", "source_url") - op.drop_column("marketplace_products", "license_type") - op.drop_column("marketplace_products", "region_restrictions") - op.drop_column("marketplace_products", "platform") - op.drop_column("marketplace_products", "digital_delivery_method") - op.drop_column("marketplace_products", "is_digital") - op.drop_column("marketplace_products", "product_type_enum") - - # Rename product_type_raw back to product_type - op.alter_column( - "marketplace_products", - "product_type_raw", - new_column_name="product_type", - ) diff --git a/alembic/versions/e1bfb453fbe9_add_warehouse_and_bin_location_to_.py b/alembic/versions/e1bfb453fbe9_add_warehouse_and_bin_location_to_.py deleted file mode 100644 index 80d263a7..00000000 --- a/alembic/versions/e1bfb453fbe9_add_warehouse_and_bin_location_to_.py +++ /dev/null @@ -1,90 +0,0 @@ -"""add_warehouse_and_bin_location_to_inventory - -Revision ID: e1bfb453fbe9 -Revises: j8e9f0a1b2c3 -Create Date: 2025-12-25 12:21:24.006548 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import text - - -# revision identifiers, used by Alembic. -revision: str = 'e1bfb453fbe9' -down_revision: Union[str, None] = 'j8e9f0a1b2c3' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def get_column_names(conn, table_name: str) -> set: - """Get column names for a table (PostgreSQL).""" - result = conn.execute(text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_name = :table AND table_schema = 'public'" - ), {"table": table_name}) - return {row[0] for row in result.fetchall()} - - -def get_index_names(conn, table_name: str) -> set: - """Get index names for a table (PostgreSQL).""" - result = conn.execute(text( - "SELECT indexname FROM pg_indexes " - "WHERE tablename = :table AND schemaname = 'public'" - ), {"table": table_name}) - return {row[0] for row in result.fetchall()} - - -def upgrade() -> None: - conn = op.get_bind() - - # Check if columns already exist (idempotent) - columns = get_column_names(conn, "inventory") - - if 'warehouse' not in columns: - op.add_column('inventory', sa.Column('warehouse', sa.String(), nullable=False, server_default='strassen')) - - if 'bin_location' not in columns: - op.add_column('inventory', sa.Column('bin_location', sa.String(), nullable=False, server_default='')) - - # Migrate existing data: copy location to bin_location, set default warehouse - conn.execute(text(""" - UPDATE inventory - SET bin_location = COALESCE(location, 'UNKNOWN'), - warehouse = 'strassen' - WHERE bin_location IS NULL OR bin_location = '' - """)) - - # Create indexes if they don't exist - existing_indexes = get_index_names(conn, "inventory") - - if 'idx_inventory_warehouse_bin' not in existing_indexes: - op.create_index('idx_inventory_warehouse_bin', 'inventory', ['warehouse', 'bin_location'], unique=False) - if 'ix_inventory_bin_location' not in existing_indexes: - op.create_index(op.f('ix_inventory_bin_location'), 'inventory', ['bin_location'], unique=False) - if 'ix_inventory_warehouse' not in existing_indexes: - op.create_index(op.f('ix_inventory_warehouse'), 'inventory', ['warehouse'], unique=False) - - -def downgrade() -> None: - conn = op.get_bind() - - # Check which indexes exist before dropping - existing_indexes = get_index_names(conn, "inventory") - - if 'ix_inventory_warehouse' in existing_indexes: - op.drop_index(op.f('ix_inventory_warehouse'), table_name='inventory') - if 'ix_inventory_bin_location' in existing_indexes: - op.drop_index(op.f('ix_inventory_bin_location'), table_name='inventory') - if 'idx_inventory_warehouse_bin' in existing_indexes: - op.drop_index('idx_inventory_warehouse_bin', table_name='inventory') - - # Check if columns exist before dropping - columns = get_column_names(conn, "inventory") - - if 'bin_location' in columns: - op.drop_column('inventory', 'bin_location') - if 'warehouse' in columns: - op.drop_column('inventory', 'warehouse') diff --git a/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py b/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py deleted file mode 100644 index e35312a9..00000000 --- a/alembic/versions/e1f2a3b4c5d6_convert_prices_to_integer_cents.py +++ /dev/null @@ -1,223 +0,0 @@ -"""convert_prices_to_integer_cents - -Revision ID: e1f2a3b4c5d6 -Revises: c00d2985701f -Create Date: 2025-12-20 21:30:00.000000 - -Converts all price/amount columns from Float to Integer cents. -This follows e-commerce best practices (Stripe, PayPal, Shopify) for -precise monetary calculations. - -Example: €105.91 is stored as 10591 (integer cents) - -Affected tables: -- products: price, sale_price, supplier_cost, margin_percent -- orders: subtotal, tax_amount, shipping_amount, discount_amount, total_amount -- order_items: unit_price, total_price -- cart_items: price_at_add -- marketplace_products: price_numeric, sale_price_numeric - -See docs/architecture/money-handling.md for full documentation. -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'e1f2a3b4c5d6' -down_revision: Union[str, None] = 'c00d2985701f' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # SQLite requires batch mode for column alterations - # Strategy: Add new _cents columns, migrate data, drop old columns - - # === PRODUCTS TABLE === - with op.batch_alter_table('products', schema=None) as batch_op: - # Add new cents columns - batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('supplier_cost_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('margin_percent_x100', sa.Integer(), nullable=True)) - - # Migrate data for products - op.execute('UPDATE products SET price_cents = ROUND(COALESCE(price, 0) * 100)') - op.execute('UPDATE products SET sale_price_cents = ROUND(sale_price * 100) WHERE sale_price IS NOT NULL') - op.execute('UPDATE products SET supplier_cost_cents = ROUND(supplier_cost * 100) WHERE supplier_cost IS NOT NULL') - op.execute('UPDATE products SET margin_percent_x100 = ROUND(margin_percent * 100) WHERE margin_percent IS NOT NULL') - - # Drop old columns - with op.batch_alter_table('products', schema=None) as batch_op: - batch_op.drop_column('price') - batch_op.drop_column('sale_price') - batch_op.drop_column('supplier_cost') - batch_op.drop_column('margin_percent') - - # === ORDERS TABLE === - with op.batch_alter_table('orders', schema=None) as batch_op: - batch_op.add_column(sa.Column('subtotal_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('tax_amount_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('shipping_amount_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('discount_amount_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('total_amount_cents', sa.Integer(), nullable=True)) - - # Migrate data for orders - op.execute('UPDATE orders SET subtotal_cents = ROUND(COALESCE(subtotal, 0) * 100)') - op.execute('UPDATE orders SET tax_amount_cents = ROUND(COALESCE(tax_amount, 0) * 100)') - op.execute('UPDATE orders SET shipping_amount_cents = ROUND(COALESCE(shipping_amount, 0) * 100)') - op.execute('UPDATE orders SET discount_amount_cents = ROUND(COALESCE(discount_amount, 0) * 100)') - op.execute('UPDATE orders SET total_amount_cents = ROUND(COALESCE(total_amount, 0) * 100)') - - # Make total_amount_cents NOT NULL after migration - with op.batch_alter_table('orders', schema=None) as batch_op: - batch_op.drop_column('subtotal') - batch_op.drop_column('tax_amount') - batch_op.drop_column('shipping_amount') - batch_op.drop_column('discount_amount') - batch_op.drop_column('total_amount') - # Alter total_amount_cents to be NOT NULL - batch_op.alter_column('total_amount_cents', - existing_type=sa.Integer(), - nullable=False) - - # === ORDER_ITEMS TABLE === - with op.batch_alter_table('order_items', schema=None) as batch_op: - batch_op.add_column(sa.Column('unit_price_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('total_price_cents', sa.Integer(), nullable=True)) - - # Migrate data for order_items - op.execute('UPDATE order_items SET unit_price_cents = ROUND(COALESCE(unit_price, 0) * 100)') - op.execute('UPDATE order_items SET total_price_cents = ROUND(COALESCE(total_price, 0) * 100)') - - with op.batch_alter_table('order_items', schema=None) as batch_op: - batch_op.drop_column('unit_price') - batch_op.drop_column('total_price') - batch_op.alter_column('unit_price_cents', - existing_type=sa.Integer(), - nullable=False) - batch_op.alter_column('total_price_cents', - existing_type=sa.Integer(), - nullable=False) - - # === CART_ITEMS TABLE === - with op.batch_alter_table('cart_items', schema=None) as batch_op: - batch_op.add_column(sa.Column('price_at_add_cents', sa.Integer(), nullable=True)) - - # Migrate data for cart_items - op.execute('UPDATE cart_items SET price_at_add_cents = ROUND(COALESCE(price_at_add, 0) * 100)') - - with op.batch_alter_table('cart_items', schema=None) as batch_op: - batch_op.drop_column('price_at_add') - batch_op.alter_column('price_at_add_cents', - existing_type=sa.Integer(), - nullable=False) - - # === MARKETPLACE_PRODUCTS TABLE === - with op.batch_alter_table('marketplace_products', schema=None) as batch_op: - batch_op.add_column(sa.Column('price_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('sale_price_cents', sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column('weight_grams', sa.Integer(), nullable=True)) - - # Migrate data for marketplace_products - op.execute('UPDATE marketplace_products SET price_cents = ROUND(price_numeric * 100) WHERE price_numeric IS NOT NULL') - op.execute('UPDATE marketplace_products SET sale_price_cents = ROUND(sale_price_numeric * 100) WHERE sale_price_numeric IS NOT NULL') - op.execute('UPDATE marketplace_products SET weight_grams = ROUND(weight * 1000) WHERE weight IS NOT NULL') - - with op.batch_alter_table('marketplace_products', schema=None) as batch_op: - batch_op.drop_column('price_numeric') - batch_op.drop_column('sale_price_numeric') - batch_op.drop_column('weight') - - -def downgrade() -> None: - # === MARKETPLACE_PRODUCTS TABLE === - with op.batch_alter_table('marketplace_products', schema=None) as batch_op: - batch_op.add_column(sa.Column('price_numeric', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('sale_price_numeric', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('weight', sa.Float(), nullable=True)) - - op.execute('UPDATE marketplace_products SET price_numeric = price_cents / 100.0 WHERE price_cents IS NOT NULL') - op.execute('UPDATE marketplace_products SET sale_price_numeric = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL') - op.execute('UPDATE marketplace_products SET weight = weight_grams / 1000.0 WHERE weight_grams IS NOT NULL') - - with op.batch_alter_table('marketplace_products', schema=None) as batch_op: - batch_op.drop_column('price_cents') - batch_op.drop_column('sale_price_cents') - batch_op.drop_column('weight_grams') - - # === CART_ITEMS TABLE === - with op.batch_alter_table('cart_items', schema=None) as batch_op: - batch_op.add_column(sa.Column('price_at_add', sa.Float(), nullable=True)) - - op.execute('UPDATE cart_items SET price_at_add = price_at_add_cents / 100.0') - - with op.batch_alter_table('cart_items', schema=None) as batch_op: - batch_op.drop_column('price_at_add_cents') - batch_op.alter_column('price_at_add', - existing_type=sa.Float(), - nullable=False) - - # === ORDER_ITEMS TABLE === - with op.batch_alter_table('order_items', schema=None) as batch_op: - batch_op.add_column(sa.Column('unit_price', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('total_price', sa.Float(), nullable=True)) - - op.execute('UPDATE order_items SET unit_price = unit_price_cents / 100.0') - op.execute('UPDATE order_items SET total_price = total_price_cents / 100.0') - - with op.batch_alter_table('order_items', schema=None) as batch_op: - batch_op.drop_column('unit_price_cents') - batch_op.drop_column('total_price_cents') - batch_op.alter_column('unit_price', - existing_type=sa.Float(), - nullable=False) - batch_op.alter_column('total_price', - existing_type=sa.Float(), - nullable=False) - - # === ORDERS TABLE === - with op.batch_alter_table('orders', schema=None) as batch_op: - batch_op.add_column(sa.Column('subtotal', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('tax_amount', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('shipping_amount', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('discount_amount', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('total_amount', sa.Float(), nullable=True)) - - op.execute('UPDATE orders SET subtotal = subtotal_cents / 100.0') - op.execute('UPDATE orders SET tax_amount = tax_amount_cents / 100.0') - op.execute('UPDATE orders SET shipping_amount = shipping_amount_cents / 100.0') - op.execute('UPDATE orders SET discount_amount = discount_amount_cents / 100.0') - op.execute('UPDATE orders SET total_amount = total_amount_cents / 100.0') - - with op.batch_alter_table('orders', schema=None) as batch_op: - batch_op.drop_column('subtotal_cents') - batch_op.drop_column('tax_amount_cents') - batch_op.drop_column('shipping_amount_cents') - batch_op.drop_column('discount_amount_cents') - batch_op.drop_column('total_amount_cents') - batch_op.alter_column('total_amount', - existing_type=sa.Float(), - nullable=False) - - # === PRODUCTS TABLE === - with op.batch_alter_table('products', schema=None) as batch_op: - batch_op.add_column(sa.Column('price', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('sale_price', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('supplier_cost', sa.Float(), nullable=True)) - batch_op.add_column(sa.Column('margin_percent', sa.Float(), nullable=True)) - - op.execute('UPDATE products SET price = price_cents / 100.0 WHERE price_cents IS NOT NULL') - op.execute('UPDATE products SET sale_price = sale_price_cents / 100.0 WHERE sale_price_cents IS NOT NULL') - op.execute('UPDATE products SET supplier_cost = supplier_cost_cents / 100.0 WHERE supplier_cost_cents IS NOT NULL') - op.execute('UPDATE products SET margin_percent = margin_percent_x100 / 100.0 WHERE margin_percent_x100 IS NOT NULL') - - with op.batch_alter_table('products', schema=None) as batch_op: - batch_op.drop_column('price_cents') - batch_op.drop_column('sale_price_cents') - batch_op.drop_column('supplier_cost_cents') - batch_op.drop_column('margin_percent_x100') diff --git a/alembic/versions/e3f4a5b6c7d8_add_messaging_tables.py b/alembic/versions/e3f4a5b6c7d8_add_messaging_tables.py deleted file mode 100644 index 62b9937d..00000000 --- a/alembic/versions/e3f4a5b6c7d8_add_messaging_tables.py +++ /dev/null @@ -1,339 +0,0 @@ -"""add_messaging_tables - -Revision ID: e3f4a5b6c7d8 -Revises: c9e22eadf533 -Create Date: 2025-12-21 - -This migration adds the messaging system tables: -- conversations: Threaded conversation threads -- conversation_participants: Links users/customers to conversations -- messages: Individual messages within conversations -- message_attachments: File attachments for messages - -Supports three communication channels: -- Admin <-> Vendor -- Vendor <-> Customer -- Admin <-> Customer -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import inspect - - -# revision identifiers, used by Alembic. -revision: str = "e3f4a5b6c7d8" -down_revision: Union[str, None] = "c9e22eadf533" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def table_exists(table_name: str) -> bool: - """Check if a table exists in the database.""" - bind = op.get_bind() - inspector = inspect(bind) - return table_name in inspector.get_table_names() - - -def index_exists(index_name: str, table_name: str) -> bool: - """Check if an index exists on a table.""" - bind = op.get_bind() - inspector = inspect(bind) - try: - indexes = inspector.get_indexes(table_name) - return any(idx["name"] == index_name for idx in indexes) - except Exception: - return False - - -def upgrade() -> None: - # ========================================================================= - # Step 1: Create conversations table - # ========================================================================= - if not table_exists("conversations"): - op.create_table( - "conversations", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "conversation_type", - sa.Enum( - "admin_vendor", - "vendor_customer", - "admin_customer", - name="conversationtype", - ), - nullable=False, - ), - sa.Column("subject", sa.String(length=500), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=True), - sa.Column("is_closed", sa.Boolean(), nullable=False, server_default="0"), - sa.Column("closed_at", sa.DateTime(), nullable=True), - sa.Column( - "closed_by_type", - sa.Enum("admin", "vendor", "customer", name="participanttype"), - nullable=True, - ), - sa.Column("closed_by_id", sa.Integer(), nullable=True), - sa.Column("last_message_at", sa.DateTime(), nullable=True), - sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_conversations_id"), "conversations", ["id"], unique=False - ) - op.create_index( - op.f("ix_conversations_conversation_type"), - "conversations", - ["conversation_type"], - unique=False, - ) - op.create_index( - op.f("ix_conversations_vendor_id"), - "conversations", - ["vendor_id"], - unique=False, - ) - op.create_index( - op.f("ix_conversations_last_message_at"), - "conversations", - ["last_message_at"], - unique=False, - ) - op.create_index( - "ix_conversations_type_vendor", - "conversations", - ["conversation_type", "vendor_id"], - unique=False, - ) - - # ========================================================================= - # Step 2: Create conversation_participants table - # ========================================================================= - if not table_exists("conversation_participants"): - op.create_table( - "conversation_participants", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("conversation_id", sa.Integer(), nullable=False), - sa.Column( - "participant_type", - sa.Enum("admin", "vendor", "customer", name="participanttype"), - nullable=False, - ), - sa.Column("participant_id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=True), - sa.Column("unread_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("last_read_at", sa.DateTime(), nullable=True), - sa.Column( - "email_notifications", sa.Boolean(), nullable=False, server_default="1" - ), - sa.Column("muted", sa.Boolean(), nullable=False, server_default="0"), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.ForeignKeyConstraint( - ["conversation_id"], - ["conversations.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "conversation_id", - "participant_type", - "participant_id", - name="uq_conversation_participant", - ), - ) - op.create_index( - op.f("ix_conversation_participants_id"), - "conversation_participants", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_conversation_participants_conversation_id"), - "conversation_participants", - ["conversation_id"], - unique=False, - ) - op.create_index( - op.f("ix_conversation_participants_participant_id"), - "conversation_participants", - ["participant_id"], - unique=False, - ) - op.create_index( - "ix_participant_lookup", - "conversation_participants", - ["participant_type", "participant_id"], - unique=False, - ) - - # ========================================================================= - # Step 3: Create messages table - # ========================================================================= - if not table_exists("messages"): - op.create_table( - "messages", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("conversation_id", sa.Integer(), nullable=False), - sa.Column( - "sender_type", - sa.Enum("admin", "vendor", "customer", name="participanttype"), - nullable=False, - ), - sa.Column("sender_id", sa.Integer(), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column( - "is_system_message", sa.Boolean(), nullable=False, server_default="0" - ), - sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default="0"), - sa.Column("deleted_at", sa.DateTime(), nullable=True), - sa.Column( - "deleted_by_type", - sa.Enum("admin", "vendor", "customer", name="participanttype"), - nullable=True, - ), - sa.Column("deleted_by_id", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.ForeignKeyConstraint( - ["conversation_id"], - ["conversations.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_messages_id"), "messages", ["id"], unique=False) - op.create_index( - op.f("ix_messages_conversation_id"), - "messages", - ["conversation_id"], - unique=False, - ) - op.create_index( - op.f("ix_messages_sender_id"), "messages", ["sender_id"], unique=False - ) - op.create_index( - "ix_messages_conversation_created", - "messages", - ["conversation_id", "created_at"], - unique=False, - ) - - # ========================================================================= - # Step 4: Create message_attachments table - # ========================================================================= - if not table_exists("message_attachments"): - op.create_table( - "message_attachments", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("message_id", sa.Integer(), nullable=False), - sa.Column("filename", sa.String(length=255), nullable=False), - sa.Column("original_filename", sa.String(length=255), nullable=False), - sa.Column("file_path", sa.String(length=1000), nullable=False), - sa.Column("file_size", sa.Integer(), nullable=False), - sa.Column("mime_type", sa.String(length=100), nullable=False), - sa.Column("is_image", sa.Boolean(), nullable=False, server_default="0"), - sa.Column("image_width", sa.Integer(), nullable=True), - sa.Column("image_height", sa.Integer(), nullable=True), - sa.Column("thumbnail_path", sa.String(length=1000), nullable=True), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.ForeignKeyConstraint( - ["message_id"], - ["messages.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_message_attachments_id"), - "message_attachments", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_message_attachments_message_id"), - "message_attachments", - ["message_id"], - unique=False, - ) - - # ========================================================================= - # Step 5: Add platform setting for attachment size limit - # ========================================================================= - # Note: This will be added via seed script or manually - # Key: message_attachment_max_size_mb - # Value: 10 - # Category: messaging - - -def downgrade() -> None: - # Drop tables in reverse order (respecting foreign keys) - if table_exists("message_attachments"): - op.drop_table("message_attachments") - - if table_exists("messages"): - op.drop_table("messages") - - if table_exists("conversation_participants"): - op.drop_table("conversation_participants") - - if table_exists("conversations"): - op.drop_table("conversations") - - # Note: Enum types are not dropped automatically - # They can be manually dropped with: - # op.execute("DROP TYPE IF EXISTS conversationtype") - # op.execute("DROP TYPE IF EXISTS participanttype") diff --git a/alembic/versions/f2b3c4d5e6f7_create_translation_tables.py b/alembic/versions/f2b3c4d5e6f7_create_translation_tables.py deleted file mode 100644 index 9db90556..00000000 --- a/alembic/versions/f2b3c4d5e6f7_create_translation_tables.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Create translation tables for multi-language support - -Revision ID: f2b3c4d5e6f7 -Revises: e1a2b3c4d5e6 -Create Date: 2025-12-11 - -This migration creates: -- marketplace_product_translations: Localized content from marketplace sources -- product_translations: Vendor-specific localized overrides - -The translation tables support multi-language product information with -language fallback capabilities. Fields in product_translations can be -NULL to inherit from marketplace_product_translations. -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "f2b3c4d5e6f7" -down_revision: Union[str, None] = "e1a2b3c4d5e6" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create marketplace_product_translations table - # Note: Unique constraint is included in create_table for SQLite compatibility - op.create_table( - "marketplace_product_translations", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column( - "marketplace_product_id", - sa.Integer(), - sa.ForeignKey("marketplace_products.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("language", sa.String(5), nullable=False), - # Localized content - sa.Column("title", sa.String(), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("short_description", sa.String(500), nullable=True), - # SEO fields - sa.Column("meta_title", sa.String(70), nullable=True), - sa.Column("meta_description", sa.String(160), nullable=True), - sa.Column("url_slug", sa.String(255), nullable=True), - # Source tracking - sa.Column("source_import_id", sa.Integer(), nullable=True), - sa.Column("source_file", sa.String(), nullable=True), - # Timestamps - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - # Unique constraint included in table creation for SQLite - sa.UniqueConstraint( - "marketplace_product_id", - "language", - name="uq_marketplace_product_translation", - ), - ) - - # Create indexes for marketplace_product_translations - op.create_index( - "idx_mpt_marketplace_product_id", - "marketplace_product_translations", - ["marketplace_product_id"], - ) - op.create_index( - "idx_mpt_language", - "marketplace_product_translations", - ["language"], - ) - - # Create product_translations table - # Note: Unique constraint is included in create_table for SQLite compatibility - op.create_table( - "product_translations", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column( - "product_id", - sa.Integer(), - sa.ForeignKey("products.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column("language", sa.String(5), nullable=False), - # Overridable localized content (NULL = inherit from marketplace) - sa.Column("title", sa.String(), nullable=True), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("short_description", sa.String(500), nullable=True), - # SEO overrides - sa.Column("meta_title", sa.String(70), nullable=True), - sa.Column("meta_description", sa.String(160), nullable=True), - sa.Column("url_slug", sa.String(255), nullable=True), - # Timestamps - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - # Unique constraint included in table creation for SQLite - sa.UniqueConstraint("product_id", "language", name="uq_product_translation"), - ) - - # Create indexes for product_translations - op.create_index( - "idx_pt_product_id", - "product_translations", - ["product_id"], - ) - op.create_index( - "idx_pt_product_language", - "product_translations", - ["product_id", "language"], - ) - - -def downgrade() -> None: - # Drop product_translations table and its indexes - op.drop_index("idx_pt_product_language", table_name="product_translations") - op.drop_index("idx_pt_product_id", table_name="product_translations") - op.drop_table("product_translations") - - # Drop marketplace_product_translations table and its indexes - op.drop_index("idx_mpt_language", table_name="marketplace_product_translations") - op.drop_index( - "idx_mpt_marketplace_product_id", table_name="marketplace_product_translations" - ) - op.drop_table("marketplace_product_translations") diff --git a/alembic/versions/f4a5b6c7d8e9_add_validator_type_to_code_quality.py b/alembic/versions/f4a5b6c7d8e9_add_validator_type_to_code_quality.py deleted file mode 100644 index 35e48b91..00000000 --- a/alembic/versions/f4a5b6c7d8e9_add_validator_type_to_code_quality.py +++ /dev/null @@ -1,95 +0,0 @@ -"""add_validator_type_to_code_quality - -Revision ID: f4a5b6c7d8e9 -Revises: e3f4a5b6c7d8 -Create Date: 2025-12-21 - -This migration adds validator_type column to architecture scans and violations -to support multiple validator types (architecture, security, performance). -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "f4a5b6c7d8e9" -down_revision: Union[str, None] = "e3f4a5b6c7d8" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add validator_type to architecture_scans table - op.add_column( - "architecture_scans", - sa.Column( - "validator_type", - sa.String(length=20), - nullable=False, - server_default="architecture", - ), - ) - op.create_index( - op.f("ix_architecture_scans_validator_type"), - "architecture_scans", - ["validator_type"], - unique=False, - ) - - # Add validator_type to architecture_violations table - op.add_column( - "architecture_violations", - sa.Column( - "validator_type", - sa.String(length=20), - nullable=False, - server_default="architecture", - ), - ) - op.create_index( - op.f("ix_architecture_violations_validator_type"), - "architecture_violations", - ["validator_type"], - unique=False, - ) - - # Add validator_type to architecture_rules table - op.add_column( - "architecture_rules", - sa.Column( - "validator_type", - sa.String(length=20), - nullable=False, - server_default="architecture", - ), - ) - op.create_index( - op.f("ix_architecture_rules_validator_type"), - "architecture_rules", - ["validator_type"], - unique=False, - ) - - -def downgrade() -> None: - # Drop indexes first - op.drop_index( - op.f("ix_architecture_rules_validator_type"), - table_name="architecture_rules", - ) - op.drop_index( - op.f("ix_architecture_violations_validator_type"), - table_name="architecture_violations", - ) - op.drop_index( - op.f("ix_architecture_scans_validator_type"), - table_name="architecture_scans", - ) - - # Drop columns - op.drop_column("architecture_rules", "validator_type") - op.drop_column("architecture_violations", "validator_type") - op.drop_column("architecture_scans", "validator_type") diff --git a/alembic/versions/f68d8da5315a_add_template_field_to_content_pages_for_.py b/alembic/versions/f68d8da5315a_add_template_field_to_content_pages_for_.py deleted file mode 100644 index b095835d..00000000 --- a/alembic/versions/f68d8da5315a_add_template_field_to_content_pages_for_.py +++ /dev/null @@ -1,34 +0,0 @@ -"""add template field to content pages for landing page designs - -Revision ID: f68d8da5315a -Revises: 72aa309d4007 -Create Date: 2025-11-22 23:51:40.694983 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "f68d8da5315a" -down_revision: Union[str, None] = "72aa309d4007" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add template column to content_pages table - op.add_column( - "content_pages", - sa.Column( - "template", sa.String(length=50), nullable=False, server_default="default" - ), - ) - - -def downgrade() -> None: - # Remove template column from content_pages table - op.drop_column("content_pages", "template") diff --git a/alembic/versions/fa7d4d10e358_add_rbac_enhancements.py b/alembic/versions/fa7d4d10e358_add_rbac_enhancements.py deleted file mode 100644 index 6c0decce..00000000 --- a/alembic/versions/fa7d4d10e358_add_rbac_enhancements.py +++ /dev/null @@ -1,148 +0,0 @@ -"""add_rbac_enhancements - -Revision ID: fa7d4d10e358 -Revises: 4951b2e50581 -Create Date: 2025-11-13 16:51:25.010057 - -SQLite-compatible version -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "fa7d4d10e358" -down_revision: Union[str, None] = "4951b2e50581" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade(): - """Upgrade database schema for RBAC enhancements. - - SQLite-compatible version using batch operations for table modifications. - """ - - # ======================================================================== - # User table changes - # ======================================================================== - with op.batch_alter_table("users", schema=None) as batch_op: - batch_op.add_column( - sa.Column( - "is_email_verified", - sa.Boolean(), - nullable=False, - server_default="false", - ) - ) - - # Set existing active users as verified - op.execute("UPDATE users SET is_email_verified = TRUE WHERE is_active = TRUE") - - # ======================================================================== - # VendorUser table changes (requires table recreation for SQLite) - # ======================================================================== - with op.batch_alter_table("vendor_users", schema=None) as batch_op: - # Add new columns - batch_op.add_column( - sa.Column( - "user_type", - sa.String(length=20), - nullable=False, - server_default="member", - ) - ) - batch_op.add_column( - sa.Column("invitation_token", sa.String(length=100), nullable=True) - ) - batch_op.add_column( - sa.Column("invitation_sent_at", sa.DateTime(), nullable=True) - ) - batch_op.add_column( - sa.Column("invitation_accepted_at", sa.DateTime(), nullable=True) - ) - - # Create index on invitation_token - batch_op.create_index("idx_vendor_users_invitation_token", ["invitation_token"]) - - # Modify role_id to be nullable (this recreates the table in SQLite) - batch_op.alter_column("role_id", existing_type=sa.Integer(), nullable=True) - - # Change is_active default (this recreates the table in SQLite) - batch_op.alter_column( - "is_active", existing_type=sa.Boolean(), server_default="false" - ) - - # Set owners correctly (after table modifications) - # SQLite-compatible UPDATE with subquery - op.execute( - """ - UPDATE vendor_users - SET user_type = 'owner' - WHERE (vendor_id, user_id) IN ( - SELECT id, owner_user_id - FROM vendors - ) - """ - ) - - # Set existing owners as active - op.execute( - """ - UPDATE vendor_users - SET is_active = TRUE - WHERE user_type = 'owner' - """ - ) - - # ======================================================================== - # Role table changes - # ======================================================================== - with op.batch_alter_table("roles", schema=None) as batch_op: - # Create index on vendor_id and name - batch_op.create_index("idx_roles_vendor_name", ["vendor_id", "name"]) - - # Note: JSONB conversion only for PostgreSQL - # SQLite stores JSON as TEXT by default, no conversion needed - - -def downgrade(): - """Downgrade database schema. - - SQLite-compatible version using batch operations. - """ - - # ======================================================================== - # Role table changes - # ======================================================================== - with op.batch_alter_table("roles", schema=None) as batch_op: - batch_op.drop_index("idx_roles_vendor_name") - - # ======================================================================== - # VendorUser table changes - # ======================================================================== - with op.batch_alter_table("vendor_users", schema=None) as batch_op: - # Revert is_active default - batch_op.alter_column( - "is_active", existing_type=sa.Boolean(), server_default="true" - ) - - # Revert role_id to NOT NULL - # Note: This might fail if there are NULL values - batch_op.alter_column("role_id", existing_type=sa.Integer(), nullable=False) - - # Drop indexes and columns - batch_op.drop_index("idx_vendor_users_invitation_token") - batch_op.drop_column("invitation_accepted_at") - batch_op.drop_column("invitation_sent_at") - batch_op.drop_column("invitation_token") - batch_op.drop_column("user_type") - - # ======================================================================== - # User table changes - # ======================================================================== - with op.batch_alter_table("users", schema=None) as batch_op: - batch_op.drop_column("is_email_verified") diff --git a/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py b/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py deleted file mode 100644 index 34d7fcfb..00000000 --- a/alembic/versions/fcfdc02d5138_add_language_settings_to_vendor_user_.py +++ /dev/null @@ -1,84 +0,0 @@ -"""add_language_settings_to_vendor_user_customer - -Revision ID: fcfdc02d5138 -Revises: b412e0b49c2e -Create Date: 2025-12-13 20:08:27.120863 - -This migration adds language preference fields to support multi-language UI: -- Vendor: default_language, dashboard_language, storefront_language -- User: preferred_language -- Customer: preferred_language - -Supported languages: en (English), fr (French), de (German), lb (Luxembourgish) -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'fcfdc02d5138' -down_revision: Union[str, None] = 'b412e0b49c2e' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ======================================================================== - # Vendor language settings - # ======================================================================== - # default_language: Default language for vendor content (products, etc.) - op.add_column( - 'vendors', - sa.Column('default_language', sa.String(5), nullable=False, server_default='fr') - ) - # dashboard_language: Language for vendor team dashboard UI - op.add_column( - 'vendors', - sa.Column('dashboard_language', sa.String(5), nullable=False, server_default='fr') - ) - # storefront_language: Default language for customer-facing shop - op.add_column( - 'vendors', - sa.Column('storefront_language', sa.String(5), nullable=False, server_default='fr') - ) - # storefront_languages: JSON array of enabled languages for storefront - # Allows vendors to enable/disable specific languages - op.add_column( - 'vendors', - sa.Column( - 'storefront_languages', - sa.JSON, - nullable=False, - server_default='["fr", "de", "en"]' - ) - ) - - # ======================================================================== - # User language preference - # ======================================================================== - # preferred_language: User's preferred UI language (NULL = use context default) - op.add_column( - 'users', - sa.Column('preferred_language', sa.String(5), nullable=True) - ) - - # ======================================================================== - # Customer language preference - # ======================================================================== - # preferred_language: Customer's preferred language (NULL = use storefront default) - op.add_column( - 'customers', - sa.Column('preferred_language', sa.String(5), nullable=True) - ) - - -def downgrade() -> None: - # Remove columns in reverse order - op.drop_column('customers', 'preferred_language') - op.drop_column('users', 'preferred_language') - op.drop_column('vendors', 'storefront_languages') - op.drop_column('vendors', 'storefront_language') - op.drop_column('vendors', 'dashboard_language') - op.drop_column('vendors', 'default_language') diff --git a/alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py b/alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py deleted file mode 100644 index cc24f81a..00000000 --- a/alembic/versions/fef1d20ce8b4_add_content_pages_table_for_cms.py +++ /dev/null @@ -1,47 +0,0 @@ -"""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 - -import sqlalchemy as sa - -from alembic import op - -# 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/alembic/versions/g5b6c7d8e9f0_add_scan_status_fields.py b/alembic/versions/g5b6c7d8e9f0_add_scan_status_fields.py deleted file mode 100644 index 1558f49c..00000000 --- a/alembic/versions/g5b6c7d8e9f0_add_scan_status_fields.py +++ /dev/null @@ -1,82 +0,0 @@ -"""add_scan_status_fields - -Add background task status fields to architecture_scans table -for harmonized background task architecture. - -Revision ID: g5b6c7d8e9f0 -Revises: f4a5b6c7d8e9 -Create Date: 2024-12-21 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "g5b6c7d8e9f0" -down_revision: str | None = "f4a5b6c7d8e9" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - # Add status field with default 'completed' for existing records - # New records will use 'pending' as default - op.add_column( - "architecture_scans", - sa.Column( - "status", - sa.String(length=30), - nullable=False, - server_default="completed", # Existing scans are already completed - ), - ) - op.create_index( - op.f("ix_architecture_scans_status"), "architecture_scans", ["status"] - ) - - # Add started_at - for existing records, use timestamp as started_at - op.add_column( - "architecture_scans", - sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), - ) - - # Add completed_at - for existing records, use timestamp + duration as completed_at - op.add_column( - "architecture_scans", - sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), - ) - - # Add error_message for failed scans - op.add_column( - "architecture_scans", - sa.Column("error_message", sa.Text(), nullable=True), - ) - - # Add progress_message for showing current step - op.add_column( - "architecture_scans", - sa.Column("progress_message", sa.String(length=255), nullable=True), - ) - - # Update existing records to have proper started_at and completed_at - # This is done via raw SQL for efficiency (PostgreSQL syntax) - op.execute( - """ - UPDATE architecture_scans - SET started_at = timestamp, - completed_at = timestamp + (COALESCE(duration_seconds, 0) || ' seconds')::interval - WHERE started_at IS NULL - """ - ) - - -def downgrade() -> None: - op.drop_index(op.f("ix_architecture_scans_status"), table_name="architecture_scans") - op.drop_column("architecture_scans", "progress_message") - op.drop_column("architecture_scans", "error_message") - op.drop_column("architecture_scans", "completed_at") - op.drop_column("architecture_scans", "started_at") - op.drop_column("architecture_scans", "status") diff --git a/alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py b/alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py deleted file mode 100644 index b88cabc2..00000000 --- a/alembic/versions/h6c7d8e9f0a1_add_invoice_tables.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Add invoice tables - -Revision ID: h6c7d8e9f0a1 -Revises: g5b6c7d8e9f0 -Create Date: 2025-12-24 - -This migration adds: -- vendor_invoice_settings: Per-vendor invoice configuration -- invoices: Invoice records with seller/buyer snapshots -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "h6c7d8e9f0a1" -down_revision: Union[str, None] = "g5b6c7d8e9f0" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create vendor_invoice_settings table - op.create_table( - "vendor_invoice_settings", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - # Company details - sa.Column("company_name", sa.String(length=255), nullable=False), - sa.Column("company_address", sa.String(length=255), nullable=True), - sa.Column("company_city", sa.String(length=100), nullable=True), - sa.Column("company_postal_code", sa.String(length=20), nullable=True), - sa.Column( - "company_country", sa.String(length=2), server_default="LU", nullable=False - ), - # VAT information - sa.Column("vat_number", sa.String(length=50), nullable=True), - sa.Column("is_vat_registered", sa.Boolean(), server_default="1", nullable=False), - # OSS - sa.Column("is_oss_registered", sa.Boolean(), server_default="0", nullable=False), - sa.Column("oss_registration_country", sa.String(length=2), nullable=True), - # Invoice numbering - sa.Column( - "invoice_prefix", sa.String(length=20), server_default="INV", nullable=False - ), - sa.Column("invoice_next_number", sa.Integer(), server_default="1", nullable=False), - sa.Column( - "invoice_number_padding", sa.Integer(), server_default="5", nullable=False - ), - # Payment information - sa.Column("payment_terms", sa.Text(), nullable=True), - sa.Column("bank_name", sa.String(length=255), nullable=True), - sa.Column("bank_iban", sa.String(length=50), nullable=True), - sa.Column("bank_bic", sa.String(length=20), nullable=True), - # Footer - sa.Column("footer_text", sa.Text(), nullable=True), - # Default VAT rate - sa.Column( - "default_vat_rate", sa.Numeric(precision=5, scale=2), server_default="17.00", nullable=False - ), - # Timestamps - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("vendor_id"), - ) - op.create_index( - op.f("ix_vendor_invoice_settings_id"), - "vendor_invoice_settings", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_vendor_invoice_settings_vendor_id"), - "vendor_invoice_settings", - ["vendor_id"], - unique=True, - ) - - # Create invoices table - op.create_table( - "invoices", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("order_id", sa.Integer(), nullable=True), - # Invoice identification - sa.Column("invoice_number", sa.String(length=50), nullable=False), - sa.Column("invoice_date", sa.DateTime(timezone=True), nullable=False), - # Status - sa.Column( - "status", sa.String(length=20), server_default="draft", nullable=False - ), - # Snapshots (JSON) - sa.Column("seller_details", sa.JSON(), nullable=False), - sa.Column("buyer_details", sa.JSON(), nullable=False), - sa.Column("line_items", sa.JSON(), nullable=False), - # VAT information - sa.Column( - "vat_regime", sa.String(length=20), server_default="domestic", nullable=False - ), - sa.Column("destination_country", sa.String(length=2), nullable=True), - sa.Column("vat_rate", sa.Numeric(precision=5, scale=2), nullable=False), - sa.Column("vat_rate_label", sa.String(length=50), nullable=True), - # Amounts (in cents) - sa.Column("currency", sa.String(length=3), server_default="EUR", nullable=False), - sa.Column("subtotal_cents", sa.Integer(), nullable=False), - sa.Column("vat_amount_cents", sa.Integer(), nullable=False), - sa.Column("total_cents", sa.Integer(), nullable=False), - # Payment info - sa.Column("payment_terms", sa.Text(), nullable=True), - sa.Column("bank_details", sa.JSON(), nullable=True), - sa.Column("footer_text", sa.Text(), nullable=True), - # PDF - sa.Column("pdf_generated_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("pdf_path", sa.String(length=500), nullable=True), - # Notes - sa.Column("notes", sa.Text(), nullable=True), - # Timestamps - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.ForeignKeyConstraint( - ["order_id"], - ["orders.id"], - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index(op.f("ix_invoices_id"), "invoices", ["id"], unique=False) - op.create_index(op.f("ix_invoices_vendor_id"), "invoices", ["vendor_id"], unique=False) - op.create_index(op.f("ix_invoices_order_id"), "invoices", ["order_id"], unique=False) - op.create_index( - "idx_invoice_vendor_number", - "invoices", - ["vendor_id", "invoice_number"], - unique=True, - ) - op.create_index( - "idx_invoice_vendor_date", - "invoices", - ["vendor_id", "invoice_date"], - unique=False, - ) - op.create_index( - "idx_invoice_status", - "invoices", - ["vendor_id", "status"], - unique=False, - ) - - -def downgrade() -> None: - # Drop invoices table - op.drop_index("idx_invoice_status", table_name="invoices") - op.drop_index("idx_invoice_vendor_date", table_name="invoices") - op.drop_index("idx_invoice_vendor_number", table_name="invoices") - op.drop_index(op.f("ix_invoices_order_id"), table_name="invoices") - op.drop_index(op.f("ix_invoices_vendor_id"), table_name="invoices") - op.drop_index(op.f("ix_invoices_id"), table_name="invoices") - op.drop_table("invoices") - - # Drop vendor_invoice_settings table - op.drop_index( - op.f("ix_vendor_invoice_settings_vendor_id"), - table_name="vendor_invoice_settings", - ) - op.drop_index( - op.f("ix_vendor_invoice_settings_id"), table_name="vendor_invoice_settings" - ) - op.drop_table("vendor_invoice_settings") diff --git a/alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py b/alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py deleted file mode 100644 index cff6e188..00000000 --- a/alembic/versions/i7d8e9f0a1b2_add_vendor_subscriptions.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Add vendor subscriptions table - -Revision ID: i7d8e9f0a1b2 -Revises: h6c7d8e9f0a1 -Create Date: 2025-12-24 - -This migration adds: -- vendor_subscriptions: Per-vendor subscription tracking with tier limits -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "i7d8e9f0a1b2" -down_revision: Union[str, None] = "h6c7d8e9f0a1" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create vendor_subscriptions table - op.create_table( - "vendor_subscriptions", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - # Tier and status - sa.Column( - "tier", sa.String(length=20), server_default="essential", nullable=False - ), - sa.Column( - "status", sa.String(length=20), server_default="trial", nullable=False - ), - # Billing period - sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), - sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), - sa.Column("is_annual", sa.Boolean(), server_default="0", nullable=False), - # Trial - sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True), - # Usage counters - sa.Column("orders_this_period", sa.Integer(), server_default="0", nullable=False), - sa.Column("orders_limit_reached_at", sa.DateTime(timezone=True), nullable=True), - # Custom overrides - sa.Column("custom_orders_limit", sa.Integer(), nullable=True), - sa.Column("custom_products_limit", sa.Integer(), nullable=True), - sa.Column("custom_team_limit", sa.Integer(), nullable=True), - # Payment (future Stripe integration) - sa.Column("stripe_customer_id", sa.String(length=100), nullable=True), - sa.Column("stripe_subscription_id", sa.String(length=100), nullable=True), - # Cancellation - sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("cancellation_reason", sa.Text(), nullable=True), - # Timestamps - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("(CURRENT_TIMESTAMP)"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("vendor_id"), - ) - op.create_index( - op.f("ix_vendor_subscriptions_id"), - "vendor_subscriptions", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_vendor_subscriptions_vendor_id"), - "vendor_subscriptions", - ["vendor_id"], - unique=True, - ) - op.create_index( - op.f("ix_vendor_subscriptions_tier"), - "vendor_subscriptions", - ["tier"], - unique=False, - ) - op.create_index( - op.f("ix_vendor_subscriptions_status"), - "vendor_subscriptions", - ["status"], - unique=False, - ) - op.create_index( - op.f("ix_vendor_subscriptions_stripe_customer_id"), - "vendor_subscriptions", - ["stripe_customer_id"], - unique=False, - ) - op.create_index( - op.f("ix_vendor_subscriptions_stripe_subscription_id"), - "vendor_subscriptions", - ["stripe_subscription_id"], - unique=False, - ) - op.create_index( - "idx_subscription_vendor_status", - "vendor_subscriptions", - ["vendor_id", "status"], - unique=False, - ) - op.create_index( - "idx_subscription_period", - "vendor_subscriptions", - ["period_start", "period_end"], - unique=False, - ) - - -def downgrade() -> None: - op.drop_index("idx_subscription_period", table_name="vendor_subscriptions") - op.drop_index("idx_subscription_vendor_status", table_name="vendor_subscriptions") - op.drop_index( - op.f("ix_vendor_subscriptions_stripe_subscription_id"), - table_name="vendor_subscriptions", - ) - op.drop_index( - op.f("ix_vendor_subscriptions_stripe_customer_id"), - table_name="vendor_subscriptions", - ) - op.drop_index( - op.f("ix_vendor_subscriptions_status"), table_name="vendor_subscriptions" - ) - op.drop_index( - op.f("ix_vendor_subscriptions_tier"), table_name="vendor_subscriptions" - ) - op.drop_index( - op.f("ix_vendor_subscriptions_vendor_id"), table_name="vendor_subscriptions" - ) - op.drop_index( - op.f("ix_vendor_subscriptions_id"), table_name="vendor_subscriptions" - ) - op.drop_table("vendor_subscriptions") diff --git a/alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py b/alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py deleted file mode 100644 index e03e6244..00000000 --- a/alembic/versions/j8e9f0a1b2c3_product_independence_populate_fields.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Populate product fields from marketplace for independence refactor - -Revision ID: j8e9f0a1b2c3 -Revises: i7d8e9f0a1b2 -Create Date: 2025-12-24 - -This migration populates NULL fields on products and product_translations -with values from their linked marketplace products. This is part of the -"product independence" refactor where products become standalone entities -instead of inheriting from marketplace products via NULL fallback. - -After this migration: -- All Product fields will have actual values (no NULL inheritance) -- All ProductTranslation records will exist with actual values -- The marketplace_product_id FK is kept for "view original source" feature -""" - -from typing import Sequence, Union - -from alembic import op -from sqlalchemy import text - -# revision identifiers, used by Alembic. -revision: str = "j8e9f0a1b2c3" -down_revision: Union[str, None] = "i7d8e9f0a1b2" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Populate NULL product fields with marketplace product values.""" - - # Get database connection for raw SQL - connection = op.get_bind() - - # ========================================================================= - # STEP 1: Populate Product fields from MarketplaceProduct - # ========================================================================= - - # Price cents - connection.execute(text(""" - UPDATE products - SET price_cents = ( - SELECT mp.price_cents - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE price_cents IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Sale price cents - connection.execute(text(""" - UPDATE products - SET sale_price_cents = ( - SELECT mp.sale_price_cents - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE sale_price_cents IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Currency (default to EUR if marketplace has NULL) - connection.execute(text(""" - UPDATE products - SET currency = COALESCE( - (SELECT mp.currency FROM marketplace_products mp WHERE mp.id = products.marketplace_product_id), - 'EUR' - ) - WHERE currency IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Brand - connection.execute(text(""" - UPDATE products - SET brand = ( - SELECT mp.brand - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE brand IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Condition - connection.execute(text(""" - UPDATE products - SET condition = ( - SELECT mp.condition - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE condition IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Availability - connection.execute(text(""" - UPDATE products - SET availability = ( - SELECT mp.availability - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE availability IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Primary image URL (marketplace uses 'image_link') - connection.execute(text(""" - UPDATE products - SET primary_image_url = ( - SELECT mp.image_link - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE primary_image_url IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # Additional images - connection.execute(text(""" - UPDATE products - SET additional_images = ( - SELECT mp.additional_images - FROM marketplace_products mp - WHERE mp.id = products.marketplace_product_id - ) - WHERE additional_images IS NULL - AND marketplace_product_id IS NOT NULL - """)) - - # ========================================================================= - # STEP 2: Create missing ProductTranslation records from MarketplaceProductTranslation - # ========================================================================= - - # Insert missing translations (where product doesn't have translation for a language - # that the marketplace product has) - connection.execute(text(""" - INSERT INTO product_translations (product_id, language, title, description, short_description, - meta_title, meta_description, url_slug, created_at, updated_at) - SELECT - p.id, - mpt.language, - mpt.title, - mpt.description, - mpt.short_description, - mpt.meta_title, - mpt.meta_description, - mpt.url_slug, - CURRENT_TIMESTAMP, - CURRENT_TIMESTAMP - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - WHERE NOT EXISTS ( - SELECT 1 FROM product_translations pt - WHERE pt.product_id = p.id AND pt.language = mpt.language - ) - """)) - - # ========================================================================= - # STEP 3: Update existing ProductTranslation NULL fields with marketplace values - # ========================================================================= - - # Update title where NULL - connection.execute(text(""" - UPDATE product_translations - SET title = ( - SELECT mpt.title - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - AND mpt.language = product_translations.language - WHERE p.id = product_translations.product_id - ) - WHERE title IS NULL - """)) - - # Update description where NULL - connection.execute(text(""" - UPDATE product_translations - SET description = ( - SELECT mpt.description - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - AND mpt.language = product_translations.language - WHERE p.id = product_translations.product_id - ) - WHERE description IS NULL - """)) - - # Update short_description where NULL - connection.execute(text(""" - UPDATE product_translations - SET short_description = ( - SELECT mpt.short_description - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - AND mpt.language = product_translations.language - WHERE p.id = product_translations.product_id - ) - WHERE short_description IS NULL - """)) - - # Update meta_title where NULL - connection.execute(text(""" - UPDATE product_translations - SET meta_title = ( - SELECT mpt.meta_title - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - AND mpt.language = product_translations.language - WHERE p.id = product_translations.product_id - ) - WHERE meta_title IS NULL - """)) - - # Update meta_description where NULL - connection.execute(text(""" - UPDATE product_translations - SET meta_description = ( - SELECT mpt.meta_description - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - AND mpt.language = product_translations.language - WHERE p.id = product_translations.product_id - ) - WHERE meta_description IS NULL - """)) - - # Update url_slug where NULL - connection.execute(text(""" - UPDATE product_translations - SET url_slug = ( - SELECT mpt.url_slug - FROM products p - JOIN marketplace_products mp ON mp.id = p.marketplace_product_id - JOIN marketplace_product_translations mpt ON mpt.marketplace_product_id = mp.id - AND mpt.language = product_translations.language - WHERE p.id = product_translations.product_id - ) - WHERE url_slug IS NULL - """)) - - -def downgrade() -> None: - """ - Downgrade is a no-op for data population. - - The data was copied, not moved. The original marketplace product data - is still intact. We don't reset fields to NULL because: - 1. It would lose any vendor customizations made after migration - 2. The model code may still work with populated fields - """ - pass diff --git a/alembic/versions/k9f0a1b2c3d4_add_tier_id_fk_to_subscriptions.py b/alembic/versions/k9f0a1b2c3d4_add_tier_id_fk_to_subscriptions.py deleted file mode 100644 index b7d74ac5..00000000 --- a/alembic/versions/k9f0a1b2c3d4_add_tier_id_fk_to_subscriptions.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Add tier_id FK to vendor_subscriptions - -Revision ID: k9f0a1b2c3d4 -Revises: 2953ed10d22c -Create Date: 2025-12-26 - -Adds tier_id column to vendor_subscriptions table with FK to subscription_tiers. -Backfills tier_id based on existing tier (code) values. -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "k9f0a1b2c3d4" -down_revision = "2953ed10d22c" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Use batch mode for SQLite compatibility - with op.batch_alter_table("vendor_subscriptions", schema=None) as batch_op: - # Add tier_id column (nullable for backfill) - batch_op.add_column( - sa.Column("tier_id", sa.Integer(), nullable=True) - ) - # Create index for tier_id - batch_op.create_index( - "ix_vendor_subscriptions_tier_id", - ["tier_id"], - unique=False, - ) - # Add FK constraint - batch_op.create_foreign_key( - "fk_vendor_subscriptions_tier_id", - "subscription_tiers", - ["tier_id"], - ["id"], - ondelete="SET NULL", - ) - - # Backfill tier_id from tier code - # This updates existing subscriptions to link to their tier - op.execute( - """ - UPDATE vendor_subscriptions - SET tier_id = ( - SELECT id FROM subscription_tiers - WHERE subscription_tiers.code = vendor_subscriptions.tier - ) - WHERE EXISTS ( - SELECT 1 FROM subscription_tiers - WHERE subscription_tiers.code = vendor_subscriptions.tier - ) - """ - ) - - -def downgrade() -> None: - # In SQLite batch mode, we must explicitly drop the index before dropping - # the column, otherwise batch mode will try to recreate the index on the - # new table (which won't have the column). - with op.batch_alter_table("vendor_subscriptions", schema=None) as batch_op: - # First drop the index on tier_id - batch_op.drop_index("ix_vendor_subscriptions_tier_id") - # Then drop the column (FK is automatically removed with the column) - batch_op.drop_column("tier_id") diff --git a/alembic/versions/l0a1b2c3d4e5_add_capacity_snapshots_table.py b/alembic/versions/l0a1b2c3d4e5_add_capacity_snapshots_table.py deleted file mode 100644 index a87beb08..00000000 --- a/alembic/versions/l0a1b2c3d4e5_add_capacity_snapshots_table.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Add capacity_snapshots table - -Revision ID: l0a1b2c3d4e5 -Revises: k9f0a1b2c3d4 -Create Date: 2025-12-26 - -Adds table for tracking daily platform capacity metrics for growth forecasting. -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "l0a1b2c3d4e5" -down_revision = "k9f0a1b2c3d4" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "capacity_snapshots", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("snapshot_date", sa.DateTime(timezone=True), nullable=False), - # Vendor metrics - sa.Column("total_vendors", sa.Integer(), nullable=False, server_default="0"), - sa.Column("active_vendors", sa.Integer(), nullable=False, server_default="0"), - sa.Column("trial_vendors", sa.Integer(), nullable=False, server_default="0"), - # Subscription metrics - sa.Column("total_subscriptions", sa.Integer(), nullable=False, server_default="0"), - sa.Column("active_subscriptions", sa.Integer(), nullable=False, server_default="0"), - # Resource metrics - sa.Column("total_products", sa.Integer(), nullable=False, server_default="0"), - sa.Column("total_orders_month", sa.Integer(), nullable=False, server_default="0"), - sa.Column("total_team_members", sa.Integer(), nullable=False, server_default="0"), - # Storage metrics - sa.Column("storage_used_gb", sa.Numeric(10, 2), nullable=False, server_default="0"), - sa.Column("db_size_mb", sa.Numeric(10, 2), nullable=False, server_default="0"), - # Capacity metrics - sa.Column("theoretical_products_limit", sa.Integer(), nullable=True), - sa.Column("theoretical_orders_limit", sa.Integer(), nullable=True), - sa.Column("theoretical_team_limit", sa.Integer(), nullable=True), - # Tier distribution - sa.Column("tier_distribution", sa.JSON(), nullable=True), - # Performance metrics - sa.Column("avg_response_ms", sa.Integer(), nullable=True), - sa.Column("peak_cpu_percent", sa.Numeric(5, 2), nullable=True), - sa.Column("peak_memory_percent", sa.Numeric(5, 2), nullable=True), - # Timestamps - sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), - # Primary key - sa.PrimaryKeyConstraint("id"), - ) - - # Create indexes - op.create_index("ix_capacity_snapshots_id", "capacity_snapshots", ["id"], unique=False) - op.create_index("ix_capacity_snapshots_date", "capacity_snapshots", ["snapshot_date"], unique=True) - - -def downgrade() -> None: - op.drop_index("ix_capacity_snapshots_date", table_name="capacity_snapshots") - op.drop_index("ix_capacity_snapshots_id", table_name="capacity_snapshots") - op.drop_table("capacity_snapshots") diff --git a/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py b/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py deleted file mode 100644 index 849da006..00000000 --- a/alembic/versions/m1b2c3d4e5f6_add_vendor_onboarding_table.py +++ /dev/null @@ -1,71 +0,0 @@ -"""add vendor onboarding table - -Revision ID: m1b2c3d4e5f6 -Revises: d7a4a3f06394 -Create Date: 2025-12-27 22:00:00.000000 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'm1b2c3d4e5f6' -down_revision: Union[str, None] = 'd7a4a3f06394' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table('vendor_onboarding', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('vendor_id', sa.Integer(), nullable=False), - # Overall status - sa.Column('status', sa.String(length=20), nullable=False, server_default='not_started'), - sa.Column('current_step', sa.String(length=30), nullable=False, server_default='company_profile'), - # Step 1: Company Profile - sa.Column('step_company_profile_completed', sa.Boolean(), nullable=False, server_default=sa.text('false')), - sa.Column('step_company_profile_completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('step_company_profile_data', sa.JSON(), nullable=True), - # Step 2: Letzshop API Configuration - sa.Column('step_letzshop_api_completed', sa.Boolean(), nullable=False, server_default=sa.text('false')), - sa.Column('step_letzshop_api_completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('step_letzshop_api_connection_verified', sa.Boolean(), nullable=False, server_default=sa.text('false')), - # Step 3: Product Import - sa.Column('step_product_import_completed', sa.Boolean(), nullable=False, server_default=sa.text('false')), - sa.Column('step_product_import_completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('step_product_import_csv_url_set', sa.Boolean(), nullable=False, server_default=sa.text('false')), - # Step 4: Order Sync - sa.Column('step_order_sync_completed', sa.Boolean(), nullable=False, server_default=sa.text('false')), - sa.Column('step_order_sync_completed_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('step_order_sync_job_id', sa.Integer(), nullable=True), - # Completion tracking - sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('completed_at', sa.DateTime(timezone=True), nullable=True), - # Admin override - sa.Column('skipped_by_admin', sa.Boolean(), nullable=False, server_default=sa.text('false')), - sa.Column('skipped_at', sa.DateTime(timezone=True), nullable=True), - sa.Column('skipped_reason', sa.Text(), nullable=True), - sa.Column('skipped_by_user_id', sa.Integer(), nullable=True), - # Timestamps - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - # Constraints - sa.ForeignKeyConstraint(['vendor_id'], ['vendors.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['skipped_by_user_id'], ['users.id']), - sa.PrimaryKeyConstraint('id'), - ) - op.create_index(op.f('ix_vendor_onboarding_id'), 'vendor_onboarding', ['id'], unique=False) - op.create_index(op.f('ix_vendor_onboarding_vendor_id'), 'vendor_onboarding', ['vendor_id'], unique=True) - op.create_index(op.f('ix_vendor_onboarding_status'), 'vendor_onboarding', ['status'], unique=False) - op.create_index('idx_onboarding_vendor_status', 'vendor_onboarding', ['vendor_id', 'status'], unique=False) - - -def downgrade() -> None: - op.drop_index('idx_onboarding_vendor_status', table_name='vendor_onboarding') - op.drop_index(op.f('ix_vendor_onboarding_status'), table_name='vendor_onboarding') - op.drop_index(op.f('ix_vendor_onboarding_vendor_id'), table_name='vendor_onboarding') - op.drop_index(op.f('ix_vendor_onboarding_id'), table_name='vendor_onboarding') - op.drop_table('vendor_onboarding') diff --git a/alembic/versions/n2c3d4e5f6a7_add_features_table.py b/alembic/versions/n2c3d4e5f6a7_add_features_table.py deleted file mode 100644 index 27b36e85..00000000 --- a/alembic/versions/n2c3d4e5f6a7_add_features_table.py +++ /dev/null @@ -1,292 +0,0 @@ -"""add features table and seed data - -Revision ID: n2c3d4e5f6a7 -Revises: ba2c0ce78396 -Create Date: 2025-12-31 10:00:00.000000 - -""" - -import json -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "n2c3d4e5f6a7" -down_revision: Union[str, None] = "ba2c0ce78396" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -# ============================================================================ -# Feature Definitions -# ============================================================================ -# category, code, name, description, ui_location, ui_icon, ui_route, display_order -FEATURES = [ - # Orders (category: orders) - ("orders", "order_management", "Order Management", "View and manage orders", "sidebar", "clipboard-list", "/vendor/{code}/orders", 1), - ("orders", "order_bulk_actions", "Bulk Order Actions", "Process multiple orders at once", "inline", None, None, 2), - ("orders", "order_export", "Order Export", "Export orders to CSV/Excel", "inline", "download", None, 3), - ("orders", "automation_rules", "Automation Rules", "Automatic order processing rules", "sidebar", "cog", "/vendor/{code}/automation", 4), - - # Inventory (category: inventory) - ("inventory", "inventory_basic", "Basic Inventory", "Track product stock levels", "sidebar", "cube", "/vendor/{code}/inventory", 1), - ("inventory", "inventory_locations", "Warehouse Locations", "Manage multiple warehouse locations", "inline", "map-pin", None, 2), - ("inventory", "inventory_purchase_orders", "Purchase Orders", "Create and manage purchase orders", "sidebar", "shopping-cart", "/vendor/{code}/purchase-orders", 3), - ("inventory", "low_stock_alerts", "Low Stock Alerts", "Get notified when stock is low", "inline", "bell", None, 4), - - # Analytics (category: analytics) - ("analytics", "basic_reports", "Basic Reports", "Essential sales and order reports", "sidebar", "chart-pie", "/vendor/{code}/reports", 1), - ("analytics", "analytics_dashboard", "Analytics Dashboard", "Advanced analytics with charts and trends", "sidebar", "chart-bar", "/vendor/{code}/analytics", 2), - ("analytics", "custom_reports", "Custom Reports", "Build custom report configurations", "inline", "document-report", None, 3), - ("analytics", "export_reports", "Export Reports", "Export reports to various formats", "inline", "download", None, 4), - - # Invoicing (category: invoicing) - ("invoicing", "invoice_lu", "Luxembourg Invoicing", "Generate compliant Luxembourg invoices", "sidebar", "document-text", "/vendor/{code}/invoices", 1), - ("invoicing", "invoice_eu_vat", "EU VAT Support", "Handle EU VAT for cross-border sales", "inline", "globe", None, 2), - ("invoicing", "invoice_bulk", "Bulk Invoicing", "Generate invoices in bulk", "inline", "document-duplicate", None, 3), - ("invoicing", "accounting_export", "Accounting Export", "Export to accounting software formats", "inline", "calculator", None, 4), - - # Integrations (category: integrations) - ("integrations", "letzshop_sync", "Letzshop Sync", "Sync orders and products with Letzshop", "settings", "refresh", None, 1), - ("integrations", "api_access", "API Access", "REST API access for custom integrations", "settings", "code", "/vendor/{code}/settings/api", 2), - ("integrations", "webhooks", "Webhooks", "Receive real-time event notifications", "settings", "lightning-bolt", "/vendor/{code}/settings/webhooks", 3), - ("integrations", "custom_integrations", "Custom Integrations", "Connect with any third-party service", "settings", "puzzle", None, 4), - - # Team (category: team) - ("team", "single_user", "Single User", "One user account", "api", None, None, 1), - ("team", "team_basic", "Team Access", "Invite team members", "sidebar", "users", "/vendor/{code}/team", 2), - ("team", "team_roles", "Team Roles", "Role-based permissions for team members", "inline", "shield-check", None, 3), - ("team", "audit_log", "Audit Log", "Track all user actions", "sidebar", "clipboard-check", "/vendor/{code}/audit-log", 4), - - # Branding (category: branding) - ("branding", "basic_shop", "Basic Shop", "Your shop on the platform", "api", None, None, 1), - ("branding", "custom_domain", "Custom Domain", "Use your own domain name", "settings", "globe-alt", None, 2), - ("branding", "white_label", "White Label", "Remove platform branding entirely", "settings", "color-swatch", None, 3), - - # Customers (category: customers) - ("customers", "customer_view", "Customer View", "View customer information", "sidebar", "user-group", "/vendor/{code}/customers", 1), - ("customers", "customer_export", "Customer Export", "Export customer data", "inline", "download", None, 2), - ("customers", "customer_messaging", "Customer Messaging", "Send messages to customers", "inline", "chat", None, 3), -] - -# ============================================================================ -# Tier Feature Assignments -# ============================================================================ -# tier_code -> list of feature codes -TIER_FEATURES = { - "essential": [ - "order_management", - "inventory_basic", - "basic_reports", - "invoice_lu", - "letzshop_sync", - "single_user", - "basic_shop", - "customer_view", - ], - "professional": [ - # All Essential features - "order_management", - "order_bulk_actions", - "order_export", - "inventory_basic", - "inventory_locations", - "inventory_purchase_orders", - "low_stock_alerts", - "basic_reports", - "invoice_lu", - "invoice_eu_vat", - "letzshop_sync", - "team_basic", - "basic_shop", - "customer_view", - "customer_export", - ], - "business": [ - # All Professional features - "order_management", - "order_bulk_actions", - "order_export", - "automation_rules", - "inventory_basic", - "inventory_locations", - "inventory_purchase_orders", - "low_stock_alerts", - "basic_reports", - "analytics_dashboard", - "custom_reports", - "export_reports", - "invoice_lu", - "invoice_eu_vat", - "invoice_bulk", - "accounting_export", - "letzshop_sync", - "api_access", - "webhooks", - "team_basic", - "team_roles", - "audit_log", - "basic_shop", - "custom_domain", - "customer_view", - "customer_export", - "customer_messaging", - ], - "enterprise": [ - # All features - "order_management", - "order_bulk_actions", - "order_export", - "automation_rules", - "inventory_basic", - "inventory_locations", - "inventory_purchase_orders", - "low_stock_alerts", - "basic_reports", - "analytics_dashboard", - "custom_reports", - "export_reports", - "invoice_lu", - "invoice_eu_vat", - "invoice_bulk", - "accounting_export", - "letzshop_sync", - "api_access", - "webhooks", - "custom_integrations", - "team_basic", - "team_roles", - "audit_log", - "basic_shop", - "custom_domain", - "white_label", - "customer_view", - "customer_export", - "customer_messaging", - ], -} - -# Minimum tier for each feature (for upgrade prompts) -# Maps feature_code -> tier_code -MINIMUM_TIER = { - # Essential - "order_management": "essential", - "inventory_basic": "essential", - "basic_reports": "essential", - "invoice_lu": "essential", - "letzshop_sync": "essential", - "single_user": "essential", - "basic_shop": "essential", - "customer_view": "essential", - # Professional - "order_bulk_actions": "professional", - "order_export": "professional", - "inventory_locations": "professional", - "inventory_purchase_orders": "professional", - "low_stock_alerts": "professional", - "invoice_eu_vat": "professional", - "team_basic": "professional", - "customer_export": "professional", - # Business - "automation_rules": "business", - "analytics_dashboard": "business", - "custom_reports": "business", - "export_reports": "business", - "invoice_bulk": "business", - "accounting_export": "business", - "api_access": "business", - "webhooks": "business", - "team_roles": "business", - "audit_log": "business", - "custom_domain": "business", - "customer_messaging": "business", - # Enterprise - "custom_integrations": "enterprise", - "white_label": "enterprise", -} - - -def upgrade() -> None: - # Create features table - op.create_table( - "features", - 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("category", sa.String(50), nullable=False), - sa.Column("ui_location", sa.String(50), nullable=True), - sa.Column("ui_icon", sa.String(50), nullable=True), - sa.Column("ui_route", sa.String(100), nullable=True), - sa.Column("ui_badge_text", sa.String(20), nullable=True), - sa.Column("minimum_tier_id", sa.Integer(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False, default=True), - sa.Column("is_visible", sa.Boolean(), nullable=False, default=True), - sa.Column("display_order", sa.Integer(), nullable=False, default=0), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(["minimum_tier_id"], ["subscription_tiers.id"]), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_features_code", "features", ["code"], unique=True) - op.create_index("ix_features_category", "features", ["category"], unique=False) - op.create_index("idx_feature_category_order", "features", ["category", "display_order"]) - op.create_index("idx_feature_active_visible", "features", ["is_active", "is_visible"]) - - # Get connection for data operations - conn = op.get_bind() - - # Get tier IDs - tier_ids = {} - result = conn.execute(sa.text("SELECT id, code FROM subscription_tiers")) - for row in result: - tier_ids[row[1]] = row[0] - - # Insert features - now = sa.func.now() - for category, code, name, description, ui_location, ui_icon, ui_route, display_order in FEATURES: - minimum_tier_code = MINIMUM_TIER.get(code) - minimum_tier_id = tier_ids.get(minimum_tier_code) if minimum_tier_code else None - - conn.execute( - sa.text(""" - 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, :ui_route, - :minimum_tier_id, true, true, :display_order, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """), - { - "code": code, - "name": name, - "description": description, - "category": category, - "ui_location": ui_location, - "ui_icon": ui_icon, - "ui_route": ui_route, - "minimum_tier_id": minimum_tier_id, - "display_order": display_order, - }, - ) - - # Update subscription_tiers with feature arrays - for tier_code, features in TIER_FEATURES.items(): - features_json = json.dumps(features) - conn.execute( - sa.text("UPDATE subscription_tiers SET features = :features WHERE code = :code"), - {"features": features_json, "code": tier_code}, - ) - - -def downgrade() -> None: - # Clear features from subscription_tiers - conn = op.get_bind() - conn.execute(sa.text("UPDATE subscription_tiers SET features = '[]'")) - - # Drop features table - op.drop_index("idx_feature_active_visible", table_name="features") - op.drop_index("idx_feature_category_order", table_name="features") - op.drop_index("ix_features_category", table_name="features") - op.drop_index("ix_features_code", table_name="features") - op.drop_table("features") diff --git a/alembic/versions/o3c4d5e6f7a8_add_inventory_transactions_table.py b/alembic/versions/o3c4d5e6f7a8_add_inventory_transactions_table.py deleted file mode 100644 index fef620d5..00000000 --- a/alembic/versions/o3c4d5e6f7a8_add_inventory_transactions_table.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Add inventory_transactions table - -Revision ID: o3c4d5e6f7a8 -Revises: n2c3d4e5f6a7 -Create Date: 2026-01-01 - -Adds an audit trail for inventory movements: -- Track all stock changes (reserve, fulfill, release, adjust, set) -- Link transactions to orders for traceability -- Store quantity snapshots for historical analysis -""" - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "o3c4d5e6f7a8" -down_revision = "n2c3d4e5f6a7" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Create transaction type enum - transaction_type_enum = sa.Enum( - "reserve", - "fulfill", - "release", - "adjust", - "set", - "import", - "return", - name="transactiontype", - ) - - op.create_table( - "inventory_transactions", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("product_id", sa.Integer(), nullable=False), - sa.Column("inventory_id", sa.Integer(), nullable=True), - sa.Column("transaction_type", transaction_type_enum, nullable=False), - sa.Column("quantity_change", sa.Integer(), nullable=False), - sa.Column("quantity_after", sa.Integer(), nullable=False), - sa.Column("reserved_after", sa.Integer(), nullable=False, server_default="0"), - sa.Column("location", sa.String(), nullable=True), - sa.Column("warehouse", sa.String(), nullable=True), - sa.Column("order_id", sa.Integer(), nullable=True), - sa.Column("order_number", sa.String(), nullable=True), - sa.Column("reason", sa.Text(), nullable=True), - sa.Column("created_by", sa.String(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - ), - sa.ForeignKeyConstraint(["vendor_id"], ["vendors.id"]), - sa.ForeignKeyConstraint(["product_id"], ["products.id"]), - sa.ForeignKeyConstraint(["inventory_id"], ["inventory.id"]), - sa.ForeignKeyConstraint(["order_id"], ["orders.id"]), - sa.PrimaryKeyConstraint("id"), - ) - - # Create indexes - op.create_index( - "ix_inventory_transactions_id", - "inventory_transactions", - ["id"], - ) - op.create_index( - "ix_inventory_transactions_vendor_id", - "inventory_transactions", - ["vendor_id"], - ) - op.create_index( - "ix_inventory_transactions_product_id", - "inventory_transactions", - ["product_id"], - ) - op.create_index( - "ix_inventory_transactions_inventory_id", - "inventory_transactions", - ["inventory_id"], - ) - op.create_index( - "ix_inventory_transactions_transaction_type", - "inventory_transactions", - ["transaction_type"], - ) - op.create_index( - "ix_inventory_transactions_order_id", - "inventory_transactions", - ["order_id"], - ) - op.create_index( - "ix_inventory_transactions_created_at", - "inventory_transactions", - ["created_at"], - ) - op.create_index( - "idx_inv_tx_vendor_product", - "inventory_transactions", - ["vendor_id", "product_id"], - ) - op.create_index( - "idx_inv_tx_vendor_created", - "inventory_transactions", - ["vendor_id", "created_at"], - ) - op.create_index( - "idx_inv_tx_type_created", - "inventory_transactions", - ["transaction_type", "created_at"], - ) - - -def downgrade() -> None: - op.drop_index("idx_inv_tx_type_created", table_name="inventory_transactions") - op.drop_index("idx_inv_tx_vendor_created", table_name="inventory_transactions") - op.drop_index("idx_inv_tx_vendor_product", table_name="inventory_transactions") - op.drop_index( - "ix_inventory_transactions_created_at", table_name="inventory_transactions" - ) - op.drop_index( - "ix_inventory_transactions_order_id", table_name="inventory_transactions" - ) - op.drop_index( - "ix_inventory_transactions_transaction_type", table_name="inventory_transactions" - ) - op.drop_index( - "ix_inventory_transactions_inventory_id", table_name="inventory_transactions" - ) - op.drop_index( - "ix_inventory_transactions_product_id", table_name="inventory_transactions" - ) - op.drop_index( - "ix_inventory_transactions_vendor_id", table_name="inventory_transactions" - ) - op.drop_index("ix_inventory_transactions_id", table_name="inventory_transactions") - op.drop_table("inventory_transactions") - - # Drop enum - sa.Enum(name="transactiontype").drop(op.get_bind(), checkfirst=True) diff --git a/alembic/versions/p4d5e6f7a8b9_add_shipped_quantity_to_order_items.py b/alembic/versions/p4d5e6f7a8b9_add_shipped_quantity_to_order_items.py deleted file mode 100644 index cbd2069b..00000000 --- a/alembic/versions/p4d5e6f7a8b9_add_shipped_quantity_to_order_items.py +++ /dev/null @@ -1,39 +0,0 @@ -# alembic/versions/p4d5e6f7a8b9_add_shipped_quantity_to_order_items.py -"""Add shipped_quantity to order_items for partial shipments. - -Revision ID: p4d5e6f7a8b9 -Revises: o3c4d5e6f7a8 -Create Date: 2026-01-01 12:00:00.000000 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'p4d5e6f7a8b9' -down_revision: Union[str, None] = 'o3c4d5e6f7a8' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add shipped_quantity column to order_items - op.add_column( - 'order_items', - sa.Column('shipped_quantity', sa.Integer(), nullable=False, server_default='0') - ) - - # Set shipped_quantity = quantity for already fulfilled items - # This handles existing data where inventory_fulfilled is True - op.execute(""" - UPDATE order_items - SET shipped_quantity = quantity - WHERE inventory_fulfilled = true - """) - - -def downgrade() -> None: - op.drop_column('order_items', 'shipped_quantity') diff --git a/alembic/versions/q5e6f7a8b9c0_add_vat_fields_to_orders.py b/alembic/versions/q5e6f7a8b9c0_add_vat_fields_to_orders.py deleted file mode 100644 index b7926c76..00000000 --- a/alembic/versions/q5e6f7a8b9c0_add_vat_fields_to_orders.py +++ /dev/null @@ -1,72 +0,0 @@ -# alembic/versions/q5e6f7a8b9c0_add_vat_fields_to_orders.py -"""Add VAT fields to orders table. - -Adds vat_regime, vat_rate, vat_rate_label, and vat_destination_country -to enable proper VAT tracking at order creation time, aligned with -invoice VAT logic. - -Revision ID: q5e6f7a8b9c0 -Revises: p4d5e6f7a8b9 -Create Date: 2026-01-02 10:00:00.000000 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = 'q5e6f7a8b9c0' -down_revision: Union[str, None] = 'p4d5e6f7a8b9' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Add VAT regime (domestic, oss, reverse_charge, origin, exempt) - op.add_column( - 'orders', - sa.Column('vat_regime', sa.String(20), nullable=True) - ) - - # Add VAT rate as percentage (e.g., 17.00 for 17%) - op.add_column( - 'orders', - sa.Column('vat_rate', sa.Numeric(5, 2), nullable=True) - ) - - # Add human-readable VAT label (e.g., "Luxembourg VAT 17%") - op.add_column( - 'orders', - sa.Column('vat_rate_label', sa.String(100), nullable=True) - ) - - # Add destination country for cross-border sales (ISO code) - op.add_column( - 'orders', - sa.Column('vat_destination_country', sa.String(2), nullable=True) - ) - - # Populate VAT fields for existing orders based on shipping country - # Default to 'domestic' for LU orders and 'origin' for other EU orders - op.execute(""" - UPDATE orders - SET vat_regime = CASE - WHEN ship_country_iso = 'LU' THEN 'domestic' - WHEN ship_country_iso IN ('AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE') THEN 'origin' - ELSE 'exempt' - END, - vat_destination_country = CASE - WHEN ship_country_iso != 'LU' AND ship_country_iso IN ('AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE') THEN ship_country_iso - ELSE NULL - END - WHERE vat_regime IS NULL - """) - - -def downgrade() -> None: - op.drop_column('orders', 'vat_destination_country') - op.drop_column('orders', 'vat_rate_label') - op.drop_column('orders', 'vat_rate') - op.drop_column('orders', 'vat_regime') diff --git a/alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py b/alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py deleted file mode 100644 index e7af3c97..00000000 --- a/alembic/versions/r6f7a8b9c0d1_add_country_iso_to_addresses.py +++ /dev/null @@ -1,144 +0,0 @@ -"""Add country_iso to customer_addresses - -Revision ID: r6f7a8b9c0d1 -Revises: q5e6f7a8b9c0 -Create Date: 2026-01-02 - -Adds country_iso field to customer_addresses table and renames -country to country_name for clarity. - -This migration is idempotent - it checks for existing columns before -making changes. -""" - -from alembic import op -import sqlalchemy as sa -from sqlalchemy import text - - -# revision identifiers, used by Alembic. -revision = "r6f7a8b9c0d1" -down_revision = "q5e6f7a8b9c0" -branch_labels = None -depends_on = None - - -# Country name to ISO code mapping for backfill -COUNTRY_ISO_MAP = { - "Luxembourg": "LU", - "Germany": "DE", - "France": "FR", - "Belgium": "BE", - "Netherlands": "NL", - "Austria": "AT", - "Italy": "IT", - "Spain": "ES", - "Portugal": "PT", - "Poland": "PL", - "Czech Republic": "CZ", - "Czechia": "CZ", - "Slovakia": "SK", - "Hungary": "HU", - "Romania": "RO", - "Bulgaria": "BG", - "Greece": "GR", - "Croatia": "HR", - "Slovenia": "SI", - "Estonia": "EE", - "Latvia": "LV", - "Lithuania": "LT", - "Finland": "FI", - "Sweden": "SE", - "Denmark": "DK", - "Ireland": "IE", - "Cyprus": "CY", - "Malta": "MT", - "United Kingdom": "GB", - "Switzerland": "CH", - "United States": "US", -} - - -def get_column_names(connection, table_name): - """Get list of column names for a table (PostgreSQL).""" - result = connection.execute(text( - "SELECT column_name FROM information_schema.columns " - "WHERE table_name = :table AND table_schema = 'public'" - ), {"table": table_name}) - return [row[0] for row in result] - - -def upgrade() -> None: - connection = op.get_bind() - columns = get_column_names(connection, "customer_addresses") - - # Check if we need to do anything (idempotent check) - has_country = "country" in columns - has_country_name = "country_name" in columns - has_country_iso = "country_iso" in columns - - # If already has new columns, nothing to do - if has_country_name and has_country_iso: - print(" Columns country_name and country_iso already exist, skipping") - return - - # If has old 'country' column, rename it (PostgreSQL supports direct rename) - if has_country and not has_country_name: - op.alter_column( - "customer_addresses", - "country", - new_column_name="country_name", - ) - - # Add country_iso if it doesn't exist - if not has_country_iso: - op.add_column( - "customer_addresses", - sa.Column("country_iso", sa.String(5), nullable=True) - ) - - # Backfill country_iso from country_name - for country_name, iso_code in COUNTRY_ISO_MAP.items(): - connection.execute( - text( - "UPDATE customer_addresses SET country_iso = :iso " - "WHERE country_name = :name" - ), - {"iso": iso_code, "name": country_name}, - ) - - # Set default for any remaining NULL values - connection.execute( - text( - "UPDATE customer_addresses SET country_iso = 'LU' " - "WHERE country_iso IS NULL" - ) - ) - - # Make country_iso NOT NULL (PostgreSQL supports direct alter) - op.alter_column( - "customer_addresses", - "country_iso", - existing_type=sa.String(5), - nullable=False, - ) - - -def downgrade() -> None: - connection = op.get_bind() - columns = get_column_names(connection, "customer_addresses") - - has_country_name = "country_name" in columns - has_country_iso = "country_iso" in columns - has_country = "country" in columns - - # Only downgrade if in the new state - if has_country_name and not has_country: - op.alter_column( - "customer_addresses", - "country_name", - new_column_name="country", - ) - - if has_country_iso: - op.drop_column("customer_addresses", "country_iso") diff --git a/alembic/versions/s7a8b9c0d1e2_add_storefront_locale_to_vendors.py b/alembic/versions/s7a8b9c0d1e2_add_storefront_locale_to_vendors.py deleted file mode 100644 index b11cb9a0..00000000 --- a/alembic/versions/s7a8b9c0d1e2_add_storefront_locale_to_vendors.py +++ /dev/null @@ -1,40 +0,0 @@ -# alembic/versions/s7a8b9c0d1e2_add_storefront_locale_to_vendors.py -"""Add storefront_locale to vendors for currency formatting. - -Revision ID: s7a8b9c0d1e2 -Revises: r6f7a8b9c0d1 -Create Date: 2026-01-02 20:00:00.000000 - -This migration adds a nullable storefront_locale field to vendors. -NULL means the vendor inherits from platform defaults. -Examples: 'fr-LU', 'de-DE', 'en-GB' -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "s7a8b9c0d1e2" -down_revision = "r6f7a8b9c0d1" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Add storefront_locale column to vendors table.""" - # Nullable - NULL means "inherit from platform default" - op.add_column( - "vendors", - sa.Column( - "storefront_locale", - sa.String(10), - nullable=True, - comment="Currency/number formatting locale (NULL = inherit from platform)", - ), - ) - - -def downgrade() -> None: - """Remove storefront_locale column from vendors table.""" - op.drop_column("vendors", "storefront_locale") diff --git a/alembic/versions/t001_rename_company_vendor_to_merchant_store.py b/alembic/versions/t001_rename_company_vendor_to_merchant_store.py deleted file mode 100644 index c9bc03bc..00000000 --- a/alembic/versions/t001_rename_company_vendor_to_merchant_store.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Rename Company/Vendor to Merchant/Store terminology. - -Revision ID: t001_terminology -Revises: loyalty_003_phase2 -Create Date: 2026-02-06 22:00:00.000000 - -Major terminology migration: -- companies -> merchants -- vendors -> stores -- company_id -> merchant_id (in all child tables) -- vendor_id -> store_id (in all child tables) -- vendor_code -> store_code -- letzshop_vendor_id -> letzshop_store_id -- letzshop_vendor_slug -> letzshop_store_slug -- vendor_name -> store_name (in marketplace_products) -- All vendor-prefixed tables renamed to store-prefixed -- company_loyalty_settings -> merchant_loyalty_settings -- letzshop_vendor_cache -> letzshop_store_cache -""" - -from typing import Sequence, Union - -from alembic import op -from sqlalchemy import text - -# revision identifiers, used by Alembic. -revision: str = "t001_terminology" -down_revision: Union[str, None] = "loyalty_003_phase2" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def _col_exists(table: str, col: str) -> bool: - """Check if column exists using raw SQL.""" - conn = op.get_bind() - result = conn.execute(text( - "SELECT 1 FROM information_schema.columns " - "WHERE table_schema='public' AND table_name=:t AND column_name=:c" - ), {"t": table, "c": col}) - return result.fetchone() is not None - - -def _table_exists(table: str) -> bool: - """Check if table exists using raw SQL.""" - conn = op.get_bind() - result = conn.execute(text( - "SELECT 1 FROM information_schema.tables " - "WHERE table_schema='public' AND table_name=:t" - ), {"t": table}) - return result.fetchone() is not None - - -def upgrade() -> None: - """Rename all Company/Vendor references to Merchant/Store.""" - - # ====================================================================== - # STEP 1: Rename columns in child tables FIRST (before renaming parent tables) - # ====================================================================== - - # --- company_id -> merchant_id --- - op.alter_column("vendors", "company_id", new_column_name="merchant_id") - op.alter_column("loyalty_programs", "company_id", new_column_name="merchant_id") - op.alter_column("loyalty_cards", "company_id", new_column_name="merchant_id") - op.alter_column("loyalty_transactions", "company_id", new_column_name="merchant_id") - op.alter_column("company_loyalty_settings", "company_id", new_column_name="merchant_id") - op.alter_column("staff_pins", "company_id", new_column_name="merchant_id") - - # --- vendor_id -> store_id (in all child tables) --- - op.alter_column("products", "vendor_id", new_column_name="store_id") - op.alter_column("customers", "vendor_id", new_column_name="store_id") - op.alter_column("customer_addresses", "vendor_id", new_column_name="store_id") - op.alter_column("orders", "vendor_id", new_column_name="store_id") - op.alter_column("order_item_exceptions", "vendor_id", new_column_name="store_id") - op.alter_column("invoices", "vendor_id", new_column_name="store_id") - op.alter_column("inventory", "vendor_id", new_column_name="store_id") - op.alter_column("inventory_transactions", "vendor_id", new_column_name="store_id") - op.alter_column("marketplace_import_jobs", "vendor_id", new_column_name="store_id") - op.alter_column("letzshop_fulfillment_queue", "vendor_id", new_column_name="store_id") - op.alter_column("letzshop_sync_logs", "vendor_id", new_column_name="store_id") - op.alter_column("letzshop_historical_import_jobs", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_users", "vendor_id", new_column_name="store_id") - op.alter_column("roles", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_domains", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_platforms", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_addons", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_subscriptions", "vendor_id", new_column_name="store_id") - op.alter_column("billing_history", "vendor_id", new_column_name="store_id") - op.alter_column("content_pages", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_themes", "vendor_id", new_column_name="store_id") - op.alter_column("media_files", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_email_templates", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_email_settings", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_letzshop_credentials", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_onboarding", "vendor_id", new_column_name="store_id") - op.alter_column("vendor_invoice_settings", "vendor_id", new_column_name="store_id") - op.alter_column("staff_pins", "vendor_id", new_column_name="store_id") - op.alter_column("loyalty_cards", "enrolled_at_vendor_id", new_column_name="enrolled_at_store_id") - op.alter_column("loyalty_transactions", "vendor_id", new_column_name="store_id") - - # Columns that may not exist yet (defined in models but not yet migrated) - if _col_exists("letzshop_fulfillment_queue", "claimed_by_vendor_id"): - op.alter_column("letzshop_fulfillment_queue", "claimed_by_vendor_id", new_column_name="claimed_by_store_id") - if _col_exists("admin_audit_logs", "vendor_id"): - op.alter_column("admin_audit_logs", "vendor_id", new_column_name="store_id") - if _col_exists("messages", "vendor_id"): - op.alter_column("messages", "vendor_id", new_column_name="store_id") - if _col_exists("conversations", "vendor_id"): - op.alter_column("conversations", "vendor_id", new_column_name="store_id") - if _table_exists("emails") and _col_exists("emails", "vendor_id"): - op.alter_column("emails", "vendor_id", new_column_name="store_id") - if _table_exists("carts") and _col_exists("carts", "vendor_id"): - op.alter_column("carts", "vendor_id", new_column_name="store_id") - - # --- Other vendor-prefixed columns --- - op.alter_column("vendors", "vendor_code", new_column_name="store_code") - op.alter_column("vendors", "letzshop_vendor_id", new_column_name="letzshop_store_id") - op.alter_column("vendors", "letzshop_vendor_slug", new_column_name="letzshop_store_slug") - op.alter_column("marketplace_products", "vendor_name", new_column_name="store_name") - - # ====================================================================== - # STEP 2: Rename parent tables - # ====================================================================== - op.rename_table("companies", "merchants") - op.rename_table("vendors", "stores") - - # ====================================================================== - # STEP 3: Rename vendor-prefixed child tables - # ====================================================================== - op.rename_table("vendor_users", "store_users") - op.rename_table("vendor_domains", "store_domains") - op.rename_table("vendor_platforms", "store_platforms") - op.rename_table("vendor_themes", "store_themes") - op.rename_table("vendor_email_templates", "store_email_templates") - op.rename_table("vendor_email_settings", "store_email_settings") - op.rename_table("vendor_addons", "store_addons") - op.rename_table("vendor_subscriptions", "store_subscriptions") - op.rename_table("vendor_letzshop_credentials", "store_letzshop_credentials") - op.rename_table("vendor_onboarding", "store_onboarding") - op.rename_table("vendor_invoice_settings", "store_invoice_settings") - op.rename_table("company_loyalty_settings", "merchant_loyalty_settings") - op.rename_table("letzshop_vendor_cache", "letzshop_store_cache") - - -def downgrade() -> None: - """Revert all Merchant/Store references back to Company/Vendor.""" - - # STEP 1: Revert table renames - op.rename_table("letzshop_store_cache", "letzshop_vendor_cache") - op.rename_table("merchant_loyalty_settings", "company_loyalty_settings") - op.rename_table("store_invoice_settings", "vendor_invoice_settings") - op.rename_table("store_onboarding", "vendor_onboarding") - op.rename_table("store_letzshop_credentials", "vendor_letzshop_credentials") - op.rename_table("store_subscriptions", "vendor_subscriptions") - op.rename_table("store_addons", "vendor_addons") - op.rename_table("store_email_settings", "vendor_email_settings") - op.rename_table("store_email_templates", "vendor_email_templates") - op.rename_table("store_themes", "vendor_themes") - op.rename_table("store_platforms", "vendor_platforms") - op.rename_table("store_domains", "vendor_domains") - op.rename_table("store_users", "vendor_users") - op.rename_table("stores", "vendors") - op.rename_table("merchants", "companies") - - # STEP 2: Revert column renames - op.alter_column("vendors", "store_code", new_column_name="vendor_code") - op.alter_column("vendors", "letzshop_store_id", new_column_name="letzshop_vendor_id") - op.alter_column("vendors", "letzshop_store_slug", new_column_name="letzshop_vendor_slug") - op.alter_column("marketplace_products", "store_name", new_column_name="vendor_name") - - op.alter_column("vendors", "merchant_id", new_column_name="company_id") - op.alter_column("loyalty_programs", "merchant_id", new_column_name="company_id") - op.alter_column("loyalty_cards", "merchant_id", new_column_name="company_id") - op.alter_column("loyalty_transactions", "merchant_id", new_column_name="company_id") - op.alter_column("company_loyalty_settings", "merchant_id", new_column_name="company_id") - op.alter_column("staff_pins", "merchant_id", new_column_name="company_id") - - op.alter_column("products", "store_id", new_column_name="vendor_id") - op.alter_column("customers", "store_id", new_column_name="vendor_id") - op.alter_column("customer_addresses", "store_id", new_column_name="vendor_id") - op.alter_column("orders", "store_id", new_column_name="vendor_id") - op.alter_column("order_item_exceptions", "store_id", new_column_name="vendor_id") - op.alter_column("invoices", "store_id", new_column_name="vendor_id") - op.alter_column("inventory", "store_id", new_column_name="vendor_id") - op.alter_column("inventory_transactions", "store_id", new_column_name="vendor_id") - op.alter_column("marketplace_import_jobs", "store_id", new_column_name="vendor_id") - op.alter_column("letzshop_fulfillment_queue", "store_id", new_column_name="vendor_id") - op.alter_column("letzshop_sync_logs", "store_id", new_column_name="vendor_id") - op.alter_column("letzshop_historical_import_jobs", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_users", "store_id", new_column_name="vendor_id") - op.alter_column("roles", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_domains", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_platforms", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_addons", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_subscriptions", "store_id", new_column_name="vendor_id") - op.alter_column("billing_history", "store_id", new_column_name="vendor_id") - op.alter_column("content_pages", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_themes", "store_id", new_column_name="vendor_id") - op.alter_column("media_files", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_email_templates", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_email_settings", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_letzshop_credentials", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_onboarding", "store_id", new_column_name="vendor_id") - op.alter_column("vendor_invoice_settings", "store_id", new_column_name="vendor_id") - op.alter_column("staff_pins", "store_id", new_column_name="vendor_id") - op.alter_column("loyalty_cards", "enrolled_at_store_id", new_column_name="enrolled_at_vendor_id") - op.alter_column("loyalty_transactions", "store_id", new_column_name="vendor_id") - - # Conditional columns - if _col_exists("letzshop_fulfillment_queue", "claimed_by_store_id"): - op.alter_column("letzshop_fulfillment_queue", "claimed_by_store_id", new_column_name="claimed_by_vendor_id") - if _col_exists("admin_audit_logs", "store_id"): - op.alter_column("admin_audit_logs", "store_id", new_column_name="vendor_id") - if _col_exists("messages", "store_id"): - op.alter_column("messages", "store_id", new_column_name="vendor_id") - if _col_exists("conversations", "store_id"): - op.alter_column("conversations", "store_id", new_column_name="vendor_id") - if _table_exists("emails") and _col_exists("emails", "store_id"): - op.alter_column("emails", "store_id", new_column_name="vendor_id") - if _table_exists("carts") and _col_exists("carts", "store_id"): - op.alter_column("carts", "store_id", new_column_name="vendor_id") diff --git a/alembic/versions/t8b9c0d1e2f3_add_password_reset_tokens.py b/alembic/versions/t8b9c0d1e2f3_add_password_reset_tokens.py deleted file mode 100644 index 329a3189..00000000 --- a/alembic/versions/t8b9c0d1e2f3_add_password_reset_tokens.py +++ /dev/null @@ -1,53 +0,0 @@ -"""add password_reset_tokens table - -Revision ID: t8b9c0d1e2f3 -Revises: s7a8b9c0d1e2 -Create Date: 2026-01-03 - -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "t8b9c0d1e2f3" -down_revision = "s7a8b9c0d1e2" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.create_table( - "password_reset_tokens", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("customer_id", sa.Integer(), nullable=False), - sa.Column("token_hash", sa.String(64), nullable=False), - sa.Column("expires_at", sa.DateTime(), nullable=False), - sa.Column("used_at", sa.DateTime(), nullable=True), - sa.Column( - "created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False - ), - sa.ForeignKeyConstraint( - ["customer_id"], - ["customers.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_password_reset_tokens_customer_id", - "password_reset_tokens", - ["customer_id"], - ) - op.create_index( - "ix_password_reset_tokens_token_hash", - "password_reset_tokens", - ["token_hash"], - ) - - -def downgrade() -> None: - op.drop_index("ix_password_reset_tokens_token_hash", table_name="password_reset_tokens") - op.drop_index("ix_password_reset_tokens_customer_id", table_name="password_reset_tokens") - op.drop_table("password_reset_tokens") diff --git a/alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py b/alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py deleted file mode 100644 index 9cf16718..00000000 --- a/alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py +++ /dev/null @@ -1,114 +0,0 @@ -# alembic/versions/u9c0d1e2f3g4_add_vendor_email_templates.py -"""Add vendor email templates and enhance email_templates table. - -Revision ID: u9c0d1e2f3g4 -Revises: t8b9c0d1e2f3 -Create Date: 2026-01-03 - -Changes: -- Add is_platform_only column to email_templates (templates that vendors cannot override) -- Add required_variables column to email_templates (JSON list of required variables) -- Create vendor_email_templates table for vendor-specific template overrides -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "u9c0d1e2f3g4" -down_revision = "t8b9c0d1e2f3" -branch_labels = None -depends_on = None - - -def upgrade(): - # Add new columns to email_templates - op.add_column( - "email_templates", - sa.Column("is_platform_only", sa.Boolean(), nullable=False, server_default="0"), - ) - op.add_column( - "email_templates", - sa.Column("required_variables", sa.Text(), nullable=True), - ) - - # Create vendor_email_templates table - op.create_table( - "vendor_email_templates", - sa.Column("id", sa.Integer(), nullable=False, autoincrement=True), - sa.Column("vendor_id", sa.Integer(), nullable=False), - sa.Column("template_code", sa.String(100), nullable=False), - sa.Column("language", sa.String(5), nullable=False, server_default="en"), - sa.Column("name", sa.String(255), nullable=True), - sa.Column("subject", sa.String(500), nullable=False), - sa.Column("body_html", sa.Text(), nullable=False), - sa.Column("body_text", sa.Text(), nullable=True), - sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"), - sa.Column( - "created_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.Column( - "updated_at", - sa.DateTime(), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - sa.PrimaryKeyConstraint("id"), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - name="fk_vendor_email_templates_vendor_id", - ondelete="CASCADE", - ), - sa.UniqueConstraint( - "vendor_id", - "template_code", - "language", - name="uq_vendor_email_template_code_language", - ), - ) - - # Create indexes for performance - op.create_index( - "ix_vendor_email_templates_vendor_id", - "vendor_email_templates", - ["vendor_id"], - ) - op.create_index( - "ix_vendor_email_templates_template_code", - "vendor_email_templates", - ["template_code"], - ) - op.create_index( - "ix_vendor_email_templates_lookup", - "vendor_email_templates", - ["vendor_id", "template_code", "language"], - ) - - # Add unique constraint to email_templates for code+language - # This ensures we can reliably look up platform templates - op.create_index( - "ix_email_templates_code_language", - "email_templates", - ["code", "language"], - unique=True, - ) - - -def downgrade(): - # Drop indexes - op.drop_index("ix_email_templates_code_language", table_name="email_templates") - op.drop_index("ix_vendor_email_templates_lookup", table_name="vendor_email_templates") - op.drop_index("ix_vendor_email_templates_template_code", table_name="vendor_email_templates") - op.drop_index("ix_vendor_email_templates_vendor_id", table_name="vendor_email_templates") - - # Drop vendor_email_templates table - op.drop_table("vendor_email_templates") - - # Remove new columns from email_templates - op.drop_column("email_templates", "required_variables") - op.drop_column("email_templates", "is_platform_only") diff --git a/alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py b/alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py deleted file mode 100644 index ac5307a1..00000000 --- a/alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py +++ /dev/null @@ -1,102 +0,0 @@ -# alembic/versions/v0a1b2c3d4e5_add_vendor_email_settings.py -"""Add vendor email settings table. - -Revision ID: v0a1b2c3d4e5 -Revises: u9c0d1e2f3g4 -Create Date: 2026-01-05 - -Changes: -- Create vendor_email_settings table for vendor SMTP/email provider configuration -- Vendors must configure this to send transactional emails -- Premium providers (SendGrid, Mailgun, SES) are tier-gated (Business+) -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "v0a1b2c3d4e5" -down_revision = "u9c0d1e2f3g4" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Create vendor_email_settings table - op.create_table( - "vendor_email_settings", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("vendor_id", sa.Integer(), nullable=False), - # Sender Identity - sa.Column("from_email", sa.String(255), nullable=False), - sa.Column("from_name", sa.String(100), nullable=False), - sa.Column("reply_to_email", sa.String(255), nullable=True), - # Signature/Footer - sa.Column("signature_text", sa.Text(), nullable=True), - sa.Column("signature_html", sa.Text(), nullable=True), - # Provider Configuration - sa.Column("provider", sa.String(20), nullable=False, default="smtp"), - # SMTP Settings - sa.Column("smtp_host", sa.String(255), nullable=True), - sa.Column("smtp_port", sa.Integer(), nullable=True, default=587), - sa.Column("smtp_username", sa.String(255), nullable=True), - sa.Column("smtp_password", sa.String(500), nullable=True), - sa.Column("smtp_use_tls", sa.Boolean(), nullable=False, default=True), - sa.Column("smtp_use_ssl", sa.Boolean(), nullable=False, default=False), - # SendGrid Settings - sa.Column("sendgrid_api_key", sa.String(500), nullable=True), - # Mailgun Settings - sa.Column("mailgun_api_key", sa.String(500), nullable=True), - sa.Column("mailgun_domain", sa.String(255), nullable=True), - # Amazon SES Settings - sa.Column("ses_access_key_id", sa.String(100), nullable=True), - sa.Column("ses_secret_access_key", sa.String(500), nullable=True), - sa.Column("ses_region", sa.String(50), nullable=True, default="eu-west-1"), - # Status & Verification - sa.Column("is_configured", sa.Boolean(), nullable=False, default=False), - sa.Column("is_verified", sa.Boolean(), nullable=False, default=False), - sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("verification_error", sa.Text(), nullable=True), - # Timestamps - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - # Constraints - sa.PrimaryKeyConstraint("id"), - sa.ForeignKeyConstraint( - ["vendor_id"], - ["vendors.id"], - name="fk_vendor_email_settings_vendor_id", - ondelete="CASCADE", - ), - sa.UniqueConstraint("vendor_id", name="uq_vendor_email_settings_vendor_id"), - ) - - # Create indexes - op.create_index( - "ix_vendor_email_settings_id", - "vendor_email_settings", - ["id"], - unique=False, - ) - op.create_index( - "ix_vendor_email_settings_vendor_id", - "vendor_email_settings", - ["vendor_id"], - unique=True, - ) - op.create_index( - "idx_vendor_email_settings_configured", - "vendor_email_settings", - ["vendor_id", "is_configured"], - ) - - -def downgrade() -> None: - # Drop indexes - op.drop_index("idx_vendor_email_settings_configured", table_name="vendor_email_settings") - op.drop_index("ix_vendor_email_settings_vendor_id", table_name="vendor_email_settings") - op.drop_index("ix_vendor_email_settings_id", table_name="vendor_email_settings") - - # Drop table - op.drop_table("vendor_email_settings") diff --git a/alembic/versions/w1b2c3d4e5f6_add_media_library_tables.py b/alembic/versions/w1b2c3d4e5f6_add_media_library_tables.py deleted file mode 100644 index 795b0827..00000000 --- a/alembic/versions/w1b2c3d4e5f6_add_media_library_tables.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Add media library tables - -Revision ID: w1b2c3d4e5f6 -Revises: v0a1b2c3d4e5 -Create Date: 2026-01-06 10:00:00.000000 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision: str = "w1b2c3d4e5f6" -down_revision: Union[str, None] = "v0a1b2c3d4e5" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # Create media_files table - op.create_table( - "media_files", - sa.Column("id", sa.Integer(), primary_key=True, index=True), - sa.Column("vendor_id", sa.Integer(), sa.ForeignKey("vendors.id"), nullable=False), - # File identification - sa.Column("filename", sa.String(255), nullable=False), - sa.Column("original_filename", sa.String(255)), - sa.Column("file_path", sa.String(500), nullable=False), - # File properties - sa.Column("media_type", sa.String(20), nullable=False), # image, video, document - sa.Column("mime_type", sa.String(100)), - sa.Column("file_size", sa.Integer()), - # Image/video dimensions - sa.Column("width", sa.Integer()), - sa.Column("height", sa.Integer()), - # Thumbnail - sa.Column("thumbnail_path", sa.String(500)), - # Metadata - sa.Column("alt_text", sa.String(500)), - sa.Column("description", sa.Text()), - sa.Column("folder", sa.String(100), default="general"), - sa.Column("tags", sa.JSON()), - sa.Column("extra_metadata", sa.JSON()), - # Status - sa.Column("is_optimized", sa.Boolean(), default=False), - sa.Column("optimized_size", sa.Integer()), - # Usage tracking - sa.Column("usage_count", sa.Integer(), default=0), - # Timestamps - sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(), onupdate=sa.func.now()), - ) - - # Create indexes for media_files - op.create_index("idx_media_vendor_id", "media_files", ["vendor_id"]) - op.create_index("idx_media_vendor_folder", "media_files", ["vendor_id", "folder"]) - op.create_index("idx_media_vendor_type", "media_files", ["vendor_id", "media_type"]) - op.create_index("idx_media_filename", "media_files", ["filename"]) - - # Create product_media table (many-to-many relationship) - op.create_table( - "product_media", - sa.Column("id", sa.Integer(), primary_key=True, index=True), - sa.Column( - "product_id", - sa.Integer(), - sa.ForeignKey("products.id", ondelete="CASCADE"), - nullable=False, - ), - sa.Column( - "media_id", - sa.Integer(), - sa.ForeignKey("media_files.id", ondelete="CASCADE"), - nullable=False, - ), - # Usage type - sa.Column("usage_type", sa.String(50), nullable=False, default="gallery"), - # Display order for galleries - sa.Column("display_order", sa.Integer(), default=0), - # Variant-specific - sa.Column("variant_id", sa.Integer()), - # Timestamps - sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), - sa.Column("updated_at", sa.DateTime(), onupdate=sa.func.now()), - # Unique constraint - sa.UniqueConstraint("product_id", "media_id", "usage_type", name="uq_product_media_usage"), - ) - - # Create indexes for product_media - op.create_index("idx_product_media_product", "product_media", ["product_id"]) - op.create_index("idx_product_media_media", "product_media", ["media_id"]) - # Note: Unique constraint is defined in the table creation above via SQLAlchemy model - # SQLite doesn't support adding constraints after table creation - - -def downgrade() -> None: - # Drop product_media table - op.drop_index("idx_product_media_media", table_name="product_media") - op.drop_index("idx_product_media_product", table_name="product_media") - op.drop_table("product_media") - - # Drop media_files table - op.drop_index("idx_media_filename", table_name="media_files") - op.drop_index("idx_media_vendor_type", table_name="media_files") - op.drop_index("idx_media_vendor_folder", table_name="media_files") - op.drop_index("idx_media_vendor_id", table_name="media_files") - op.drop_table("media_files") diff --git a/alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py b/alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py deleted file mode 100644 index b8e334bb..00000000 --- a/alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py +++ /dev/null @@ -1,43 +0,0 @@ -# alembic/versions/x2c3d4e5f6g7_make_marketplace_product_id_nullable.py -"""Make marketplace_product_id nullable for direct product creation. - -Revision ID: x2c3d4e5f6g7 -Revises: w1b2c3d4e5f6 -Create Date: 2026-01-06 23:15:00.000000 - -""" - -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "x2c3d4e5f6g7" -down_revision: str = "w1b2c3d4e5f6" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Make marketplace_product_id nullable to allow direct product creation.""" - # SQLite doesn't support ALTER COLUMN, so we need to recreate the table - # For SQLite, we use batch mode which handles this automatically - with op.batch_alter_table("products") as batch_op: - batch_op.alter_column( - "marketplace_product_id", - existing_type=sa.Integer(), - nullable=True, - ) - - -def downgrade() -> None: - """Revert marketplace_product_id to NOT NULL.""" - # Note: This will fail if there are any NULL values in the column - with op.batch_alter_table("products") as batch_op: - batch_op.alter_column( - "marketplace_product_id", - existing_type=sa.Integer(), - nullable=False, - ) diff --git a/alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py b/alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py deleted file mode 100644 index a7db7e52..00000000 --- a/alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py +++ /dev/null @@ -1,47 +0,0 @@ -# alembic/versions/y3d4e5f6g7h8_add_product_type_columns.py -"""Add is_digital and product_type columns to products table. - -Makes Product fully independent from MarketplaceProduct for product type info. - -Revision ID: y3d4e5f6g7h8 -Revises: x2c3d4e5f6g7 -Create Date: 2026-01-07 10:00:00.000000 - -""" - -from collections.abc import Sequence - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision: str = "y3d4e5f6g7h8" -down_revision: str = "x2c3d4e5f6g7" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Add is_digital and product_type columns to products table.""" - with op.batch_alter_table("products") as batch_op: - batch_op.add_column( - sa.Column("is_digital", sa.Boolean(), nullable=False, server_default="0") - ) - batch_op.add_column( - sa.Column( - "product_type", - sa.String(20), - nullable=False, - server_default="physical", - ) - ) - batch_op.create_index("idx_product_is_digital", ["is_digital"]) - - -def downgrade() -> None: - """Remove is_digital and product_type columns.""" - with op.batch_alter_table("products") as batch_op: - batch_op.drop_index("idx_product_is_digital") - batch_op.drop_column("product_type") - batch_op.drop_column("is_digital") diff --git a/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py b/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py deleted file mode 100644 index ea8b53b4..00000000 --- a/alembic/versions/z4e5f6a7b8c9_add_multi_platform_support.py +++ /dev/null @@ -1,374 +0,0 @@ -"""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 OMS platform ID for backfilling - 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") diff --git a/alembic/versions/z5f6g7h8i9j0_add_loyalty_platform.py b/alembic/versions/z5f6g7h8i9j0_add_loyalty_platform.py deleted file mode 100644 index 12e09ac9..00000000 --- a/alembic/versions/z5f6g7h8i9j0_add_loyalty_platform.py +++ /dev/null @@ -1,424 +0,0 @@ -"""add loyalty platform - -Revision ID: z5f6g7h8i9j0 -Revises: z4e5f6a7b8c9 -Create Date: 2026-01-19 12:00:00.000000 - -This migration adds the Loyalty+ platform: -1. Inserts loyalty platform record -2. Creates platform marketing pages (home, pricing, features, how-it-works) -3. Creates vendor default pages (about, rewards-catalog, terms, privacy) -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "z5f6g7h8i9j0" -down_revision: Union[str, None] = "z4e5f6a7b8c9" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - conn = op.get_bind() - - # ========================================================================= - # 1. Insert Loyalty 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 ('loyalty', 'Loyalty+', 'Customer loyalty program platform for Luxembourg businesses', - 'loyalty.lu', 'loyalty', 'fr', '["fr", "de", "en"]', true, true, - '{"primary_color": "#8B5CF6", "secondary_color": "#A78BFA"}', - '{"features": ["points", "rewards", "tiers", "analytics"]}', - CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """) - ) - - # Get the Loyalty platform ID - result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'loyalty'")) - loyalty_platform_id = result.fetchone()[0] - - # ========================================================================= - # 2. Create platform marketing pages (is_platform_page=True) - # ========================================================================= - platform_pages = [ - { - "slug": "home", - "title": "Loyalty+ - Customer Loyalty Platform", - "content": """
-

Build Customer Loyalty That Lasts

-

Reward your customers, increase retention, and grow your business with Loyalty+

-
- -
-
-

Points & Rewards

-

Create custom point systems that incentivize repeat purchases and customer engagement.

-
-
-

Member Tiers

-

Reward your best customers with exclusive benefits and VIP treatment.

-
-
-

Real-time Analytics

-

Track program performance and customer behavior with detailed insights.

-
-
""", - "meta_description": "Loyalty+ is Luxembourg's leading customer loyalty platform. Build lasting relationships with your customers through points, rewards, and personalized experiences.", - "show_in_header": False, - "show_in_footer": False, - "display_order": 0, - }, - { - "slug": "pricing", - "title": "Pricing - Loyalty+", - "content": """
-

Simple, Transparent Pricing

-

Choose the plan that fits your business

-
- -
-
-

Starter

-
€49/month
- -
- -
-

Enterprise

-
Custom
- -
-
""", - "meta_description": "Loyalty+ pricing plans starting at €49/month. Choose Starter, Growth, or Enterprise for your customer loyalty program.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 1, - }, - { - "slug": "features", - "title": "Features - Loyalty+", - "content": """
-

Powerful Features for Modern Loyalty

-

Everything you need to build and manage a successful loyalty program

-
- -
-

Points & Earning Rules

-

Create flexible point systems with custom earning rules based on purchases, actions, or special events.

- -
- -
-

Rewards Catalog

-

Offer enticing rewards that keep customers coming back.

- -
- -
-

Member Tiers

-

Recognize and reward your most loyal customers with tiered benefits.

- -
- -
-

Analytics & Insights

-

Make data-driven decisions with comprehensive analytics.

- -
""", - "meta_description": "Explore Loyalty+ features: points systems, rewards catalog, member tiers, and analytics. Build the perfect loyalty program for your business.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 2, - }, - { - "slug": "how-it-works", - "title": "How It Works - Loyalty+", - "content": """
-

Getting Started is Easy

-

Launch your loyalty program in just a few steps

-
- -
-
-
1
-

Sign Up

-

Create your account and choose your plan. No credit card required for the free trial.

-
-
-
2
-

Configure Your Program

-

Set up your point rules, rewards, and member tiers using our intuitive dashboard.

-
-
-
3
-

Integrate

-

Connect Loyalty+ to your POS, e-commerce, or app using our APIs and plugins.

-
-
-
4
-

Launch & Grow

-

Invite your customers and watch your loyalty program drive results.

-
-
- -
-

Ready to Build Customer Loyalty?

-

Start your free 14-day trial today.

- Get Started Free -
""", - "meta_description": "Learn how to launch your Loyalty+ program in 4 easy steps. Sign up, configure, integrate, and start building customer loyalty today.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 3, - }, - ] - - for page in platform_pages: - conn.execute( - sa.text(""" - INSERT INTO content_pages (platform_id, vendor_id, slug, title, content, content_format, - meta_description, is_published, is_platform_page, - show_in_header, show_in_footer, display_order, - created_at, updated_at) - VALUES (:platform_id, NULL, :slug, :title, :content, 'html', - :meta_description, true, true, - :show_in_header, :show_in_footer, :display_order, - CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """), - { - "platform_id": loyalty_platform_id, - "slug": page["slug"], - "title": page["title"], - "content": page["content"], - "meta_description": page["meta_description"], - "show_in_header": page["show_in_header"], - "show_in_footer": page["show_in_footer"], - "display_order": page["display_order"], - } - ) - - # ========================================================================= - # 3. Create vendor default pages (is_platform_page=False) - # ========================================================================= - vendor_defaults = [ - { - "slug": "about", - "title": "About Us", - "content": """
-

About Our Loyalty Program

-

Welcome to our customer loyalty program! We value your continued support and want to reward you for being part of our community.

- -

Why Join?

- - -

How It Works

-

Simply sign up, start earning points with every purchase, and redeem them for rewards you'll love.

-
""", - "meta_description": "Learn about our customer loyalty program. Earn points, unlock rewards, and enjoy exclusive member benefits.", - "show_in_header": False, - "show_in_footer": True, - "display_order": 10, - }, - { - "slug": "rewards-catalog", - "title": "Rewards Catalog", - "content": """
-

Rewards Catalog

-

Browse our selection of rewards and redeem your hard-earned points!

- -
-
-

Your rewards catalog will appear here once configured.

-
-
- -

How to Redeem

-
    -
  1. Check your point balance in your account
  2. -
  3. Browse available rewards
  4. -
  5. Click "Redeem" on your chosen reward
  6. -
  7. Use your reward code at checkout
  8. -
-
""", - "meta_description": "Browse and redeem your loyalty points for exclusive rewards, discounts, and special offers.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 11, - }, - { - "slug": "terms", - "title": "Loyalty Program Terms & Conditions", - "content": """
-

Loyalty Program Terms & Conditions

-

Last updated: January 2026

- -

1. Program Membership

-

Membership in our loyalty program is free and open to all customers who meet the eligibility requirements.

- -

2. Earning Points

-

Points are earned on qualifying purchases. The earning rate and qualifying purchases are determined by the program operator and may change with notice.

- -

3. Redeeming Points

-

Points can be redeemed for rewards as shown in the rewards catalog. Minimum point thresholds may apply.

- -

4. Point Expiration

-

Points may expire after a period of account inactivity. Members will be notified before points expire.

- -

5. Program Changes

-

We reserve the right to modify, suspend, or terminate the program with reasonable notice to members.

- -

6. Privacy

-

Your personal information is handled in accordance with our Privacy Policy.

-
""", - "meta_description": "Read the terms and conditions for our customer loyalty program including earning rules, redemption, and point expiration policies.", - "show_in_header": False, - "show_in_footer": True, - "show_in_legal": True, - "display_order": 20, - }, - { - "slug": "privacy", - "title": "Privacy Policy", - "content": """
-

Privacy Policy

-

Last updated: January 2026

- -

Information We Collect

-

We collect information you provide when joining our loyalty program, including:

- - -

How We Use Your Information

-

Your information helps us:

- - -

Data Protection

-

We implement appropriate security measures to protect your personal information in accordance with GDPR and Luxembourg data protection laws.

- -

Your Rights

-

You have the right to access, correct, or delete your personal data. Contact us to exercise these rights.

- -

Contact

-

For privacy inquiries, please contact our data protection officer.

-
""", - "meta_description": "Our privacy policy explains how we collect, use, and protect your personal information in our loyalty program.", - "show_in_header": False, - "show_in_footer": True, - "show_in_legal": True, - "display_order": 21, - }, - ] - - for page in vendor_defaults: - show_in_legal = page.get("show_in_legal", False) - conn.execute( - sa.text(""" - INSERT INTO content_pages (platform_id, vendor_id, slug, title, content, content_format, - meta_description, is_published, is_platform_page, - show_in_header, show_in_footer, show_in_legal, display_order, - created_at, updated_at) - VALUES (:platform_id, NULL, :slug, :title, :content, 'html', - :meta_description, true, false, - :show_in_header, :show_in_footer, :show_in_legal, :display_order, - CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """), - { - "platform_id": loyalty_platform_id, - "slug": page["slug"], - "title": page["title"], - "content": page["content"], - "meta_description": page["meta_description"], - "show_in_header": page["show_in_header"], - "show_in_footer": page["show_in_footer"], - "show_in_legal": show_in_legal, - "display_order": page["display_order"], - } - ) - - -def downgrade() -> None: - conn = op.get_bind() - - # Get the Loyalty platform ID - result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'loyalty'")) - row = result.fetchone() - if row: - loyalty_platform_id = row[0] - - # Delete all content pages for loyalty platform - conn.execute( - sa.text("DELETE FROM content_pages WHERE platform_id = :platform_id"), - {"platform_id": loyalty_platform_id} - ) - - # Delete vendor_platforms entries for loyalty - conn.execute( - sa.text("DELETE FROM vendor_platforms WHERE platform_id = :platform_id"), - {"platform_id": loyalty_platform_id} - ) - - # Delete loyalty platform - conn.execute(sa.text("DELETE FROM platforms WHERE code = 'loyalty'")) diff --git a/alembic/versions/z6g7h8i9j0k1_add_main_platform.py b/alembic/versions/z6g7h8i9j0k1_add_main_platform.py deleted file mode 100644 index d25de645..00000000 --- a/alembic/versions/z6g7h8i9j0k1_add_main_platform.py +++ /dev/null @@ -1,431 +0,0 @@ -"""add main platform for marketing site - -Revision ID: z6g7h8i9j0k1 -Revises: z5f6g7h8i9j0 -Create Date: 2026-01-19 14:00:00.000000 - -This migration adds the 'main' platform for the main marketing site: -1. Inserts main platform record (wizamart.lu) -2. Creates platform marketing pages (home, about, faq, pricing, contact) - -The 'main' platform serves as the marketing homepage at: -- Development: localhost:9999/ (no /platforms/ prefix) -- Production: wizamart.lu/ - -All other platforms are accessed via: -- Development: localhost:9999/platforms/{code}/ -- Production: {code}.lu or custom domain -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "z6g7h8i9j0k1" -down_revision: Union[str, None] = "z5f6g7h8i9j0" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - conn = op.get_bind() - - # ========================================================================= - # 1. Insert Main Marketing 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 ('main', 'Wizamart', 'Main marketing site showcasing all Wizamart platforms', - 'wizamart.lu', NULL, 'fr', '["fr", "de", "en"]', true, true, - '{"primary_color": "#2563EB", "secondary_color": "#3B82F6"}', - '{"is_marketing_site": true}', - CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """) - ) - - # Get the Main platform ID - result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'main'")) - main_platform_id = result.fetchone()[0] - - # ========================================================================= - # 2. Create platform marketing pages (is_platform_page=True) - # ========================================================================= - platform_pages = [ - { - "slug": "home", - "title": "Wizamart - E-commerce Solutions for Luxembourg", - "content": """
-

Build Your Business with Wizamart

-

All-in-one e-commerce, loyalty, and business solutions for Luxembourg merchants

-
- -
-

Our Platforms

- -
-

Wizamart OMS

-

Order Management System for multi-channel selling. Manage orders, inventory, and fulfillment from one dashboard.

- Learn More -
- -
-

Loyalty+

-

Customer loyalty platform to reward your customers and increase retention. Points, rewards, and member tiers.

- Learn More -
- -
-

Site Builder

-

Create beautiful websites for your local business. No coding required.

- Coming Soon -
-
- -
-

Why Choose Wizamart?

- -
""", - "meta_description": "Wizamart offers e-commerce, loyalty, and business solutions for Luxembourg merchants. OMS, Loyalty+, and Site Builder platforms.", - "show_in_header": False, - "show_in_footer": False, - "display_order": 0, - }, - { - "slug": "about", - "title": "About Wizamart", - "content": """
-

About Wizamart

- -
-

Our Mission

-

We're building the tools Luxembourg businesses need to thrive in the digital economy. From order management to customer loyalty, we provide the infrastructure that powers local commerce.

-
- -
-

Our Story

-

Wizamart was founded with a simple idea: Luxembourg businesses deserve world-class e-commerce tools that understand their unique needs. Local languages, local payment methods, local compliance - built in from the start.

-
- -
-

Our Team

-

We're a team of developers, designers, and business experts based in Luxembourg. We understand the local market because we're part of it.

-
- -
-

Our Values

- -
-
""", - "meta_description": "Learn about Wizamart, the Luxembourg-based company building e-commerce and business solutions for local merchants.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 1, - }, - { - "slug": "pricing", - "title": "Pricing - Wizamart", - "content": """
-

Choose Your Platform

-

Each platform has its own pricing. Choose the tools your business needs.

- -
-
-

Wizamart OMS

-

Order Management System

-
From €49/month
-
    -
  • Multi-channel order management
  • -
  • Inventory tracking
  • -
  • Shipping integrations
  • -
  • Analytics dashboard
  • -
- View OMS Pricing -
- -
-

Loyalty+

-

Customer Loyalty Platform

-
From €49/month
-
    -
  • Points & rewards system
  • -
  • Member tiers
  • -
  • Analytics & insights
  • -
  • POS integrations
  • -
- View Loyalty+ Pricing -
- -
-

Bundle & Save

-

Use multiple platforms together

-
Save up to 20%
-
    -
  • Seamless integration
  • -
  • Unified dashboard
  • -
  • Single invoice
  • -
  • Priority support
  • -
- Contact Sales -
-
-
""", - "meta_description": "Wizamart pricing for OMS, Loyalty+, and bundled solutions. Plans starting at €49/month.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 2, - }, - { - "slug": "faq", - "title": "FAQ - Frequently Asked Questions", - "content": """
-

Frequently Asked Questions

- -
-

General

- -
-

What is Wizamart?

-

Wizamart is a suite of business tools for Luxembourg merchants, including order management (OMS), customer loyalty (Loyalty+), and website building (Site Builder).

-
- -
-

Do I need to use all platforms?

-

No! Each platform works independently. Use one, two, or all three - whatever fits your business needs.

-
- -
-

What languages are supported?

-

All platforms support French, German, and English - the three main languages of Luxembourg.

-
-
- -
-

Billing & Pricing

- -
-

Is there a free trial?

-

Yes! All platforms offer a 14-day free trial with no credit card required.

-
- -
-

What payment methods do you accept?

-

We accept credit cards, SEPA direct debit, and bank transfers.

-
- -
-

Can I cancel anytime?

-

Yes, you can cancel your subscription at any time. No long-term contracts required.

-
-
- -
-

Support

- -
-

How do I get help?

-

All plans include email support. Professional and Business plans include priority support with faster response times.

-
- -
-

Do you offer onboarding?

-

Yes! We offer guided onboarding for all new customers to help you get started quickly.

-
-
-
""", - "meta_description": "Frequently asked questions about Wizamart platforms, pricing, billing, and support.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 3, - }, - { - "slug": "contact", - "title": "Contact Us - Wizamart", - "content": """
-

Contact Us

-

We'd love to hear from you. Get in touch with our team.

- -
-
-

Sales

-

Interested in our platforms? Let's talk about how we can help your business.

-

sales@wizamart.lu

-
- -
-

Support

-

Already a customer? Our support team is here to help.

-

support@wizamart.lu

-
- -
-

General Inquiries

-

For everything else, reach out to our general inbox.

-

hello@wizamart.lu

-
-
- -
-

Office

-

Wizamart S.à r.l.
- Luxembourg City
- Luxembourg

-
-
""", - "meta_description": "Contact Wizamart for sales, support, or general inquiries. We're here to help your Luxembourg business succeed.", - "show_in_header": True, - "show_in_footer": True, - "display_order": 4, - }, - { - "slug": "terms", - "title": "Terms of Service - Wizamart", - "content": """
-

Terms of Service

-

Last updated: January 2026

- -

These Terms of Service govern your use of Wizamart platforms and services.

- -

1. Acceptance of Terms

-

By accessing or using our services, you agree to be bound by these Terms.

- -

2. Services

-

Wizamart provides e-commerce and business management tools including order management, loyalty programs, and website building services.

- -

3. Account Registration

-

You must provide accurate information when creating an account and keep your login credentials secure.

- -

4. Fees and Payment

-

Subscription fees are billed in advance on a monthly or annual basis. Prices are listed in EUR and include applicable VAT for Luxembourg customers.

- -

5. Data Protection

-

We process personal data in accordance with our Privacy Policy and applicable data protection laws including GDPR.

- -

6. Limitation of Liability

-

Our liability is limited to the amount paid for services in the 12 months preceding any claim.

- -

7. Governing Law

-

These Terms are governed by Luxembourg law. Disputes shall be resolved in Luxembourg courts.

- -

8. Contact

-

For questions about these Terms, contact us at legal@wizamart.lu

-
""", - "meta_description": "Wizamart Terms of Service. Read the terms and conditions for using our e-commerce and business platforms.", - "show_in_header": False, - "show_in_footer": True, - "show_in_legal": True, - "display_order": 10, - }, - { - "slug": "privacy", - "title": "Privacy Policy - Wizamart", - "content": """
-

Privacy Policy

-

Last updated: January 2026

- -

Introduction

-

Wizamart S.à r.l. ("we", "us") is committed to protecting your privacy. This policy explains how we collect, use, and protect your personal data.

- -

Data Controller

-

Wizamart S.à r.l.
Luxembourg City, Luxembourg
Email: privacy@wizamart.lu

- -

Data We Collect

- - -

How We Use Your Data

- - -

Your Rights

-

Under GDPR, you have the right to:

- - -

Contact

-

To exercise your rights or ask questions, contact our Data Protection Officer at privacy@wizamart.lu

-
""", - "meta_description": "Wizamart Privacy Policy. Learn how we collect, use, and protect your personal data in compliance with GDPR.", - "show_in_header": False, - "show_in_footer": True, - "show_in_legal": True, - "display_order": 11, - }, - ] - - for page in platform_pages: - show_in_legal = page.get("show_in_legal", False) - conn.execute( - sa.text(""" - INSERT INTO content_pages (platform_id, vendor_id, slug, title, content, content_format, - meta_description, is_published, is_platform_page, - show_in_header, show_in_footer, show_in_legal, display_order, - created_at, updated_at) - VALUES (:platform_id, NULL, :slug, :title, :content, 'html', - :meta_description, true, true, - :show_in_header, :show_in_footer, :show_in_legal, :display_order, - CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) - """), - { - "platform_id": main_platform_id, - "slug": page["slug"], - "title": page["title"], - "content": page["content"], - "meta_description": page["meta_description"], - "show_in_header": page["show_in_header"], - "show_in_footer": page["show_in_footer"], - "show_in_legal": show_in_legal, - "display_order": page["display_order"], - } - ) - - -def downgrade() -> None: - conn = op.get_bind() - - # Get the Main platform ID - result = conn.execute(sa.text("SELECT id FROM platforms WHERE code = 'main'")) - row = result.fetchone() - if row: - main_platform_id = row[0] - - # Delete all content pages for main platform - conn.execute( - sa.text("DELETE FROM content_pages WHERE platform_id = :platform_id"), - {"platform_id": main_platform_id} - ) - - # Delete vendor_platforms entries for main (if any) - conn.execute( - sa.text("DELETE FROM vendor_platforms WHERE platform_id = :platform_id"), - {"platform_id": main_platform_id} - ) - - # Delete main platform - conn.execute(sa.text("DELETE FROM platforms WHERE code = 'main'")) diff --git a/alembic/versions/z7h8i9j0k1l2_fix_content_page_nullable_columns.py b/alembic/versions/z7h8i9j0k1l2_fix_content_page_nullable_columns.py deleted file mode 100644 index 12a0fe1f..00000000 --- a/alembic/versions/z7h8i9j0k1l2_fix_content_page_nullable_columns.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Fix content_page nullable boolean columns - -Revision ID: z7h8i9j0k1l2 -Revises: z6g7h8i9j0k1 -Create Date: 2026-01-20 - -This migration: -1. Sets NULL values to defaults for boolean and integer columns -2. Alters columns to be NOT NULL -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "z7h8i9j0k1l2" -down_revision = "z6g7h8i9j0k1" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # First, update any NULL values to defaults - op.execute(""" - UPDATE content_pages - SET display_order = 0 - WHERE display_order IS NULL - """) - - op.execute(""" - UPDATE content_pages - SET show_in_footer = true - WHERE show_in_footer IS NULL - """) - - op.execute(""" - UPDATE content_pages - SET show_in_header = false - WHERE show_in_header IS NULL - """) - - op.execute(""" - UPDATE content_pages - SET show_in_legal = false - WHERE show_in_legal IS NULL - """) - - # Now alter columns to be NOT NULL - op.alter_column( - "content_pages", - "display_order", - existing_type=sa.Integer(), - nullable=False, - server_default="0", - ) - - op.alter_column( - "content_pages", - "show_in_footer", - existing_type=sa.Boolean(), - nullable=False, - server_default="true", - ) - - op.alter_column( - "content_pages", - "show_in_header", - existing_type=sa.Boolean(), - nullable=False, - server_default="false", - ) - - op.alter_column( - "content_pages", - "show_in_legal", - existing_type=sa.Boolean(), - nullable=False, - server_default="false", - ) - - -def downgrade() -> None: - # Revert columns to nullable (no server default) - op.alter_column( - "content_pages", - "display_order", - existing_type=sa.Integer(), - nullable=True, - server_default=None, - ) - - op.alter_column( - "content_pages", - "show_in_footer", - existing_type=sa.Boolean(), - nullable=True, - server_default=None, - ) - - op.alter_column( - "content_pages", - "show_in_header", - existing_type=sa.Boolean(), - nullable=True, - server_default=None, - ) - - op.alter_column( - "content_pages", - "show_in_legal", - existing_type=sa.Boolean(), - nullable=True, - server_default=None, - ) diff --git a/alembic/versions/z8i9j0k1l2m3_add_sections_to_content_pages.py b/alembic/versions/z8i9j0k1l2m3_add_sections_to_content_pages.py deleted file mode 100644 index dbf4da8d..00000000 --- a/alembic/versions/z8i9j0k1l2m3_add_sections_to_content_pages.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Add sections column to content_pages - -Revision ID: z8i9j0k1l2m3 -Revises: z7h8i9j0k1l2 -Create Date: 2026-01-23 - -Adds sections JSON column for structured homepage editing with multi-language support. -The sections column stores hero, features, pricing, and cta configurations -with TranslatableText pattern for i18n. -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "z8i9j0k1l2m3" -down_revision = "z7h8i9j0k1l2" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column( - "content_pages", - sa.Column( - "sections", - sa.JSON(), - nullable=True, - comment="Structured homepage sections (hero, features, pricing, cta) with i18n", - ), - ) - - -def downgrade() -> None: - op.drop_column("content_pages", "sections") diff --git a/alembic/versions/z9j0k1l2m3n4_add_admin_platform_roles.py b/alembic/versions/z9j0k1l2m3n4_add_admin_platform_roles.py deleted file mode 100644 index 0f5ca272..00000000 --- a/alembic/versions/z9j0k1l2m3n4_add_admin_platform_roles.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Add admin platform roles (super admin + platform admin) - -Revision ID: z9j0k1l2m3n4 -Revises: z8i9j0k1l2m3 -Create Date: 2026-01-24 - -Adds support for super admin and platform admin roles: -- is_super_admin column on users table -- admin_platforms junction table for platform admin assignments - -Super admins have access to all platforms. -Platform admins are assigned to specific platforms via admin_platforms. -Existing admins are migrated to super admins for backward compatibility. -""" - -from datetime import UTC, datetime - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "z9j0k1l2m3n4" -down_revision = "z8i9j0k1l2m3" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # 1. Add is_super_admin column to users table - op.add_column( - "users", - sa.Column( - "is_super_admin", - sa.Boolean(), - nullable=False, - server_default="false", - comment="Whether this admin has access to all platforms (super admin)", - ), - ) - - # 2. Create admin_platforms junction table - op.create_table( - "admin_platforms", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "user_id", - sa.Integer(), - nullable=False, - comment="Reference to the admin user", - ), - sa.Column( - "platform_id", - sa.Integer(), - nullable=False, - comment="Reference to the platform", - ), - sa.Column( - "is_active", - sa.Boolean(), - nullable=False, - server_default="true", - comment="Whether the admin assignment is active", - ), - sa.Column( - "assigned_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.func.now(), - comment="When the admin was assigned to this platform", - ), - sa.Column( - "assigned_by_user_id", - sa.Integer(), - nullable=True, - comment="Super admin who made this assignment", - ), - 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(), - onupdate=sa.func.now(), - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["platform_id"], - ["platforms.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["assigned_by_user_id"], - ["users.id"], - ondelete="SET NULL", - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "platform_id", name="uq_admin_platform"), - ) - - # Create indexes for performance - op.create_index( - "idx_admin_platforms_user_id", - "admin_platforms", - ["user_id"], - ) - op.create_index( - "idx_admin_platforms_platform_id", - "admin_platforms", - ["platform_id"], - ) - op.create_index( - "idx_admin_platform_active", - "admin_platforms", - ["user_id", "platform_id", "is_active"], - ) - op.create_index( - "idx_admin_platform_user_active", - "admin_platforms", - ["user_id", "is_active"], - ) - - # 3. Migrate existing admins to super admins for backward compatibility - # All current admins get super admin access to maintain their existing permissions - op.execute("UPDATE users SET is_super_admin = TRUE WHERE role = 'admin'") - - -def downgrade() -> None: - # Drop indexes - op.drop_index("idx_admin_platform_user_active", table_name="admin_platforms") - op.drop_index("idx_admin_platform_active", table_name="admin_platforms") - op.drop_index("idx_admin_platforms_platform_id", table_name="admin_platforms") - op.drop_index("idx_admin_platforms_user_id", table_name="admin_platforms") - - # Drop admin_platforms table - op.drop_table("admin_platforms") - - # Drop is_super_admin column - op.drop_column("users", "is_super_admin") diff --git a/alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py b/alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py deleted file mode 100644 index b2c18cba..00000000 --- a/alembic/versions/za0k1l2m3n4o5_add_admin_menu_config.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Add admin menu configuration table - -Revision ID: za0k1l2m3n4o5 -Revises: z9j0k1l2m3n4 -Create Date: 2026-01-25 - -Adds configurable admin sidebar menus: -- Platform-level config: Controls which menu items platform admins see -- User-level config: Controls which menu items super admins see -- Opt-out model: All items visible by default -- Mandatory items enforced at application level (companies, vendors, users, settings) -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "za0k1l2m3n4o5" -down_revision = "z9j0k1l2m3n4" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Create admin_menu_configs table - op.create_table( - "admin_menu_configs", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "platform_id", - sa.Integer(), - nullable=True, - comment="Platform scope - applies to all platform admins of this platform", - ), - sa.Column( - "user_id", - sa.Integer(), - nullable=True, - comment="User scope - applies to this specific super admin", - ), - sa.Column( - "menu_item_id", - sa.String(50), - nullable=False, - comment="Menu item identifier from registry (e.g., 'products', 'inventory')", - ), - sa.Column( - "is_visible", - sa.Boolean(), - nullable=False, - server_default="true", - comment="Whether this menu item is visible (False = hidden)", - ), - 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(), - onupdate=sa.func.now(), - ), - # Foreign keys - sa.ForeignKeyConstraint( - ["platform_id"], - ["platforms.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["user_id"], - ["users.id"], - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id"), - # Unique constraints - sa.UniqueConstraint("platform_id", "menu_item_id", name="uq_platform_menu_config"), - sa.UniqueConstraint("user_id", "menu_item_id", name="uq_user_menu_config"), - # Check constraint: exactly one scope must be set - sa.CheckConstraint( - "(platform_id IS NOT NULL AND user_id IS NULL) OR " - "(platform_id IS NULL AND user_id IS NOT NULL)", - name="ck_admin_menu_config_scope", - ), - ) - - # Create indexes for performance - op.create_index( - "idx_admin_menu_configs_platform_id", - "admin_menu_configs", - ["platform_id"], - ) - op.create_index( - "idx_admin_menu_configs_user_id", - "admin_menu_configs", - ["user_id"], - ) - op.create_index( - "idx_admin_menu_configs_menu_item_id", - "admin_menu_configs", - ["menu_item_id"], - ) - op.create_index( - "idx_admin_menu_config_platform_visible", - "admin_menu_configs", - ["platform_id", "is_visible"], - ) - op.create_index( - "idx_admin_menu_config_user_visible", - "admin_menu_configs", - ["user_id", "is_visible"], - ) - - -def downgrade() -> None: - # Drop indexes - op.drop_index("idx_admin_menu_config_user_visible", table_name="admin_menu_configs") - op.drop_index("idx_admin_menu_config_platform_visible", table_name="admin_menu_configs") - op.drop_index("idx_admin_menu_configs_menu_item_id", table_name="admin_menu_configs") - op.drop_index("idx_admin_menu_configs_user_id", table_name="admin_menu_configs") - op.drop_index("idx_admin_menu_configs_platform_id", table_name="admin_menu_configs") - - # Drop table - op.drop_table("admin_menu_configs") diff --git a/alembic/versions/zb1l2m3n4o5p6_add_frontend_type_to_menu_config.py b/alembic/versions/zb1l2m3n4o5p6_add_frontend_type_to_menu_config.py deleted file mode 100644 index ded81419..00000000 --- a/alembic/versions/zb1l2m3n4o5p6_add_frontend_type_to_menu_config.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Add frontend_type to admin_menu_configs - -Revision ID: zb1l2m3n4o5p6 -Revises: za0k1l2m3n4o5 -Create Date: 2026-01-25 - -Adds frontend_type column to support both admin and vendor menu configuration: -- 'admin': Admin panel menus (super admins, platform admins) -- 'vendor': Vendor dashboard menus (configured per platform) - -Also updates unique constraints to include frontend_type and adds -a check constraint ensuring user_id scope is only used for admin frontend. -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "zb1l2m3n4o5p6" -down_revision = "za0k1l2m3n4o5" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # 1. Create the enum type for frontend_type - frontend_type_enum = sa.Enum('admin', 'vendor', name='frontendtype') - frontend_type_enum.create(op.get_bind(), checkfirst=True) - - # 2. Add frontend_type column with default value - op.add_column( - "admin_menu_configs", - sa.Column( - "frontend_type", - sa.Enum('admin', 'vendor', name='frontendtype'), - nullable=False, - server_default="admin", - comment="Which frontend this config applies to (admin or vendor)", - ), - ) - - # 3. Create index on frontend_type - op.create_index( - "idx_admin_menu_configs_frontend_type", - "admin_menu_configs", - ["frontend_type"], - ) - - # 4. Drop old unique constraints - op.drop_constraint("uq_platform_menu_config", "admin_menu_configs", type_="unique") - op.drop_constraint("uq_user_menu_config", "admin_menu_configs", type_="unique") - - # 5. Create new unique constraints that include frontend_type - op.create_unique_constraint( - "uq_frontend_platform_menu_config", - "admin_menu_configs", - ["frontend_type", "platform_id", "menu_item_id"], - ) - op.create_unique_constraint( - "uq_frontend_user_menu_config", - "admin_menu_configs", - ["frontend_type", "user_id", "menu_item_id"], - ) - - # 6. Add check constraint: user_id scope only allowed for admin frontend - op.create_check_constraint( - "ck_user_scope_admin_only", - "admin_menu_configs", - "(user_id IS NULL) OR (frontend_type = 'admin')", - ) - - # 7. Create composite indexes for common queries - op.create_index( - "idx_admin_menu_config_frontend_platform", - "admin_menu_configs", - ["frontend_type", "platform_id"], - ) - op.create_index( - "idx_admin_menu_config_frontend_user", - "admin_menu_configs", - ["frontend_type", "user_id"], - ) - - -def downgrade() -> None: - # Drop new indexes - op.drop_index("idx_admin_menu_config_frontend_user", table_name="admin_menu_configs") - op.drop_index("idx_admin_menu_config_frontend_platform", table_name="admin_menu_configs") - - # Drop check constraint - op.drop_constraint("ck_user_scope_admin_only", "admin_menu_configs", type_="check") - - # Drop new unique constraints - op.drop_constraint("uq_frontend_user_menu_config", "admin_menu_configs", type_="unique") - op.drop_constraint("uq_frontend_platform_menu_config", "admin_menu_configs", type_="unique") - - # Restore old unique constraints - op.create_unique_constraint( - "uq_platform_menu_config", - "admin_menu_configs", - ["platform_id", "menu_item_id"], - ) - op.create_unique_constraint( - "uq_user_menu_config", - "admin_menu_configs", - ["user_id", "menu_item_id"], - ) - - # Drop frontend_type index - op.drop_index("idx_admin_menu_configs_frontend_type", table_name="admin_menu_configs") - - # Drop frontend_type column - op.drop_column("admin_menu_configs", "frontend_type") - - # Drop the enum type - sa.Enum('admin', 'vendor', name='frontendtype').drop(op.get_bind(), checkfirst=True) diff --git a/alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py b/alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py deleted file mode 100644 index 4f31fc16..00000000 --- a/alembic/versions/zc2m3n4o5p6q7_add_platform_modules_table.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Add platform modules table - -Revision ID: zc2m3n4o5p6q7 -Revises: zb1l2m3n4o5p6 -Create Date: 2026-01-26 - -Adds platform_modules junction table for tracking module enablement per platform: -- Auditability: Track when modules were enabled/disabled and by whom -- Configuration: Per-module settings specific to each platform -- State tracking: Explicit enabled/disabled states with timestamps - -This replaces the simpler Platform.settings["enabled_modules"] JSON approach -for better auditability and query capabilities. -""" - -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = "zc2m3n4o5p6q7" -down_revision = "zb1l2m3n4o5p6" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - # Create platform_modules table - op.create_table( - "platform_modules", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column( - "platform_id", - sa.Integer(), - nullable=False, - comment="Platform this module configuration belongs to", - ), - sa.Column( - "module_code", - sa.String(50), - nullable=False, - comment="Module code (e.g., 'billing', 'inventory', 'orders')", - ), - sa.Column( - "is_enabled", - sa.Boolean(), - nullable=False, - server_default="true", - comment="Whether this module is currently enabled for the platform", - ), - sa.Column( - "enabled_at", - sa.DateTime(timezone=True), - nullable=True, - comment="When the module was last enabled", - ), - sa.Column( - "enabled_by_user_id", - sa.Integer(), - nullable=True, - comment="User who enabled the module", - ), - sa.Column( - "disabled_at", - sa.DateTime(timezone=True), - nullable=True, - comment="When the module was last disabled", - ), - sa.Column( - "disabled_by_user_id", - sa.Integer(), - nullable=True, - comment="User who disabled the module", - ), - sa.Column( - "config", - sa.JSON(), - nullable=False, - server_default="{}", - comment="Module-specific configuration for this platform", - ), - 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(), - onupdate=sa.func.now(), - ), - # Primary key - sa.PrimaryKeyConstraint("id"), - # Foreign keys - sa.ForeignKeyConstraint( - ["platform_id"], - ["platforms.id"], - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["enabled_by_user_id"], - ["users.id"], - ondelete="SET NULL", - ), - sa.ForeignKeyConstraint( - ["disabled_by_user_id"], - ["users.id"], - ondelete="SET NULL", - ), - # Unique constraint - one config per platform/module pair - sa.UniqueConstraint("platform_id", "module_code", name="uq_platform_module"), - ) - - # Create indexes for performance - op.create_index( - "idx_platform_module_platform_id", - "platform_modules", - ["platform_id"], - ) - op.create_index( - "idx_platform_module_code", - "platform_modules", - ["module_code"], - ) - op.create_index( - "idx_platform_module_enabled", - "platform_modules", - ["platform_id", "is_enabled"], - ) - - -def downgrade() -> None: - # Drop indexes - op.drop_index("idx_platform_module_enabled", table_name="platform_modules") - op.drop_index("idx_platform_module_code", table_name="platform_modules") - op.drop_index("idx_platform_module_platform_id", table_name="platform_modules") - - # Drop table - op.drop_table("platform_modules") diff --git a/alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py b/alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py deleted file mode 100644 index 00af6d27..00000000 --- a/alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py +++ /dev/null @@ -1,83 +0,0 @@ -# alembic/versions/zd3n4o5p6q7r8_promote_cms_customers_to_core.py -"""Promote CMS and Customers modules to core. - -Revision ID: zd3n4o5p6q7r8 -Revises: ze4o5p6q7r8s9 -Create Date: 2026-01-27 10:10:00.000000 - -This migration ensures that CMS and Customers modules are enabled for all platforms, -since they are now core modules that cannot be disabled. -""" - -from datetime import datetime, timezone - -from alembic import op -import sqlalchemy as sa - -# revision identifiers, used by Alembic. -revision = "zd3n4o5p6q7r8" -down_revision = "ze4o5p6q7r8s9" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Enable CMS and Customers modules for all platforms.""" - connection = op.get_bind() - - # Get all platform IDs - platforms = connection.execute( - sa.text("SELECT id FROM platforms") - ).fetchall() - - now = datetime.now(timezone.utc) - core_modules = ["cms", "customers"] - - for (platform_id,) in platforms: - for module_code in core_modules: - # Check if record exists - existing = connection.execute( - sa.text( - """ - SELECT id FROM platform_modules - WHERE platform_id = :platform_id AND module_code = :module_code - """ - ), - {"platform_id": platform_id, "module_code": module_code}, - ).fetchone() - - if existing: - # Update to enabled - connection.execute( - sa.text( - """ - UPDATE platform_modules - SET is_enabled = true, enabled_at = :now - WHERE platform_id = :platform_id AND module_code = :module_code - """ - ), - {"platform_id": platform_id, "module_code": module_code, "now": now}, - ) - else: - # Insert new enabled record - connection.execute( - sa.text( - """ - INSERT INTO platform_modules (platform_id, module_code, is_enabled, enabled_at, config) - VALUES (:platform_id, :module_code, true, :now, '{}') - """ - ), - {"platform_id": platform_id, "module_code": module_code, "now": now}, - ) - - # Note: JSON settings update skipped - platform_modules table is the primary - # mechanism now. Legacy JSON settings will be handled by ModuleService fallback. - - -def downgrade() -> None: - """ - Note: This doesn't actually disable CMS and Customers since that would - break functionality. It just removes the explicit enabling done by upgrade. - """ - # No-op: We don't want to disable core modules - pass diff --git a/alembic/versions/ze4o5p6q7r8s9_rename_platform_admin_to_tenancy.py b/alembic/versions/ze4o5p6q7r8s9_rename_platform_admin_to_tenancy.py deleted file mode 100644 index 88bcfa69..00000000 --- a/alembic/versions/ze4o5p6q7r8s9_rename_platform_admin_to_tenancy.py +++ /dev/null @@ -1,60 +0,0 @@ -# alembic/versions/ze4o5p6q7r8s9_rename_platform_admin_to_tenancy.py -"""Rename platform-admin module to tenancy. - -Revision ID: ze4o5p6q7r8s9 -Revises: zc2m3n4o5p6q7 -Create Date: 2026-01-27 10:00:00.000000 - -""" - -from alembic import op - -# revision identifiers, used by Alembic. -revision = "ze4o5p6q7r8s9" -down_revision = "zc2m3n4o5p6q7" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - """Rename platform-admin to tenancy in platform_modules table.""" - # Update module_code in platform_modules junction table - op.execute( - """ - UPDATE platform_modules - SET module_code = 'tenancy' - WHERE module_code = 'platform-admin' - """ - ) - - # Also update any JSON settings that might reference the old module code - # This handles Platform.settings["enabled_modules"] for legacy data - # Note: settings column is JSON type, so we use text replacement approach - op.execute( - """ - UPDATE platforms - SET settings = REPLACE(settings::text, '"platform-admin"', '"tenancy"')::json - WHERE settings::text LIKE '%"platform-admin"%' - """ - ) - - -def downgrade() -> None: - """Revert tenancy back to platform-admin.""" - # Revert module_code in platform_modules junction table - op.execute( - """ - UPDATE platform_modules - SET module_code = 'platform-admin' - WHERE module_code = 'tenancy' - """ - ) - - # Revert JSON settings - op.execute( - """ - UPDATE platforms - SET settings = REPLACE(settings::text, '"tenancy"', '"platform-admin"')::json - WHERE settings::text LIKE '%"tenancy"%' - """ - ) diff --git a/app/modules/billing/definition.py b/app/modules/billing/definition.py index 17d8c0a6..3e716cf0 100644 --- a/app/modules/billing/definition.py +++ b/app/modules/billing/definition.py @@ -233,6 +233,7 @@ billing_module = ModuleDefinition( schemas_path="app.modules.billing.schemas", exceptions_path="app.modules.billing.exceptions", tasks_path="app.modules.billing.tasks", + migrations_path="migrations", # ========================================================================= # Scheduled Tasks # ========================================================================= diff --git a/app/modules/billing/migrations/versions/billing_001_initial.py b/app/modules/billing/migrations/versions/billing_001_initial.py new file mode 100644 index 00000000..eaa49168 --- /dev/null +++ b/app/modules/billing/migrations/versions/billing_001_initial.py @@ -0,0 +1,233 @@ +"""billing initial - subscription tiers, addons, billing history + +Revision ID: billing_001 +Revises: core_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "billing_001" +down_revision = "core_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- subscription_tiers --- + op.create_table( + "subscription_tiers", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=True, index=True, comment="Platform this tier belongs to (NULL = global tier)"), + sa.Column("code", sa.String(30), nullable=False, index=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("price_monthly_cents", sa.Integer(), nullable=False), + sa.Column("price_annual_cents", sa.Integer(), nullable=True), + sa.Column("stripe_product_id", sa.String(100), nullable=True), + sa.Column("stripe_price_monthly_id", sa.String(100), nullable=True), + sa.Column("stripe_price_annual_id", sa.String(100), nullable=True), + sa.Column("display_order", sa.Integer(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("is_public", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_tier_platform_active", "subscription_tiers", ["platform_id", "is_active"]) + + # --- store_platforms (depends on subscription_tiers) --- + op.create_table( + "store_platforms", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the store"), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Reference to the platform"), + sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True, comment="Platform-specific subscription tier"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", comment="Whether the store is active on this platform"), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default="false", comment="Whether this is the store's primary platform"), + sa.Column("custom_subdomain", sa.String(100), nullable=True, comment="Platform-specific subdomain (if different from main subdomain)"), + sa.Column("settings", sa.JSON(), nullable=True, server_default="{}", comment="Platform-specific store settings"), + sa.Column("joined_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, comment="When the store joined this platform"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("store_id", "platform_id", name="uq_store_platform"), + ) + op.create_index("idx_store_platform_active", "store_platforms", ["store_id", "platform_id", "is_active"]) + op.create_index("idx_store_platform_primary", "store_platforms", ["store_id", "is_primary"]) + + # --- tier_feature_limits --- + op.create_table( + "tier_feature_limits", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("feature_code", sa.String(80), nullable=False, index=True), + sa.Column("limit_value", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("tier_id", "feature_code", name="uq_tier_feature_code"), + ) + op.create_index("idx_tier_feature_lookup", "tier_feature_limits", ["tier_id", "feature_code"]) + + # --- addon_products --- + op.create_table( + "addon_products", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("code", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(100), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category", sa.String(50), nullable=False, index=True), + sa.Column("price_cents", sa.Integer(), nullable=False), + sa.Column("billing_period", sa.String(20), nullable=False, server_default="monthly"), + sa.Column("quantity_unit", sa.String(50), nullable=True), + sa.Column("quantity_value", sa.Integer(), nullable=True), + sa.Column("stripe_product_id", sa.String(100), nullable=True), + sa.Column("stripe_price_id", sa.String(100), nullable=True), + sa.Column("display_order", sa.Integer(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- store_addons --- + op.create_table( + "store_addons", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("addon_product_id", sa.Integer(), sa.ForeignKey("addon_products.id"), nullable=False, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="active", index=True), + sa.Column("domain_name", sa.String(255), nullable=True, index=True), + sa.Column("quantity", sa.Integer(), nullable=False, server_default="1"), + sa.Column("stripe_subscription_item_id", sa.String(100), nullable=True), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=True), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_store_addon_status", "store_addons", ["store_id", "status"]) + op.create_index("idx_store_addon_product", "store_addons", ["store_id", "addon_product_id"]) + + # --- merchant_subscriptions --- + op.create_table( + "merchant_subscriptions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="trial", index=True), + sa.Column("is_annual", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), + sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), + sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("stripe_customer_id", sa.String(100), nullable=True, index=True), + sa.Column("stripe_subscription_id", sa.String(100), nullable=True, index=True), + sa.Column("stripe_payment_method_id", sa.String(100), nullable=True), + sa.Column("payment_retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_payment_error", sa.Text(), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancellation_reason", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_platform_subscription"), + ) + op.create_index("idx_merchant_sub_status", "merchant_subscriptions", ["merchant_id", "status"]) + op.create_index("idx_merchant_sub_platform", "merchant_subscriptions", ["platform_id", "status"]) + + # --- merchant_feature_overrides --- + op.create_table( + "merchant_feature_overrides", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("feature_code", sa.String(80), nullable=False, index=True), + sa.Column("limit_value", sa.Integer(), nullable=True), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("reason", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("merchant_id", "platform_id", "feature_code", name="uq_merchant_platform_feature"), + ) + op.create_index("idx_merchant_override_lookup", "merchant_feature_overrides", ["merchant_id", "platform_id", "feature_code"]) + + # --- stripe_webhook_events --- + op.create_table( + "stripe_webhook_events", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("event_id", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("event_type", sa.String(100), nullable=False, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("payload_encrypted", sa.Text(), nullable=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True, index=True), + sa.Column("merchant_subscription_id", sa.Integer(), sa.ForeignKey("merchant_subscriptions.id"), nullable=True, index=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_webhook_event_type_status", "stripe_webhook_events", ["event_type", "status"]) + + # --- billing_history --- + op.create_table( + "billing_history", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id"), nullable=True, index=True), + sa.Column("stripe_invoice_id", sa.String(100), unique=True, nullable=True, index=True), + sa.Column("stripe_payment_intent_id", sa.String(100), nullable=True), + sa.Column("invoice_number", sa.String(50), nullable=True), + sa.Column("invoice_date", sa.DateTime(timezone=True), nullable=False), + sa.Column("due_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("subtotal_cents", sa.Integer(), nullable=False), + sa.Column("tax_cents", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_cents", sa.Integer(), nullable=False), + sa.Column("amount_paid_cents", sa.Integer(), nullable=False, server_default="0"), + sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"), + sa.Column("status", sa.String(20), nullable=False, index=True), + sa.Column("invoice_pdf_url", sa.String(500), nullable=True), + sa.Column("hosted_invoice_url", sa.String(500), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("line_items", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_billing_store_date", "billing_history", ["store_id", "invoice_date"]) + op.create_index("idx_billing_status", "billing_history", ["store_id", "status"]) + + # --- capacity_snapshots --- + op.create_table( + "capacity_snapshots", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("snapshot_date", sa.DateTime(timezone=True), unique=True, nullable=False, index=True), + sa.Column("total_stores", sa.Integer(), nullable=False, server_default="0"), + sa.Column("active_stores", sa.Integer(), nullable=False, server_default="0"), + sa.Column("trial_stores", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_subscriptions", sa.Integer(), nullable=False, server_default="0"), + sa.Column("active_subscriptions", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_products", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_orders_month", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_team_members", sa.Integer(), nullable=False, server_default="0"), + sa.Column("storage_used_gb", sa.Numeric(10, 2), nullable=False, server_default="0"), + sa.Column("db_size_mb", sa.Numeric(10, 2), nullable=False, server_default="0"), + sa.Column("theoretical_products_limit", sa.Integer(), nullable=True), + sa.Column("theoretical_orders_limit", sa.Integer(), nullable=True), + sa.Column("theoretical_team_limit", sa.Integer(), nullable=True), + sa.Column("tier_distribution", sa.JSON(), nullable=True), + sa.Column("avg_response_ms", sa.Integer(), nullable=True), + sa.Column("peak_cpu_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("peak_memory_percent", sa.Numeric(5, 2), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_capacity_snapshots_date", "capacity_snapshots", ["snapshot_date"], unique=False) + + +def downgrade() -> None: + op.drop_table("capacity_snapshots") + op.drop_table("billing_history") + op.drop_table("stripe_webhook_events") + op.drop_table("merchant_feature_overrides") + op.drop_table("merchant_subscriptions") + op.drop_table("store_addons") + op.drop_table("addon_products") + op.drop_table("tier_feature_limits") + op.drop_table("store_platforms") + op.drop_table("subscription_tiers") diff --git a/app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py b/app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py deleted file mode 100644 index 2f7612ee..00000000 --- a/app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py +++ /dev/null @@ -1,179 +0,0 @@ -# app/modules/billing/migrations/versions/billing_001_merchant_subscriptions_and_feature_limits.py -""" -Merchant subscriptions and feature limits migration. - -Creates: -- merchant_subscriptions table (replaces store_subscriptions) -- tier_feature_limits table (replaces hardcoded limit columns) -- merchant_feature_overrides table (replaces custom_*_limit columns) - -Drops: -- store_subscriptions table -- features table - -Alters: -- subscription_tiers: removes limit columns and features JSON - -Revision ID: billing_001 -""" - -from alembic import op -import sqlalchemy as sa - - -# Revision identifiers -revision = "billing_001" -down_revision = None -branch_labels = ("billing",) -depends_on = None - - -def upgrade() -> None: - # ======================================================================== - # Create merchant_subscriptions table - # ======================================================================== - op.create_table( - "merchant_subscriptions", - sa.Column("id", sa.Integer(), primary_key=True, index=True), - sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True), - sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True), - sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="SET NULL"), nullable=True, index=True), - sa.Column("status", sa.String(20), nullable=False, server_default="trial", index=True), - sa.Column("is_annual", sa.Boolean(), nullable=False, server_default="0"), - sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), - sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), - sa.Column("trial_ends_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("stripe_customer_id", sa.String(100), nullable=True, index=True), - sa.Column("stripe_subscription_id", sa.String(100), nullable=True, index=True), - sa.Column("stripe_payment_method_id", sa.String(100), nullable=True), - sa.Column("payment_retry_count", sa.Integer(), nullable=False, server_default="0"), - sa.Column("last_payment_error", sa.Text(), nullable=True), - sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("cancellation_reason", sa.Text(), 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.UniqueConstraint("merchant_id", "platform_id", name="uq_merchant_platform_subscription"), - ) - op.create_index("idx_merchant_sub_status", "merchant_subscriptions", ["merchant_id", "status"]) - op.create_index("idx_merchant_sub_platform", "merchant_subscriptions", ["platform_id", "status"]) - - # ======================================================================== - # Create tier_feature_limits table - # ======================================================================== - op.create_table( - "tier_feature_limits", - sa.Column("id", sa.Integer(), primary_key=True, index=True), - sa.Column("tier_id", sa.Integer(), sa.ForeignKey("subscription_tiers.id", ondelete="CASCADE"), nullable=False, index=True), - sa.Column("feature_code", sa.String(80), nullable=False, index=True), - sa.Column("limit_value", sa.Integer(), 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.UniqueConstraint("tier_id", "feature_code", name="uq_tier_feature_code"), - ) - op.create_index("idx_tier_feature_lookup", "tier_feature_limits", ["tier_id", "feature_code"]) - - # ======================================================================== - # Create merchant_feature_overrides table - # ======================================================================== - op.create_table( - "merchant_feature_overrides", - sa.Column("id", sa.Integer(), primary_key=True, index=True), - sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True), - sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True), - sa.Column("feature_code", sa.String(80), nullable=False, index=True), - sa.Column("limit_value", sa.Integer(), nullable=True), - sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default="1"), - sa.Column("reason", sa.String(255), 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.UniqueConstraint("merchant_id", "platform_id", "feature_code", name="uq_merchant_platform_feature"), - ) - op.create_index("idx_merchant_override_lookup", "merchant_feature_overrides", ["merchant_id", "platform_id", "feature_code"]) - - # ======================================================================== - # Drop legacy tables - # ======================================================================== - op.drop_table("store_subscriptions") - op.drop_table("features") - - # ======================================================================== - # Remove legacy columns from subscription_tiers - # ======================================================================== - with op.batch_alter_table("subscription_tiers") as batch_op: - batch_op.drop_column("orders_per_month") - batch_op.drop_column("products_limit") - batch_op.drop_column("team_members") - batch_op.drop_column("order_history_months") - batch_op.drop_column("cms_pages_limit") - batch_op.drop_column("cms_custom_pages_limit") - batch_op.drop_column("features") - - # ======================================================================== - # Update stripe_webhook_events FK to merchant_subscriptions - # ======================================================================== - with op.batch_alter_table("stripe_webhook_events") as batch_op: - batch_op.drop_column("subscription_id") - batch_op.add_column( - sa.Column("merchant_subscription_id", sa.Integer(), - sa.ForeignKey("merchant_subscriptions.id"), nullable=True, index=True) - ) - - # ======================================================================== - # Add merchant_id to billing_history - # ======================================================================== - with op.batch_alter_table("billing_history") as batch_op: - batch_op.add_column( - sa.Column("merchant_id", sa.Integer(), - sa.ForeignKey("merchants.id"), nullable=True, index=True) - ) - - -def downgrade() -> None: - # Remove merchant_id from billing_history - with op.batch_alter_table("billing_history") as batch_op: - batch_op.drop_column("merchant_id") - - # Restore subscription_id on stripe_webhook_events - with op.batch_alter_table("stripe_webhook_events") as batch_op: - batch_op.drop_column("merchant_subscription_id") - batch_op.add_column( - sa.Column("subscription_id", sa.Integer(), - sa.ForeignKey("store_subscriptions.id"), nullable=True, index=True) - ) - - # Restore columns on subscription_tiers - with op.batch_alter_table("subscription_tiers") as batch_op: - batch_op.add_column(sa.Column("orders_per_month", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("products_limit", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("team_members", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("order_history_months", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("cms_pages_limit", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("cms_custom_pages_limit", sa.Integer(), nullable=True)) - batch_op.add_column(sa.Column("features", sa.JSON(), nullable=True)) - - # Recreate features table - op.create_table( - "features", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("code", sa.String(50), unique=True, nullable=False), - sa.Column("name", sa.String(100), nullable=False), - sa.Column("description", sa.Text(), nullable=True), - sa.Column("category", sa.String(50), nullable=False), - sa.Column("is_active", sa.Boolean(), server_default="1"), - ) - - # Recreate store_subscriptions table - op.create_table( - "store_subscriptions", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), unique=True, nullable=False), - sa.Column("tier", sa.String(20), nullable=False, server_default="essential"), - sa.Column("status", sa.String(20), nullable=False, server_default="trial"), - sa.Column("period_start", sa.DateTime(timezone=True), nullable=False), - sa.Column("period_end", sa.DateTime(timezone=True), nullable=False), - ) - - # Drop new tables - op.drop_table("merchant_feature_overrides") - op.drop_table("tier_feature_limits") - op.drop_table("merchant_subscriptions") diff --git a/app/modules/billing/models/subscription.py b/app/modules/billing/models/subscription.py index 9e8d97ad..afe2708c 100644 --- a/app/modules/billing/models/subscription.py +++ b/app/modules/billing/models/subscription.py @@ -243,8 +243,8 @@ class StoreAddOn(Base, TimestampMixin): addon_product = relationship("AddOnProduct") __table_args__ = ( - Index("idx_vendor_addon_status", "store_id", "status"), - Index("idx_vendor_addon_product", "store_id", "addon_product_id"), + Index("idx_store_addon_status", "store_id", "status"), + Index("idx_store_addon_product", "store_id", "addon_product_id"), ) def __repr__(self): diff --git a/app/modules/cart/definition.py b/app/modules/cart/definition.py index bd9f3ae0..c905f053 100644 --- a/app/modules/cart/definition.py +++ b/app/modules/cart/definition.py @@ -29,6 +29,7 @@ cart_module = ModuleDefinition( version="1.0.0", is_self_contained=True, requires=["inventory"], # Checks inventory availability + migrations_path="migrations", features=[ "cart_management", # Basic cart CRUD operations "cart_persistence", # Session and database persistence diff --git a/app/modules/cart/migrations/__init__.py b/app/modules/cart/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/cart/migrations/versions/__init__.py b/app/modules/cart/migrations/versions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/modules/cart/migrations/versions/cart_001_initial.py b/app/modules/cart/migrations/versions/cart_001_initial.py new file mode 100644 index 00000000..4366c5b9 --- /dev/null +++ b/app/modules/cart/migrations/versions/cart_001_initial.py @@ -0,0 +1,35 @@ +"""cart initial - cart items + +Revision ID: cart_001 +Revises: inventory_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "cart_001" +down_revision = "inventory_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- cart_items --- + op.create_table( + "cart_items", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id"), nullable=False), + sa.Column("session_id", sa.String(255), nullable=False, index=True), + sa.Column("quantity", sa.Integer(), nullable=False, server_default="1"), + sa.Column("price_at_add_cents", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("store_id", "session_id", "product_id", name="uq_cart_item"), + ) + op.create_index("idx_cart_session", "cart_items", ["store_id", "session_id"]) + op.create_index("idx_cart_created", "cart_items", ["created_at"]) + + +def downgrade() -> None: + op.drop_table("cart_items") diff --git a/app/modules/catalog/definition.py b/app/modules/catalog/definition.py index aa914b82..b4717af3 100644 --- a/app/modules/catalog/definition.py +++ b/app/modules/catalog/definition.py @@ -58,6 +58,7 @@ catalog_module = ModuleDefinition( version="1.0.0", is_self_contained=True, requires=["inventory"], + migrations_path="migrations", features=[ "product_catalog", # Core product catalog functionality "product_search", # Search and filtering diff --git a/app/modules/catalog/migrations/__init__.py b/app/modules/catalog/migrations/__init__.py new file mode 100644 index 00000000..a308f53a --- /dev/null +++ b/app/modules/catalog/migrations/__init__.py @@ -0,0 +1 @@ +"""Module migrations.""" diff --git a/app/modules/catalog/migrations/versions/__init__.py b/app/modules/catalog/migrations/versions/__init__.py new file mode 100644 index 00000000..a308f53a --- /dev/null +++ b/app/modules/catalog/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Module migrations.""" diff --git a/app/modules/catalog/migrations/versions/catalog_001_initial.py b/app/modules/catalog/migrations/versions/catalog_001_initial.py new file mode 100644 index 00000000..be745718 --- /dev/null +++ b/app/modules/catalog/migrations/versions/catalog_001_initial.py @@ -0,0 +1,97 @@ +"""catalog initial - products, translations, product media + +Revision ID: catalog_001 +Revises: cms_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "catalog_001" +down_revision = "cms_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- products --- + op.create_table( + "products", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("marketplace_product_id", sa.Integer(), sa.ForeignKey("marketplace_products.id"), nullable=True), + sa.Column("store_sku", sa.String(), nullable=True, index=True), + sa.Column("gtin", sa.String(50), nullable=True, index=True), + sa.Column("gtin_type", sa.String(20), nullable=True), + sa.Column("price_cents", sa.Integer(), nullable=True), + sa.Column("sale_price_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(3), nullable=True, server_default="EUR"), + sa.Column("brand", sa.String(), nullable=True), + sa.Column("condition", sa.String(), nullable=True), + sa.Column("availability", sa.String(), nullable=True), + sa.Column("primary_image_url", sa.String(), nullable=True), + sa.Column("additional_images", sa.JSON(), nullable=True), + sa.Column("download_url", sa.String(), nullable=True), + sa.Column("license_type", sa.String(50), nullable=True), + sa.Column("tax_rate_percent", sa.Integer(), nullable=False, server_default="17"), + sa.Column("supplier", sa.String(50), nullable=True), + sa.Column("supplier_product_id", sa.String(), nullable=True), + sa.Column("cost_cents", sa.Integer(), nullable=True), + sa.Column("margin_percent_x100", sa.Integer(), nullable=True), + sa.Column("is_digital", sa.Boolean(), nullable=True, server_default="false", index=True), + sa.Column("product_type", sa.String(20), nullable=True, server_default="physical"), + sa.Column("is_featured", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="true"), + sa.Column("display_order", sa.Integer(), nullable=True, server_default="0"), + sa.Column("min_quantity", sa.Integer(), nullable=True, server_default="1"), + sa.Column("max_quantity", sa.Integer(), nullable=True), + sa.Column("fulfillment_email_template", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("store_id", "marketplace_product_id", name="uq_store_marketplace_product"), + ) + op.create_index("idx_product_store_active", "products", ["store_id", "is_active"]) + op.create_index("idx_product_store_featured", "products", ["store_id", "is_featured"]) + op.create_index("idx_product_store_sku", "products", ["store_id", "store_sku"]) + op.create_index("idx_product_supplier", "products", ["supplier", "supplier_product_id"]) + + # --- product_translations --- + op.create_table( + "product_translations", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("language", sa.String(5), nullable=False), + sa.Column("title", sa.String(), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("short_description", sa.String(500), nullable=True), + sa.Column("meta_title", sa.String(70), nullable=True), + sa.Column("meta_description", sa.String(160), nullable=True), + sa.Column("url_slug", sa.String(255), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("product_id", "language", name="uq_product_translation"), + ) + op.create_index("idx_pt_product_id", "product_translations", ["product_id"]) + op.create_index("idx_pt_product_language", "product_translations", ["product_id", "language"]) + + # --- product_media --- + op.create_table( + "product_media", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id", ondelete="CASCADE"), nullable=False), + sa.Column("media_id", sa.Integer(), sa.ForeignKey("media_files.id", ondelete="CASCADE"), nullable=False), + sa.Column("usage_type", sa.String(50), nullable=False, server_default="gallery"), + sa.Column("display_order", sa.Integer(), nullable=True, server_default="0"), + sa.Column("variant_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("product_id", "media_id", "usage_type", name="uq_product_media_usage"), + ) + op.create_index("idx_product_media_product", "product_media", ["product_id"]) + op.create_index("idx_product_media_media", "product_media", ["media_id"]) + + +def downgrade() -> None: + op.drop_table("product_media") + op.drop_table("product_translations") + op.drop_table("products") diff --git a/app/modules/catalog/models/product.py b/app/modules/catalog/models/product.py index 92134846..69bb9396 100644 --- a/app/modules/catalog/models/product.py +++ b/app/modules/catalog/models/product.py @@ -121,11 +121,11 @@ class Product(Base, TimestampMixin): # === CONSTRAINTS & INDEXES === __table_args__ = ( UniqueConstraint( - "store_id", "marketplace_product_id", name="uq_vendor_marketplace_product" + "store_id", "marketplace_product_id", name="uq_store_marketplace_product" ), - Index("idx_product_vendor_active", "store_id", "is_active"), - Index("idx_product_vendor_featured", "store_id", "is_featured"), - Index("idx_product_vendor_sku", "store_id", "store_sku"), + Index("idx_product_store_active", "store_id", "is_active"), + Index("idx_product_store_featured", "store_id", "is_featured"), + Index("idx_product_store_sku", "store_id", "store_sku"), Index("idx_product_supplier", "supplier", "supplier_product_id"), ) diff --git a/app/modules/cms/definition.py b/app/modules/cms/definition.py index 91abe654..98b21176 100644 --- a/app/modules/cms/definition.py +++ b/app/modules/cms/definition.py @@ -254,6 +254,7 @@ cms_module = ModuleDefinition( services_path="app.modules.cms.services", models_path="app.modules.cms.models", exceptions_path="app.modules.cms.exceptions", + migrations_path="migrations", # Module templates (namespaced as cms/admin/*.html and cms/store/*.html) templates_path="templates", # Module-specific translations (accessible via cms.* keys) diff --git a/app/modules/cms/migrations/versions/cms_001_initial.py b/app/modules/cms/migrations/versions/cms_001_initial.py new file mode 100644 index 00000000..ee64db47 --- /dev/null +++ b/app/modules/cms/migrations/versions/cms_001_initial.py @@ -0,0 +1,107 @@ +"""cms initial - content pages, store themes, media files + +Revision ID: cms_001 +Revises: marketplace_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "cms_001" +down_revision = "marketplace_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- content_pages --- + op.create_table( + "content_pages", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("platform_id", sa.Integer(), sa.ForeignKey("platforms.id", ondelete="CASCADE"), nullable=False, index=True, comment="Platform this page belongs to"), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=True, index=True, comment="Store this page belongs to (NULL for platform/default pages)"), + sa.Column("is_platform_page", sa.Boolean(), nullable=False, server_default="false", comment="True = platform marketing page (homepage, pricing); False = store default or override"), + sa.Column("slug", sa.String(100), nullable=False, index=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("content_format", sa.String(20), nullable=True, server_default="html"), + sa.Column("template", sa.String(50), nullable=False, server_default="default"), + sa.Column("sections", sa.JSON(), nullable=True, comment="Structured homepage sections (hero, features, pricing, cta) with i18n"), + sa.Column("meta_description", sa.String(300), nullable=True), + sa.Column("meta_keywords", sa.String(300), nullable=True), + sa.Column("is_published", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("display_order", sa.Integer(), nullable=False, server_default="0"), + sa.Column("show_in_footer", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("show_in_header", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("show_in_legal", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("created_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("updated_by", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.UniqueConstraint("platform_id", "store_id", "slug", name="uq_platform_store_slug"), + ) + op.create_index("idx_platform_store_published", "content_pages", ["platform_id", "store_id", "is_published"]) + op.create_index("idx_platform_slug_published", "content_pages", ["platform_id", "slug", "is_published"]) + op.create_index("idx_platform_page_type", "content_pages", ["platform_id", "is_platform_page"]) + + # --- store_themes --- + op.create_table( + "store_themes", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), unique=True, nullable=False), + sa.Column("theme_name", sa.String(100), nullable=True, server_default="default"), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="true"), + sa.Column("colors", sa.JSON(), nullable=True), + sa.Column("font_family_heading", sa.String(100), nullable=True, server_default="Inter, sans-serif"), + sa.Column("font_family_body", sa.String(100), nullable=True, server_default="Inter, sans-serif"), + sa.Column("logo_url", sa.String(500), nullable=True), + sa.Column("logo_dark_url", sa.String(500), nullable=True), + sa.Column("favicon_url", sa.String(500), nullable=True), + sa.Column("banner_url", sa.String(500), nullable=True), + sa.Column("layout_style", sa.String(50), nullable=True, server_default="grid"), + sa.Column("header_style", sa.String(50), nullable=True, server_default="fixed"), + sa.Column("product_card_style", sa.String(50), nullable=True, server_default="modern"), + sa.Column("custom_css", sa.Text(), nullable=True), + sa.Column("social_links", sa.JSON(), nullable=True), + sa.Column("meta_title_template", sa.String(200), nullable=True), + sa.Column("meta_description", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- media_files --- + op.create_table( + "media_files", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("original_filename", sa.String(255), nullable=True), + sa.Column("file_path", sa.String(500), nullable=False), + sa.Column("media_type", sa.String(20), nullable=False), + sa.Column("mime_type", sa.String(100), nullable=True), + sa.Column("file_size", sa.Integer(), nullable=True), + sa.Column("width", sa.Integer(), nullable=True), + sa.Column("height", sa.Integer(), nullable=True), + sa.Column("thumbnail_path", sa.String(500), nullable=True), + sa.Column("alt_text", sa.String(500), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("folder", sa.String(100), nullable=True, server_default="general"), + sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("extra_metadata", sa.JSON(), nullable=True), + sa.Column("is_optimized", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("optimized_size", sa.Integer(), nullable=True), + sa.Column("usage_count", sa.Integer(), nullable=True, server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_media_store_id", "media_files", ["store_id"]) + op.create_index("idx_media_store_folder", "media_files", ["store_id", "folder"]) + op.create_index("idx_media_store_type", "media_files", ["store_id", "media_type"]) + op.create_index("idx_media_filename", "media_files", ["filename"]) + + +def downgrade() -> None: + op.drop_table("media_files") + op.drop_table("store_themes") + op.drop_table("content_pages") diff --git a/app/modules/customers/definition.py b/app/modules/customers/definition.py index bb82722a..53a45f8f 100644 --- a/app/modules/customers/definition.py +++ b/app/modules/customers/definition.py @@ -138,6 +138,7 @@ customers_module = ModuleDefinition( models_path="app.modules.customers.models", schemas_path="app.modules.customers.schemas", exceptions_path="app.modules.customers.exceptions", + migrations_path="migrations", # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, # Feature provider for feature flags diff --git a/app/modules/customers/migrations/versions/customers_001_initial.py b/app/modules/customers/migrations/versions/customers_001_initial.py new file mode 100644 index 00000000..6793139e --- /dev/null +++ b/app/modules/customers/migrations/versions/customers_001_initial.py @@ -0,0 +1,75 @@ +"""customers initial - customers, addresses, password reset tokens + +Revision ID: customers_001 +Revises: catalog_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "customers_001" +down_revision = "catalog_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- customers --- + op.create_table( + "customers", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("email", sa.String(255), nullable=False, index=True), + sa.Column("hashed_password", sa.String(255), nullable=False), + sa.Column("first_name", sa.String(100), nullable=True), + sa.Column("last_name", sa.String(100), nullable=True), + sa.Column("phone", sa.String(50), nullable=True), + sa.Column("customer_number", sa.String(100), nullable=False, index=True), + sa.Column("preferences", sa.JSON(), nullable=True), + sa.Column("marketing_consent", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("last_order_date", sa.DateTime(), nullable=True), + sa.Column("total_orders", sa.Integer(), nullable=True, server_default="0"), + sa.Column("total_spent", sa.Numeric(10, 2), nullable=True, server_default="0"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("preferred_language", sa.String(5), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- customer_addresses --- + op.create_table( + "customer_addresses", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False), + sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id"), nullable=False), + sa.Column("address_type", sa.String(50), nullable=False), + sa.Column("first_name", sa.String(100), nullable=False), + sa.Column("last_name", sa.String(100), nullable=False), + sa.Column("company", sa.String(200), nullable=True), + sa.Column("address_line_1", sa.String(255), nullable=False), + sa.Column("address_line_2", sa.String(255), nullable=True), + sa.Column("city", sa.String(100), nullable=False), + sa.Column("postal_code", sa.String(20), nullable=False), + sa.Column("country_name", sa.String(100), nullable=False), + sa.Column("country_iso", sa.String(5), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- password_reset_tokens --- + op.create_table( + "password_reset_tokens", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id", ondelete="CASCADE"), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False, index=True), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("used_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("password_reset_tokens") + op.drop_table("customer_addresses") + op.drop_table("customers") diff --git a/app/modules/dev_tools/definition.py b/app/modules/dev_tools/definition.py index 8c663218..dc6ff03f 100644 --- a/app/modules/dev_tools/definition.py +++ b/app/modules/dev_tools/definition.py @@ -85,6 +85,7 @@ dev_tools_module = ModuleDefinition( schemas_path="app.modules.dev_tools.schemas", exceptions_path="app.modules.dev_tools.exceptions", tasks_path="app.modules.dev_tools.tasks", + migrations_path="migrations", # Note: Code quality and test tasks are on-demand, not scheduled. # If scheduled scans are desired, add ScheduledTask entries here. ) diff --git a/app/modules/dev_tools/migrations/versions/dev_tools_001_initial.py b/app/modules/dev_tools/migrations/versions/dev_tools_001_initial.py new file mode 100644 index 00000000..7586582f --- /dev/null +++ b/app/modules/dev_tools/migrations/versions/dev_tools_001_initial.py @@ -0,0 +1,163 @@ +"""dev_tools initial - test runs, architecture scans, violations + +Revision ID: dev_tools_001 +Revises: loyalty_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "dev_tools_001" +down_revision = "loyalty_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- test_runs --- + op.create_table( + "test_runs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + sa.Column("total_tests", sa.Integer(), nullable=True), + sa.Column("passed", sa.Integer(), nullable=True), + sa.Column("failed", sa.Integer(), nullable=True), + sa.Column("errors", sa.Integer(), nullable=True), + sa.Column("skipped", sa.Integer(), nullable=True), + sa.Column("xfailed", sa.Integer(), nullable=True), + sa.Column("xpassed", sa.Integer(), nullable=True), + sa.Column("coverage_percent", sa.Float(), nullable=True), + sa.Column("duration_seconds", sa.Float(), nullable=True), + sa.Column("triggered_by", sa.String(100), nullable=True), + sa.Column("git_commit_hash", sa.String(40), nullable=True), + sa.Column("git_branch", sa.String(100), nullable=True), + sa.Column("test_path", sa.String(500), nullable=True), + sa.Column("pytest_args", sa.String(500), nullable=True), + sa.Column("status", sa.String(20), nullable=True, server_default="running", index=True), + sa.Column("celery_task_id", sa.String(255), nullable=True, index=True), + ) + + # --- test_results --- + op.create_table( + "test_results", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("run_id", sa.Integer(), sa.ForeignKey("test_runs.id"), nullable=False, index=True), + sa.Column("node_id", sa.String(500), nullable=False, index=True), + sa.Column("test_name", sa.String(200), nullable=False), + sa.Column("test_file", sa.String(300), nullable=False), + sa.Column("test_class", sa.String(200), nullable=True), + sa.Column("outcome", sa.String(20), nullable=False, index=True), + sa.Column("duration_seconds", sa.Float(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("traceback", sa.Text(), nullable=True), + sa.Column("markers", sa.JSON(), nullable=True), + sa.Column("parameters", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + # --- test_collections --- + op.create_table( + "test_collections", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("total_tests", sa.Integer(), nullable=True), + sa.Column("total_files", sa.Integer(), nullable=True), + sa.Column("total_classes", sa.Integer(), nullable=True), + sa.Column("unit_tests", sa.Integer(), nullable=True), + sa.Column("integration_tests", sa.Integer(), nullable=True), + sa.Column("performance_tests", sa.Integer(), nullable=True), + sa.Column("system_tests", sa.Integer(), nullable=True), + sa.Column("test_files", sa.JSON(), nullable=True), + sa.Column("collected_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + # --- architecture_scans --- + op.create_table( + "architecture_scans", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("timestamp", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + sa.Column("validator_type", sa.String(20), nullable=False, server_default="architecture", index=True), + sa.Column("status", sa.String(30), nullable=False, server_default="pending", index=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("progress_message", sa.String(255), nullable=True), + sa.Column("total_files", sa.Integer(), nullable=True), + sa.Column("total_violations", sa.Integer(), nullable=True), + sa.Column("errors", sa.Integer(), nullable=True), + sa.Column("warnings", sa.Integer(), nullable=True), + sa.Column("duration_seconds", sa.Float(), nullable=True), + sa.Column("triggered_by", sa.String(100), nullable=True), + sa.Column("git_commit_hash", sa.String(40), nullable=True), + sa.Column("celery_task_id", sa.String(255), nullable=True, index=True), + ) + + # --- architecture_violations --- + op.create_table( + "architecture_violations", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("scan_id", sa.Integer(), sa.ForeignKey("architecture_scans.id"), nullable=False, index=True), + sa.Column("validator_type", sa.String(20), nullable=False, server_default="architecture", index=True), + sa.Column("rule_id", sa.String(20), nullable=False, index=True), + sa.Column("rule_name", sa.String(200), nullable=False), + sa.Column("severity", sa.String(10), nullable=False, index=True), + sa.Column("file_path", sa.String(500), nullable=False, index=True), + sa.Column("line_number", sa.Integer(), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("context", sa.Text(), nullable=True), + sa.Column("suggestion", sa.Text(), nullable=True), + sa.Column("status", sa.String(20), nullable=True, server_default="open", index=True), + sa.Column("assigned_to", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("resolution_note", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + # --- architecture_rules --- + op.create_table( + "architecture_rules", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("rule_id", sa.String(20), unique=True, nullable=False, index=True), + sa.Column("validator_type", sa.String(20), nullable=False, server_default="architecture", index=True), + sa.Column("category", sa.String(50), nullable=False), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("severity", sa.String(10), nullable=False), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("custom_config", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + # --- violation_assignments --- + op.create_table( + "violation_assignments", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("violation_id", sa.Integer(), sa.ForeignKey("architecture_violations.id"), nullable=False, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("assigned_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("assigned_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("due_date", sa.DateTime(timezone=True), nullable=True), + sa.Column("priority", sa.String(10), nullable=True), + ) + + # --- violation_comments --- + op.create_table( + "violation_comments", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("violation_id", sa.Integer(), sa.ForeignKey("architecture_violations.id"), nullable=False, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("comment", sa.Text(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("violation_comments") + op.drop_table("violation_assignments") + op.drop_table("architecture_rules") + op.drop_table("architecture_violations") + op.drop_table("architecture_scans") + op.drop_table("test_collections") + op.drop_table("test_results") + op.drop_table("test_runs") diff --git a/app/modules/inventory/definition.py b/app/modules/inventory/definition.py index ac0a2ffa..9ddb9ac7 100644 --- a/app/modules/inventory/definition.py +++ b/app/modules/inventory/definition.py @@ -145,6 +145,7 @@ inventory_module = ModuleDefinition( models_path="app.modules.inventory.models", schemas_path="app.modules.inventory.schemas", exceptions_path="app.modules.inventory.exceptions", + migrations_path="migrations", # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, # Feature provider for feature flags diff --git a/app/modules/inventory/migrations/versions/inventory_001_initial.py b/app/modules/inventory/migrations/versions/inventory_001_initial.py new file mode 100644 index 00000000..8c93cca8 --- /dev/null +++ b/app/modules/inventory/migrations/versions/inventory_001_initial.py @@ -0,0 +1,69 @@ +"""inventory initial - inventory, inventory transactions + +Revision ID: inventory_001 +Revises: orders_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "inventory_001" +down_revision = "orders_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- inventory --- + op.create_table( + "inventory", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id"), nullable=False, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("warehouse", sa.String(), nullable=False, server_default="strassen", index=True), + sa.Column("bin_location", sa.String(), nullable=False, index=True), + sa.Column("location", sa.String(), nullable=True, index=True), + sa.Column("quantity", sa.Integer(), nullable=False, server_default="0"), + sa.Column("reserved_quantity", sa.Integer(), nullable=True, server_default="0"), + sa.Column("gtin", sa.String(), nullable=True, index=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("product_id", "warehouse", "bin_location", name="uq_inventory_product_warehouse_bin"), + ) + op.create_index("idx_inventory_store_product", "inventory", ["store_id", "product_id"]) + op.create_index("idx_inventory_warehouse_bin", "inventory", ["warehouse", "bin_location"]) + + # --- inventory_transactions --- + # Create the enum type for transaction_type + transaction_type_enum = sa.Enum( + "reserve", "fulfill", "release", "adjust", "set", "import", "return", + name="transactiontype", + ) + op.create_table( + "inventory_transactions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id"), nullable=False, index=True), + sa.Column("inventory_id", sa.Integer(), sa.ForeignKey("inventory.id"), nullable=True, index=True), + sa.Column("transaction_type", transaction_type_enum, nullable=False, index=True), + sa.Column("quantity_change", sa.Integer(), nullable=False), + sa.Column("quantity_after", sa.Integer(), nullable=False), + sa.Column("reserved_after", sa.Integer(), nullable=False, server_default="0"), + sa.Column("location", sa.String(), nullable=True), + sa.Column("warehouse", sa.String(), nullable=True), + sa.Column("order_id", sa.Integer(), sa.ForeignKey("orders.id"), nullable=True, index=True), + sa.Column("order_number", sa.String(), nullable=True), + sa.Column("reason", sa.Text(), nullable=True), + sa.Column("created_by", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True), + ) + op.create_index("idx_inv_tx_store_product", "inventory_transactions", ["store_id", "product_id"]) + op.create_index("idx_inv_tx_store_created", "inventory_transactions", ["store_id", "created_at"]) + op.create_index("idx_inv_tx_order", "inventory_transactions", ["order_id"]) + op.create_index("idx_inv_tx_type_created", "inventory_transactions", ["transaction_type", "created_at"]) + + +def downgrade() -> None: + op.drop_table("inventory_transactions") + op.drop_table("inventory") + sa.Enum(name="transactiontype").drop(op.get_bind(), checkfirst=True) diff --git a/app/modules/loyalty/migrations/versions/loyalty_001_add_loyalty_module_tables.py b/app/modules/loyalty/migrations/versions/loyalty_001_add_loyalty_module_tables.py deleted file mode 100644 index 3b3a54f5..00000000 --- a/app/modules/loyalty/migrations/versions/loyalty_001_add_loyalty_module_tables.py +++ /dev/null @@ -1,650 +0,0 @@ -"""add loyalty module tables - -Revision ID: 0fb5d6d6ff97 -Revises: zd3n4o5p6q7r8 -Create Date: 2026-01-28 22:55:34.074321 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql -from sqlalchemy.dialects import sqlite - -# revision identifiers, used by Alembic. -revision: str = '0fb5d6d6ff97' -down_revision: Union[str, None] = 'zd3n4o5p6q7r8' -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('loyalty_programs', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('store_id', sa.Integer(), nullable=False), - sa.Column('loyalty_type', sa.String(length=20), nullable=False), - sa.Column('stamps_target', sa.Integer(), nullable=False, comment='Number of stamps needed for reward'), - sa.Column('stamps_reward_description', sa.String(length=255), nullable=False, comment='Description of stamp reward'), - sa.Column('stamps_reward_value_cents', sa.Integer(), nullable=True, comment='Value of stamp reward in cents (for analytics)'), - sa.Column('points_per_euro', sa.Integer(), nullable=False, comment='Points earned per euro spent'), - sa.Column('points_rewards', sqlite.JSON(), nullable=False, comment='List of point rewards: [{id, name, points_required, description}]'), - sa.Column('cooldown_minutes', sa.Integer(), nullable=False, comment='Minutes between stamps for same card'), - sa.Column('max_daily_stamps', sa.Integer(), nullable=False, comment='Maximum stamps per card per day'), - sa.Column('require_staff_pin', sa.Boolean(), nullable=False, comment='Require staff PIN for stamp/points operations'), - sa.Column('card_name', sa.String(length=100), nullable=True, comment='Display name for loyalty card'), - sa.Column('card_color', sa.String(length=7), nullable=False, comment='Primary color for card (hex)'), - sa.Column('card_secondary_color', sa.String(length=7), nullable=True, comment='Secondary color for card (hex)'), - sa.Column('logo_url', sa.String(length=500), nullable=True, comment='URL to store logo for card'), - sa.Column('hero_image_url', sa.String(length=500), nullable=True, comment='URL to hero image for card'), - sa.Column('google_issuer_id', sa.String(length=100), nullable=True, comment='Google Wallet Issuer ID'), - sa.Column('google_class_id', sa.String(length=200), nullable=True, comment='Google Wallet Loyalty Class ID'), - sa.Column('apple_pass_type_id', sa.String(length=100), nullable=True, comment='Apple Wallet Pass Type ID'), - sa.Column('terms_text', sa.Text(), nullable=True, comment='Loyalty program terms and conditions'), - sa.Column('privacy_url', sa.String(length=500), nullable=True, comment='URL to privacy policy'), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('activated_at', sa.DateTime(timezone=True), nullable=True, comment='When program was first activated'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_loyalty_program_store_active', 'loyalty_programs', ['store_id', 'is_active'], unique=False) - op.create_index(op.f('ix_loyalty_programs_id'), 'loyalty_programs', ['id'], unique=False) - op.create_index(op.f('ix_loyalty_programs_is_active'), 'loyalty_programs', ['is_active'], unique=False) - op.create_index(op.f('ix_loyalty_programs_store_id'), 'loyalty_programs', ['store_id'], unique=True) - op.create_table('loyalty_cards', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('customer_id', sa.Integer(), nullable=False), - sa.Column('program_id', sa.Integer(), nullable=False), - sa.Column('store_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'), - sa.Column('card_number', sa.String(length=20), nullable=False, comment='Human-readable card number'), - sa.Column('qr_code_data', sa.String(length=50), nullable=False, comment='Data encoded in QR code for scanning'), - sa.Column('stamp_count', sa.Integer(), nullable=False, comment='Current stamps toward next reward'), - sa.Column('total_stamps_earned', sa.Integer(), nullable=False, comment='Lifetime stamps earned'), - sa.Column('stamps_redeemed', sa.Integer(), nullable=False, comment='Total rewards redeemed (stamps reset on redemption)'), - sa.Column('points_balance', sa.Integer(), nullable=False, comment='Current available points'), - sa.Column('total_points_earned', sa.Integer(), nullable=False, comment='Lifetime points earned'), - sa.Column('points_redeemed', sa.Integer(), nullable=False, comment='Lifetime points redeemed'), - sa.Column('google_object_id', sa.String(length=200), nullable=True, comment='Google Wallet Loyalty Object ID'), - sa.Column('google_object_jwt', sa.String(length=2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button"), - sa.Column('apple_serial_number', sa.String(length=100), nullable=True, comment='Apple Wallet pass serial number'), - sa.Column('apple_auth_token', sa.String(length=100), nullable=True, comment='Apple Wallet authentication token for updates'), - sa.Column('last_stamp_at', sa.DateTime(timezone=True), nullable=True, comment='Last stamp added (for cooldown)'), - sa.Column('last_points_at', sa.DateTime(timezone=True), nullable=True, comment='Last points earned'), - sa.Column('last_redemption_at', sa.DateTime(timezone=True), nullable=True, comment='Last reward redemption'), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['program_id'], ['loyalty_programs.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_loyalty_card_customer_program', 'loyalty_cards', ['customer_id', 'program_id'], unique=True) - op.create_index('idx_loyalty_card_store_active', 'loyalty_cards', ['store_id', 'is_active'], unique=False) - op.create_index(op.f('ix_loyalty_cards_apple_serial_number'), 'loyalty_cards', ['apple_serial_number'], unique=True) - op.create_index(op.f('ix_loyalty_cards_card_number'), 'loyalty_cards', ['card_number'], unique=True) - op.create_index(op.f('ix_loyalty_cards_customer_id'), 'loyalty_cards', ['customer_id'], unique=False) - op.create_index(op.f('ix_loyalty_cards_google_object_id'), 'loyalty_cards', ['google_object_id'], unique=False) - op.create_index(op.f('ix_loyalty_cards_id'), 'loyalty_cards', ['id'], unique=False) - op.create_index(op.f('ix_loyalty_cards_is_active'), 'loyalty_cards', ['is_active'], unique=False) - op.create_index(op.f('ix_loyalty_cards_program_id'), 'loyalty_cards', ['program_id'], unique=False) - op.create_index(op.f('ix_loyalty_cards_qr_code_data'), 'loyalty_cards', ['qr_code_data'], unique=True) - op.create_index(op.f('ix_loyalty_cards_store_id'), 'loyalty_cards', ['store_id'], unique=False) - op.create_table('staff_pins', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('program_id', sa.Integer(), nullable=False), - sa.Column('store_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'), - sa.Column('name', sa.String(length=100), nullable=False, comment='Staff member name'), - sa.Column('staff_id', sa.String(length=50), nullable=True, comment='Optional staff ID/employee number'), - sa.Column('pin_hash', sa.String(length=255), nullable=False, comment='bcrypt hash of PIN'), - sa.Column('failed_attempts', sa.Integer(), nullable=False, comment='Consecutive failed PIN attempts'), - sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True, comment='Lockout expires at this time'), - sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True, comment='Last successful use of PIN'), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['program_id'], ['loyalty_programs.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_staff_pin_program_active', 'staff_pins', ['program_id', 'is_active'], unique=False) - op.create_index('idx_staff_pin_store_active', 'staff_pins', ['store_id', 'is_active'], unique=False) - op.create_index(op.f('ix_staff_pins_id'), 'staff_pins', ['id'], unique=False) - op.create_index(op.f('ix_staff_pins_is_active'), 'staff_pins', ['is_active'], unique=False) - op.create_index(op.f('ix_staff_pins_program_id'), 'staff_pins', ['program_id'], unique=False) - op.create_index(op.f('ix_staff_pins_staff_id'), 'staff_pins', ['staff_id'], unique=False) - op.create_index(op.f('ix_staff_pins_store_id'), 'staff_pins', ['store_id'], unique=False) - op.create_table('apple_device_registrations', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('card_id', sa.Integer(), nullable=False), - sa.Column('device_library_identifier', sa.String(length=100), nullable=False, comment='Unique identifier for the device/library'), - sa.Column('push_token', sa.String(length=100), nullable=False, comment='APNs push token for this device'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['card_id'], ['loyalty_cards.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_apple_device_card', 'apple_device_registrations', ['device_library_identifier', 'card_id'], unique=True) - op.create_index(op.f('ix_apple_device_registrations_card_id'), 'apple_device_registrations', ['card_id'], unique=False) - op.create_index(op.f('ix_apple_device_registrations_device_library_identifier'), 'apple_device_registrations', ['device_library_identifier'], unique=False) - op.create_index(op.f('ix_apple_device_registrations_id'), 'apple_device_registrations', ['id'], unique=False) - op.create_table('loyalty_transactions', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('card_id', sa.Integer(), nullable=False), - sa.Column('store_id', sa.Integer(), nullable=False, comment='Denormalized for query performance'), - sa.Column('staff_pin_id', sa.Integer(), nullable=True, comment='Staff PIN used for this operation'), - sa.Column('transaction_type', sa.String(length=30), nullable=False), - sa.Column('stamps_delta', sa.Integer(), nullable=False, comment='Change in stamps (+1 for earn, -N for redeem)'), - sa.Column('points_delta', sa.Integer(), nullable=False, comment='Change in points (+N for earn, -N for redeem)'), - sa.Column('stamps_balance_after', sa.Integer(), nullable=True, comment='Stamp count after this transaction'), - sa.Column('points_balance_after', sa.Integer(), nullable=True, comment='Points balance after this transaction'), - sa.Column('purchase_amount_cents', sa.Integer(), nullable=True, comment='Purchase amount in cents (for points calculation)'), - sa.Column('order_reference', sa.String(length=100), nullable=True, comment='Reference to order that triggered points'), - sa.Column('reward_id', sa.String(length=50), nullable=True, comment='ID of redeemed reward (from program.points_rewards)'), - sa.Column('reward_description', sa.String(length=255), nullable=True, comment='Description of redeemed reward'), - sa.Column('ip_address', sa.String(length=45), nullable=True, comment='IP address of requester (IPv4 or IPv6)'), - sa.Column('user_agent', sa.String(length=500), nullable=True, comment='User agent string'), - sa.Column('notes', sa.Text(), nullable=True, comment='Additional notes (e.g., reason for adjustment)'), - sa.Column('transaction_at', sa.DateTime(timezone=True), nullable=False, comment='When the transaction occurred (may differ from created_at)'), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['card_id'], ['loyalty_cards.id'], ondelete='CASCADE'), - sa.ForeignKeyConstraint(['staff_pin_id'], ['staff_pins.id'], ondelete='SET NULL'), - sa.ForeignKeyConstraint(['store_id'], ['stores.id'], ondelete='CASCADE'), - sa.PrimaryKeyConstraint('id') - ) - op.create_index('idx_loyalty_tx_card_type', 'loyalty_transactions', ['card_id', 'transaction_type'], unique=False) - op.create_index('idx_loyalty_tx_type_date', 'loyalty_transactions', ['transaction_type', 'transaction_at'], unique=False) - op.create_index('idx_loyalty_tx_store_date', 'loyalty_transactions', ['store_id', 'transaction_at'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_card_id'), 'loyalty_transactions', ['card_id'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_id'), 'loyalty_transactions', ['id'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_order_reference'), 'loyalty_transactions', ['order_reference'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_staff_pin_id'), 'loyalty_transactions', ['staff_pin_id'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_transaction_at'), 'loyalty_transactions', ['transaction_at'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_transaction_type'), 'loyalty_transactions', ['transaction_type'], unique=False) - op.create_index(op.f('ix_loyalty_transactions_store_id'), 'loyalty_transactions', ['store_id'], unique=False) - op.alter_column('admin_menu_configs', 'platform_id', - existing_type=sa.INTEGER(), - comment='Platform scope - applies to users/stores of this platform', - existing_comment='Platform scope - applies to all platform admins of this platform', - existing_nullable=True) - op.alter_column('admin_menu_configs', 'user_id', - existing_type=sa.INTEGER(), - comment='User scope - applies to this specific super admin (admin frontend only)', - existing_comment='User scope - applies to this specific super admin', - existing_nullable=True) - op.alter_column('admin_menu_configs', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('admin_menu_configs', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.drop_index('idx_admin_menu_configs_frontend_type', table_name='admin_menu_configs') - op.drop_index('idx_admin_menu_configs_menu_item_id', table_name='admin_menu_configs') - op.drop_index('idx_admin_menu_configs_platform_id', table_name='admin_menu_configs') - op.drop_index('idx_admin_menu_configs_user_id', table_name='admin_menu_configs') - op.create_index(op.f('ix_admin_menu_configs_frontend_type'), 'admin_menu_configs', ['frontend_type'], unique=False) - op.create_index(op.f('ix_admin_menu_configs_id'), 'admin_menu_configs', ['id'], unique=False) - op.create_index(op.f('ix_admin_menu_configs_menu_item_id'), 'admin_menu_configs', ['menu_item_id'], unique=False) - op.create_index(op.f('ix_admin_menu_configs_platform_id'), 'admin_menu_configs', ['platform_id'], unique=False) - op.create_index(op.f('ix_admin_menu_configs_user_id'), 'admin_menu_configs', ['user_id'], unique=False) - op.alter_column('admin_platforms', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('admin_platforms', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.drop_index('idx_admin_platforms_platform_id', table_name='admin_platforms') - op.drop_index('idx_admin_platforms_user_id', table_name='admin_platforms') - op.create_index(op.f('ix_admin_platforms_id'), 'admin_platforms', ['id'], unique=False) - op.create_index(op.f('ix_admin_platforms_platform_id'), 'admin_platforms', ['platform_id'], unique=False) - op.create_index(op.f('ix_admin_platforms_user_id'), 'admin_platforms', ['user_id'], unique=False) - op.alter_column('content_pages', 'platform_id', - existing_type=sa.INTEGER(), - comment='Platform this page belongs to', - existing_nullable=False) - op.alter_column('content_pages', 'store_id', - existing_type=sa.INTEGER(), - comment='Store this page belongs to (NULL for platform/default pages)', - existing_nullable=True) - op.alter_column('content_pages', 'is_platform_page', - existing_type=sa.BOOLEAN(), - comment='True = platform marketing page (homepage, pricing); False = store default or override', - existing_nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('platform_modules', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('platform_modules', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.create_index(op.f('ix_platform_modules_id'), 'platform_modules', ['id'], unique=False) - op.alter_column('platforms', 'code', - existing_type=sa.VARCHAR(length=50), - comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')", - existing_nullable=False) - op.alter_column('platforms', 'name', - existing_type=sa.VARCHAR(length=100), - comment="Display name (e.g., 'Wizamart OMS')", - existing_nullable=False) - op.alter_column('platforms', 'description', - existing_type=sa.TEXT(), - comment='Platform description for admin/marketing purposes', - existing_nullable=True) - op.alter_column('platforms', 'domain', - existing_type=sa.VARCHAR(length=255), - comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')", - existing_nullable=True) - op.alter_column('platforms', 'path_prefix', - existing_type=sa.VARCHAR(length=50), - comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)", - existing_nullable=True) - op.alter_column('platforms', 'logo', - existing_type=sa.VARCHAR(length=500), - comment='Logo URL for light mode', - existing_nullable=True) - op.alter_column('platforms', 'logo_dark', - existing_type=sa.VARCHAR(length=500), - comment='Logo URL for dark mode', - existing_nullable=True) - op.alter_column('platforms', 'favicon', - existing_type=sa.VARCHAR(length=500), - comment='Favicon URL', - existing_nullable=True) - op.alter_column('platforms', 'theme_config', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment='Theme configuration (colors, fonts, etc.)', - existing_nullable=True) - op.alter_column('platforms', 'default_language', - existing_type=sa.VARCHAR(length=5), - comment="Default language code (e.g., 'fr', 'en', 'de')", - existing_nullable=False, - existing_server_default=sa.text("'fr'::character varying")) - op.alter_column('platforms', 'supported_languages', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment='List of supported language codes', - existing_nullable=False) - op.alter_column('platforms', 'is_active', - existing_type=sa.BOOLEAN(), - comment='Whether the platform is active and accessible', - existing_nullable=False, - existing_server_default=sa.text('true')) - op.alter_column('platforms', 'is_public', - existing_type=sa.BOOLEAN(), - comment='Whether the platform is visible in public listings', - existing_nullable=False, - existing_server_default=sa.text('true')) - op.alter_column('platforms', 'settings', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment='Platform-specific settings and feature flags', - existing_nullable=True) - op.alter_column('platforms', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('platforms', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.create_index(op.f('ix_platforms_id'), 'platforms', ['id'], unique=False) - op.alter_column('subscription_tiers', 'platform_id', - existing_type=sa.INTEGER(), - comment='Platform this tier belongs to (NULL = global tier)', - existing_nullable=True) - op.alter_column('subscription_tiers', 'cms_pages_limit', - existing_type=sa.INTEGER(), - comment='Total CMS pages limit (NULL = unlimited)', - existing_nullable=True) - op.alter_column('subscription_tiers', 'cms_custom_pages_limit', - existing_type=sa.INTEGER(), - comment='Custom pages limit, excluding overrides (NULL = unlimited)', - existing_nullable=True) - op.drop_index('ix_subscription_tiers_code', table_name='subscription_tiers') - op.create_index(op.f('ix_subscription_tiers_code'), 'subscription_tiers', ['code'], unique=False) - op.alter_column('users', 'is_super_admin', - existing_type=sa.BOOLEAN(), - comment=None, - existing_comment='Whether this admin has access to all platforms (super admin)', - existing_nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('store_platforms', 'store_id', - existing_type=sa.INTEGER(), - comment='Reference to the store', - existing_nullable=False) - op.alter_column('store_platforms', 'platform_id', - existing_type=sa.INTEGER(), - comment='Reference to the platform', - existing_nullable=False) - op.alter_column('store_platforms', 'tier_id', - existing_type=sa.INTEGER(), - comment='Platform-specific subscription tier', - existing_nullable=True) - op.alter_column('store_platforms', 'is_active', - existing_type=sa.BOOLEAN(), - comment='Whether the store is active on this platform', - existing_nullable=False, - existing_server_default=sa.text('true')) - op.alter_column('store_platforms', 'is_primary', - existing_type=sa.BOOLEAN(), - comment="Whether this is the store's primary platform", - existing_nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('store_platforms', 'custom_subdomain', - existing_type=sa.VARCHAR(length=100), - comment='Platform-specific subdomain (if different from main subdomain)', - existing_nullable=True) - op.alter_column('store_platforms', 'settings', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment='Platform-specific store settings', - existing_nullable=True) - op.alter_column('store_platforms', 'joined_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - comment='When the store joined this platform', - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('store_platforms', 'created_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('store_platforms', 'updated_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - type_=sa.DateTime(), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.create_index(op.f('ix_store_platforms_id'), 'store_platforms', ['id'], unique=False) - # ### end Alembic commands ### - - -def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_index(op.f('ix_store_platforms_id'), table_name='store_platforms') - op.alter_column('store_platforms', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('store_platforms', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('store_platforms', 'joined_at', - existing_type=postgresql.TIMESTAMP(timezone=True), - comment=None, - existing_comment='When the store joined this platform', - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('store_platforms', 'settings', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment=None, - existing_comment='Platform-specific store settings', - existing_nullable=True) - op.alter_column('store_platforms', 'custom_subdomain', - existing_type=sa.VARCHAR(length=100), - comment=None, - existing_comment='Platform-specific subdomain (if different from main subdomain)', - existing_nullable=True) - op.alter_column('store_platforms', 'is_primary', - existing_type=sa.BOOLEAN(), - comment=None, - existing_comment="Whether this is the store's primary platform", - existing_nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('store_platforms', 'is_active', - existing_type=sa.BOOLEAN(), - comment=None, - existing_comment='Whether the store is active on this platform', - existing_nullable=False, - existing_server_default=sa.text('true')) - op.alter_column('store_platforms', 'tier_id', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Platform-specific subscription tier', - existing_nullable=True) - op.alter_column('store_platforms', 'platform_id', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Reference to the platform', - existing_nullable=False) - op.alter_column('store_platforms', 'store_id', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Reference to the store', - existing_nullable=False) - op.alter_column('users', 'is_super_admin', - existing_type=sa.BOOLEAN(), - comment='Whether this admin has access to all platforms (super admin)', - existing_nullable=False, - existing_server_default=sa.text('false')) - op.drop_index(op.f('ix_subscription_tiers_code'), table_name='subscription_tiers') - op.create_index('ix_subscription_tiers_code', 'subscription_tiers', ['code'], unique=True) - op.alter_column('subscription_tiers', 'cms_custom_pages_limit', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Custom pages limit, excluding overrides (NULL = unlimited)', - existing_nullable=True) - op.alter_column('subscription_tiers', 'cms_pages_limit', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Total CMS pages limit (NULL = unlimited)', - existing_nullable=True) - op.alter_column('subscription_tiers', 'platform_id', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Platform this tier belongs to (NULL = global tier)', - existing_nullable=True) - op.drop_index(op.f('ix_platforms_id'), table_name='platforms') - op.alter_column('platforms', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('platforms', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('platforms', 'settings', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment=None, - existing_comment='Platform-specific settings and feature flags', - existing_nullable=True) - op.alter_column('platforms', 'is_public', - existing_type=sa.BOOLEAN(), - comment=None, - existing_comment='Whether the platform is visible in public listings', - existing_nullable=False, - existing_server_default=sa.text('true')) - op.alter_column('platforms', 'is_active', - existing_type=sa.BOOLEAN(), - comment=None, - existing_comment='Whether the platform is active and accessible', - existing_nullable=False, - existing_server_default=sa.text('true')) - op.alter_column('platforms', 'supported_languages', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment=None, - existing_comment='List of supported language codes', - existing_nullable=False) - op.alter_column('platforms', 'default_language', - existing_type=sa.VARCHAR(length=5), - comment=None, - existing_comment="Default language code (e.g., 'fr', 'en', 'de')", - existing_nullable=False, - existing_server_default=sa.text("'fr'::character varying")) - op.alter_column('platforms', 'theme_config', - existing_type=postgresql.JSON(astext_type=sa.Text()), - comment=None, - existing_comment='Theme configuration (colors, fonts, etc.)', - existing_nullable=True) - op.alter_column('platforms', 'favicon', - existing_type=sa.VARCHAR(length=500), - comment=None, - existing_comment='Favicon URL', - existing_nullable=True) - op.alter_column('platforms', 'logo_dark', - existing_type=sa.VARCHAR(length=500), - comment=None, - existing_comment='Logo URL for dark mode', - existing_nullable=True) - op.alter_column('platforms', 'logo', - existing_type=sa.VARCHAR(length=500), - comment=None, - existing_comment='Logo URL for light mode', - existing_nullable=True) - op.alter_column('platforms', 'path_prefix', - existing_type=sa.VARCHAR(length=50), - comment=None, - existing_comment="Development path prefix (e.g., 'oms' for localhost:9999/oms/*)", - existing_nullable=True) - op.alter_column('platforms', 'domain', - existing_type=sa.VARCHAR(length=255), - comment=None, - existing_comment="Production domain (e.g., 'oms.lu', 'loyalty.lu')", - existing_nullable=True) - op.alter_column('platforms', 'description', - existing_type=sa.TEXT(), - comment=None, - existing_comment='Platform description for admin/marketing purposes', - existing_nullable=True) - op.alter_column('platforms', 'name', - existing_type=sa.VARCHAR(length=100), - comment=None, - existing_comment="Display name (e.g., 'Wizamart OMS')", - existing_nullable=False) - op.alter_column('platforms', 'code', - existing_type=sa.VARCHAR(length=50), - comment=None, - existing_comment="Unique platform identifier (e.g., 'oms', 'loyalty', 'sites')", - existing_nullable=False) - op.drop_index(op.f('ix_platform_modules_id'), table_name='platform_modules') - op.alter_column('platform_modules', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('platform_modules', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('content_pages', 'is_platform_page', - existing_type=sa.BOOLEAN(), - comment=None, - existing_comment='True = platform marketing page (homepage, pricing); False = store default or override', - existing_nullable=False, - existing_server_default=sa.text('false')) - op.alter_column('content_pages', 'store_id', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Store this page belongs to (NULL for platform/default pages)', - existing_nullable=True) - op.alter_column('content_pages', 'platform_id', - existing_type=sa.INTEGER(), - comment=None, - existing_comment='Platform this page belongs to', - existing_nullable=False) - op.drop_index(op.f('ix_admin_platforms_user_id'), table_name='admin_platforms') - op.drop_index(op.f('ix_admin_platforms_platform_id'), table_name='admin_platforms') - op.drop_index(op.f('ix_admin_platforms_id'), table_name='admin_platforms') - op.create_index('idx_admin_platforms_user_id', 'admin_platforms', ['user_id'], unique=False) - op.create_index('idx_admin_platforms_platform_id', 'admin_platforms', ['platform_id'], unique=False) - op.alter_column('admin_platforms', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('admin_platforms', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.drop_index(op.f('ix_admin_menu_configs_user_id'), table_name='admin_menu_configs') - op.drop_index(op.f('ix_admin_menu_configs_platform_id'), table_name='admin_menu_configs') - op.drop_index(op.f('ix_admin_menu_configs_menu_item_id'), table_name='admin_menu_configs') - op.drop_index(op.f('ix_admin_menu_configs_id'), table_name='admin_menu_configs') - op.drop_index(op.f('ix_admin_menu_configs_frontend_type'), table_name='admin_menu_configs') - op.create_index('idx_admin_menu_configs_user_id', 'admin_menu_configs', ['user_id'], unique=False) - op.create_index('idx_admin_menu_configs_platform_id', 'admin_menu_configs', ['platform_id'], unique=False) - op.create_index('idx_admin_menu_configs_menu_item_id', 'admin_menu_configs', ['menu_item_id'], unique=False) - op.create_index('idx_admin_menu_configs_frontend_type', 'admin_menu_configs', ['frontend_type'], unique=False) - op.alter_column('admin_menu_configs', 'updated_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('admin_menu_configs', 'created_at', - existing_type=sa.DateTime(), - type_=postgresql.TIMESTAMP(timezone=True), - existing_nullable=False, - existing_server_default=sa.text('now()')) - op.alter_column('admin_menu_configs', 'user_id', - existing_type=sa.INTEGER(), - comment='User scope - applies to this specific super admin', - existing_comment='User scope - applies to this specific super admin (admin frontend only)', - existing_nullable=True) - op.alter_column('admin_menu_configs', 'platform_id', - existing_type=sa.INTEGER(), - comment='Platform scope - applies to all platform admins of this platform', - existing_comment='Platform scope - applies to users/stores of this platform', - existing_nullable=True) - op.drop_index(op.f('ix_loyalty_transactions_store_id'), table_name='loyalty_transactions') - op.drop_index(op.f('ix_loyalty_transactions_transaction_type'), table_name='loyalty_transactions') - op.drop_index(op.f('ix_loyalty_transactions_transaction_at'), table_name='loyalty_transactions') - op.drop_index(op.f('ix_loyalty_transactions_staff_pin_id'), table_name='loyalty_transactions') - op.drop_index(op.f('ix_loyalty_transactions_order_reference'), table_name='loyalty_transactions') - op.drop_index(op.f('ix_loyalty_transactions_id'), table_name='loyalty_transactions') - op.drop_index(op.f('ix_loyalty_transactions_card_id'), table_name='loyalty_transactions') - op.drop_index('idx_loyalty_tx_store_date', table_name='loyalty_transactions') - op.drop_index('idx_loyalty_tx_type_date', table_name='loyalty_transactions') - op.drop_index('idx_loyalty_tx_card_type', table_name='loyalty_transactions') - op.drop_table('loyalty_transactions') - op.drop_index(op.f('ix_apple_device_registrations_id'), table_name='apple_device_registrations') - op.drop_index(op.f('ix_apple_device_registrations_device_library_identifier'), table_name='apple_device_registrations') - op.drop_index(op.f('ix_apple_device_registrations_card_id'), table_name='apple_device_registrations') - op.drop_index('idx_apple_device_card', table_name='apple_device_registrations') - op.drop_table('apple_device_registrations') - op.drop_index(op.f('ix_staff_pins_store_id'), table_name='staff_pins') - op.drop_index(op.f('ix_staff_pins_staff_id'), table_name='staff_pins') - op.drop_index(op.f('ix_staff_pins_program_id'), table_name='staff_pins') - op.drop_index(op.f('ix_staff_pins_is_active'), table_name='staff_pins') - op.drop_index(op.f('ix_staff_pins_id'), table_name='staff_pins') - op.drop_index('idx_staff_pin_store_active', table_name='staff_pins') - op.drop_index('idx_staff_pin_program_active', table_name='staff_pins') - op.drop_table('staff_pins') - op.drop_index(op.f('ix_loyalty_cards_store_id'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_qr_code_data'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_program_id'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_is_active'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_id'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_google_object_id'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_customer_id'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_card_number'), table_name='loyalty_cards') - op.drop_index(op.f('ix_loyalty_cards_apple_serial_number'), table_name='loyalty_cards') - op.drop_index('idx_loyalty_card_store_active', table_name='loyalty_cards') - op.drop_index('idx_loyalty_card_customer_program', table_name='loyalty_cards') - op.drop_table('loyalty_cards') - op.drop_index(op.f('ix_loyalty_programs_store_id'), table_name='loyalty_programs') - op.drop_index(op.f('ix_loyalty_programs_is_active'), table_name='loyalty_programs') - op.drop_index(op.f('ix_loyalty_programs_id'), table_name='loyalty_programs') - op.drop_index('idx_loyalty_program_store_active', table_name='loyalty_programs') - op.drop_table('loyalty_programs') - # ### end Alembic commands ### diff --git a/app/modules/loyalty/migrations/versions/loyalty_001_initial.py b/app/modules/loyalty/migrations/versions/loyalty_001_initial.py new file mode 100644 index 00000000..7a9af5c3 --- /dev/null +++ b/app/modules/loyalty/migrations/versions/loyalty_001_initial.py @@ -0,0 +1,174 @@ +"""loyalty initial - programs, cards, transactions, staff pins, apple devices, settings + +Revision ID: loyalty_001 +Revises: messaging_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "loyalty_001" +down_revision = "messaging_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- loyalty_programs --- + op.create_table( + "loyalty_programs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant that owns this program (chain-wide)"), + sa.Column("loyalty_type", sa.String(20), nullable=False, server_default="points"), + sa.Column("stamps_target", sa.Integer(), nullable=False, server_default="10", comment="Number of stamps needed for reward"), + sa.Column("stamps_reward_description", sa.String(255), nullable=False, server_default="Free item", comment="Description of stamp reward"), + sa.Column("stamps_reward_value_cents", sa.Integer(), nullable=True, comment="Value of stamp reward in cents (for analytics)"), + sa.Column("points_per_euro", sa.Integer(), nullable=False, server_default="1", comment="Points earned per euro spent (1 euro = X points)"), + sa.Column("points_rewards", sa.JSON(), nullable=False, comment="List of point rewards: [{id, name, points_required, description}]"), + sa.Column("points_expiration_days", sa.Integer(), nullable=True, comment="Days of inactivity before points expire (None = never expire)"), + sa.Column("welcome_bonus_points", sa.Integer(), nullable=False, server_default="0", comment="Bonus points awarded on enrollment"), + sa.Column("minimum_redemption_points", sa.Integer(), nullable=False, server_default="100", comment="Minimum points required for any redemption"), + sa.Column("minimum_purchase_cents", sa.Integer(), nullable=False, server_default="0", comment="Minimum purchase amount (cents) to earn points (0 = no minimum)"), + sa.Column("tier_config", sa.JSON(), nullable=True, comment='Future: Tier thresholds {"bronze": 0, "silver": 1000, "gold": 5000}'), + sa.Column("cooldown_minutes", sa.Integer(), nullable=False, server_default="15", comment="Minutes between stamps for same card"), + sa.Column("max_daily_stamps", sa.Integer(), nullable=False, server_default="5", comment="Maximum stamps per card per day"), + sa.Column("require_staff_pin", sa.Boolean(), nullable=False, server_default="true", comment="Require staff PIN for stamp/points operations"), + sa.Column("card_name", sa.String(100), nullable=True, comment="Display name for loyalty card"), + sa.Column("card_color", sa.String(7), nullable=False, server_default="#4F46E5", comment="Primary color for card (hex)"), + sa.Column("card_secondary_color", sa.String(7), nullable=True, comment="Secondary color for card (hex)"), + sa.Column("logo_url", sa.String(500), nullable=True, comment="URL to merchant logo for card"), + sa.Column("hero_image_url", sa.String(500), nullable=True, comment="URL to hero image for card"), + sa.Column("google_issuer_id", sa.String(100), nullable=True, comment="Google Wallet Issuer ID"), + sa.Column("google_class_id", sa.String(200), nullable=True, comment="Google Wallet Loyalty Class ID"), + sa.Column("apple_pass_type_id", sa.String(100), nullable=True, comment="Apple Wallet Pass Type ID"), + sa.Column("terms_text", sa.Text(), nullable=True, comment="Loyalty program terms and conditions"), + sa.Column("privacy_url", sa.String(500), nullable=True, comment="URL to privacy policy"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True), + sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True, comment="When program was first activated"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_loyalty_program_merchant_active", "loyalty_programs", ["merchant_id", "is_active"]) + + # --- staff_pins --- + op.create_table( + "staff_pins", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program"), + sa.Column("program_id", sa.Integer(), sa.ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True, comment="Store (location) where this staff member works"), + sa.Column("name", sa.String(100), nullable=False, comment="Staff member name"), + sa.Column("staff_id", sa.String(50), nullable=True, index=True, comment="Optional staff ID/employee number"), + sa.Column("pin_hash", sa.String(255), nullable=False, comment="bcrypt hash of PIN"), + sa.Column("failed_attempts", sa.Integer(), nullable=False, server_default="0", comment="Consecutive failed PIN attempts"), + sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True, comment="Lockout expires at this time"), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True, comment="Last successful use of PIN"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_staff_pin_merchant_active", "staff_pins", ["merchant_id", "is_active"]) + op.create_index("idx_staff_pin_store_active", "staff_pins", ["store_id", "is_active"]) + op.create_index("idx_staff_pin_program_active", "staff_pins", ["program_id", "is_active"]) + + # --- loyalty_cards --- + op.create_table( + "loyalty_cards", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant whose program this card belongs to"), + sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("program_id", sa.Integer(), sa.ForeignKey("loyalty_programs.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("enrolled_at_store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store where customer enrolled (for analytics)"), + sa.Column("card_number", sa.String(20), unique=True, nullable=False, index=True, comment="Human-readable card number (XXXX-XXXX-XXXX)"), + sa.Column("qr_code_data", sa.String(50), unique=True, nullable=False, index=True, comment="Data encoded in QR code for scanning"), + sa.Column("stamp_count", sa.Integer(), nullable=False, server_default="0", comment="Current stamps toward next reward"), + sa.Column("total_stamps_earned", sa.Integer(), nullable=False, server_default="0", comment="Lifetime stamps earned"), + sa.Column("stamps_redeemed", sa.Integer(), nullable=False, server_default="0", comment="Total rewards redeemed (stamps reset on redemption)"), + sa.Column("points_balance", sa.Integer(), nullable=False, server_default="0", comment="Current available points"), + sa.Column("total_points_earned", sa.Integer(), nullable=False, server_default="0", comment="Lifetime points earned"), + sa.Column("points_redeemed", sa.Integer(), nullable=False, server_default="0", comment="Lifetime points redeemed"), + sa.Column("google_object_id", sa.String(200), nullable=True, index=True, comment="Google Wallet Loyalty Object ID"), + sa.Column("google_object_jwt", sa.String(2000), nullable=True, comment="JWT for Google Wallet 'Add to Wallet' button"), + sa.Column("apple_serial_number", sa.String(100), unique=True, nullable=True, index=True, comment="Apple Wallet pass serial number"), + sa.Column("apple_auth_token", sa.String(100), nullable=True, comment="Apple Wallet authentication token for updates"), + sa.Column("last_stamp_at", sa.DateTime(timezone=True), nullable=True, comment="Last stamp added (for cooldown)"), + sa.Column("last_points_at", sa.DateTime(timezone=True), nullable=True, comment="Last points earned (for expiration tracking)"), + sa.Column("last_redemption_at", sa.DateTime(timezone=True), nullable=True, comment="Last reward redemption"), + sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True, comment="Any activity (for expiration calculation)"), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true", index=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_loyalty_card_merchant_customer", "loyalty_cards", ["merchant_id", "customer_id"], unique=True) + op.create_index("idx_loyalty_card_merchant_active", "loyalty_cards", ["merchant_id", "is_active"]) + op.create_index("idx_loyalty_card_customer_program", "loyalty_cards", ["customer_id", "program_id"], unique=True) + + # --- loyalty_transactions --- + op.create_table( + "loyalty_transactions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), nullable=False, index=True, comment="Merchant that owns the loyalty program"), + sa.Column("card_id", sa.Integer(), sa.ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="SET NULL"), nullable=True, index=True, comment="Store (location) that processed this transaction"), + sa.Column("staff_pin_id", sa.Integer(), sa.ForeignKey("staff_pins.id", ondelete="SET NULL"), nullable=True, index=True, comment="Staff PIN used for this operation"), + sa.Column("related_transaction_id", sa.Integer(), sa.ForeignKey("loyalty_transactions.id", ondelete="SET NULL"), nullable=True, index=True, comment="Original transaction (for voids/returns)"), + sa.Column("transaction_type", sa.String(30), nullable=False, index=True), + sa.Column("stamps_delta", sa.Integer(), nullable=False, server_default="0", comment="Change in stamps (+1 for earn, -N for redeem)"), + sa.Column("points_delta", sa.Integer(), nullable=False, server_default="0", comment="Change in points (+N for earn, -N for redeem)"), + sa.Column("stamps_balance_after", sa.Integer(), nullable=True, comment="Stamp count after this transaction"), + sa.Column("points_balance_after", sa.Integer(), nullable=True, comment="Points balance after this transaction"), + sa.Column("purchase_amount_cents", sa.Integer(), nullable=True, comment="Purchase amount in cents (for points calculation)"), + sa.Column("order_reference", sa.String(100), nullable=True, index=True, comment="Reference to order that triggered points"), + sa.Column("reward_id", sa.String(50), nullable=True, comment="ID of redeemed reward (from program.points_rewards)"), + sa.Column("reward_description", sa.String(255), nullable=True, comment="Description of redeemed reward"), + sa.Column("ip_address", sa.String(45), nullable=True, comment="IP address of requester (IPv4 or IPv6)"), + sa.Column("user_agent", sa.String(500), nullable=True, comment="User agent string"), + sa.Column("notes", sa.Text(), nullable=True, comment="Additional notes (e.g., reason for adjustment)"), + sa.Column("transaction_at", sa.DateTime(timezone=True), nullable=False, index=True, comment="When the transaction occurred (may differ from created_at)"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_loyalty_tx_card_type", "loyalty_transactions", ["card_id", "transaction_type"]) + op.create_index("idx_loyalty_tx_store_date", "loyalty_transactions", ["store_id", "transaction_at"]) + op.create_index("idx_loyalty_tx_type_date", "loyalty_transactions", ["transaction_type", "transaction_at"]) + op.create_index("idx_loyalty_tx_merchant_date", "loyalty_transactions", ["merchant_id", "transaction_at"]) + op.create_index("idx_loyalty_tx_merchant_store", "loyalty_transactions", ["merchant_id", "store_id"]) + + # --- apple_device_registrations --- + op.create_table( + "apple_device_registrations", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("card_id", sa.Integer(), sa.ForeignKey("loyalty_cards.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("device_library_identifier", sa.String(100), nullable=False, index=True, comment="Unique identifier for the device/library"), + sa.Column("push_token", sa.String(100), nullable=False, comment="APNs push token for this device"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_apple_device_card", "apple_device_registrations", ["device_library_identifier", "card_id"], unique=True) + + # --- merchant_loyalty_settings --- + op.create_table( + "merchant_loyalty_settings", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("merchant_id", sa.Integer(), sa.ForeignKey("merchants.id", ondelete="CASCADE"), unique=True, nullable=False, index=True, comment="Merchant these settings apply to"), + sa.Column("staff_pin_policy", sa.String(20), nullable=False, server_default="required", comment="Staff PIN policy: required, optional, disabled"), + sa.Column("staff_pin_lockout_attempts", sa.Integer(), nullable=False, server_default="5", comment="Max failed PIN attempts before lockout"), + sa.Column("staff_pin_lockout_minutes", sa.Integer(), nullable=False, server_default="30", comment="Lockout duration in minutes"), + sa.Column("allow_self_enrollment", sa.Boolean(), nullable=False, server_default="true", comment="Allow customers to self-enroll via QR code"), + sa.Column("allow_void_transactions", sa.Boolean(), nullable=False, server_default="true", comment="Allow voiding points for returns"), + sa.Column("allow_cross_location_redemption", sa.Boolean(), nullable=False, server_default="true", comment="Allow redemption at any merchant location"), + sa.Column("require_order_reference", sa.Boolean(), nullable=False, server_default="false", comment="Require order reference when earning points"), + sa.Column("log_ip_addresses", sa.Boolean(), nullable=False, server_default="true", comment="Log IP addresses for transactions"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_merchant_loyalty_settings_merchant", "merchant_loyalty_settings", ["merchant_id"]) + + +def downgrade() -> None: + op.drop_table("merchant_loyalty_settings") + op.drop_table("apple_device_registrations") + op.drop_table("loyalty_transactions") + op.drop_table("loyalty_cards") + op.drop_table("staff_pins") + op.drop_table("loyalty_programs") diff --git a/app/modules/loyalty/migrations/versions/loyalty_003_phase2_merchant_based.py b/app/modules/loyalty/migrations/versions/loyalty_003_phase2_merchant_based.py deleted file mode 100644 index 9efbc4dd..00000000 --- a/app/modules/loyalty/migrations/versions/loyalty_003_phase2_merchant_based.py +++ /dev/null @@ -1,560 +0,0 @@ -"""Phase 2: migrate loyalty module to merchant-based architecture - -Revision ID: loyalty_003_phase2 -Revises: 0fb5d6d6ff97 -Create Date: 2026-02-06 20:30:00.000000 - -Phase 2 changes: -- loyalty_programs: store_id -> merchant_id (one program per merchant) -- loyalty_cards: add merchant_id, rename store_id -> enrolled_at_store_id -- loyalty_transactions: add merchant_id, add related_transaction_id, store_id nullable -- staff_pins: add merchant_id -- NEW TABLE: merchant_loyalty_settings -- NEW COLUMNS on loyalty_programs: points_expiration_days, welcome_bonus_points, - minimum_redemption_points, minimum_purchase_cents, tier_config -- NEW COLUMN on loyalty_cards: last_activity_at -""" - -from typing import Sequence, Union - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "loyalty_003_phase2" -down_revision: Union[str, None] = "0fb5d6d6ff97" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - # ========================================================================= - # 1. Create merchant_loyalty_settings table - # ========================================================================= - op.create_table( - "merchant_loyalty_settings", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("merchant_id", sa.Integer(), nullable=False), - sa.Column( - "staff_pin_policy", - sa.String(length=20), - nullable=False, - server_default="required", - ), - sa.Column( - "staff_pin_lockout_attempts", - sa.Integer(), - nullable=False, - server_default="5", - ), - sa.Column( - "staff_pin_lockout_minutes", - sa.Integer(), - nullable=False, - server_default="30", - ), - sa.Column( - "allow_self_enrollment", - sa.Boolean(), - nullable=False, - server_default=sa.text("true"), - ), - sa.Column( - "allow_void_transactions", - sa.Boolean(), - nullable=False, - server_default=sa.text("true"), - ), - sa.Column( - "allow_cross_location_redemption", - sa.Boolean(), - nullable=False, - server_default=sa.text("true"), - ), - sa.Column( - "require_order_reference", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - sa.Column( - "log_ip_addresses", - sa.Boolean(), - nullable=False, - server_default=sa.text("true"), - ), - sa.Column("created_at", sa.DateTime(), nullable=False), - sa.Column("updated_at", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["merchant_id"], ["merchants.id"], ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - op.f("ix_merchant_loyalty_settings_id"), - "merchant_loyalty_settings", - ["id"], - unique=False, - ) - op.create_index( - op.f("ix_merchant_loyalty_settings_merchant_id"), - "merchant_loyalty_settings", - ["merchant_id"], - unique=True, - ) - - # ========================================================================= - # 2. Modify loyalty_programs: store_id -> merchant_id + new columns - # ========================================================================= - - # Add merchant_id (nullable first for data migration) - op.add_column( - "loyalty_programs", sa.Column("merchant_id", sa.Integer(), nullable=True) - ) - - # Migrate existing data: derive merchant_id from store_id - op.execute( - """ - UPDATE loyalty_programs lp - SET merchant_id = v.merchant_id - FROM stores v - WHERE v.id = lp.store_id - """ - ) - - # Make merchant_id non-nullable - op.alter_column("loyalty_programs", "merchant_id", nullable=False) - - # Add FK and indexes - op.create_foreign_key( - "fk_loyalty_programs_merchant_id", - "loyalty_programs", - "merchants", - ["merchant_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_index( - op.f("ix_loyalty_programs_merchant_id"), - "loyalty_programs", - ["merchant_id"], - unique=True, - ) - op.create_index( - "idx_loyalty_program_merchant_active", - "loyalty_programs", - ["merchant_id", "is_active"], - ) - - # Add new Phase 2 columns - op.add_column( - "loyalty_programs", - sa.Column("points_expiration_days", sa.Integer(), nullable=True), - ) - op.add_column( - "loyalty_programs", - sa.Column( - "welcome_bonus_points", - sa.Integer(), - nullable=False, - server_default="0", - ), - ) - op.add_column( - "loyalty_programs", - sa.Column( - "minimum_redemption_points", - sa.Integer(), - nullable=False, - server_default="100", - ), - ) - op.add_column( - "loyalty_programs", - sa.Column( - "minimum_purchase_cents", - sa.Integer(), - nullable=False, - server_default="0", - ), - ) - op.add_column( - "loyalty_programs", - sa.Column("tier_config", sa.JSON(), nullable=True), - ) - - # Drop old store_id column and indexes - op.drop_index("idx_loyalty_program_store_active", table_name="loyalty_programs") - op.drop_index( - op.f("ix_loyalty_programs_store_id"), table_name="loyalty_programs" - ) - op.drop_constraint( - "loyalty_programs_store_id_fkey", "loyalty_programs", type_="foreignkey" - ) - op.drop_column("loyalty_programs", "store_id") - - # ========================================================================= - # 3. Modify loyalty_cards: add merchant_id, rename store_id - # ========================================================================= - - # Add merchant_id - op.add_column( - "loyalty_cards", sa.Column("merchant_id", sa.Integer(), nullable=True) - ) - - # Migrate data - op.execute( - """ - UPDATE loyalty_cards lc - SET merchant_id = v.merchant_id - FROM stores v - WHERE v.id = lc.store_id - """ - ) - - op.alter_column("loyalty_cards", "merchant_id", nullable=False) - - op.create_foreign_key( - "fk_loyalty_cards_merchant_id", - "loyalty_cards", - "merchants", - ["merchant_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_index( - op.f("ix_loyalty_cards_merchant_id"), - "loyalty_cards", - ["merchant_id"], - unique=False, - ) - op.create_index( - "idx_loyalty_card_merchant_active", - "loyalty_cards", - ["merchant_id", "is_active"], - ) - op.create_index( - "idx_loyalty_card_merchant_customer", - "loyalty_cards", - ["merchant_id", "customer_id"], - unique=True, - ) - - # Rename store_id -> enrolled_at_store_id, make nullable, change FK - op.drop_index("idx_loyalty_card_store_active", table_name="loyalty_cards") - op.drop_index(op.f("ix_loyalty_cards_store_id"), table_name="loyalty_cards") - op.drop_constraint( - "loyalty_cards_store_id_fkey", "loyalty_cards", type_="foreignkey" - ) - op.alter_column( - "loyalty_cards", - "store_id", - new_column_name="enrolled_at_store_id", - nullable=True, - ) - op.create_foreign_key( - "fk_loyalty_cards_enrolled_store", - "loyalty_cards", - "stores", - ["enrolled_at_store_id"], - ["id"], - ondelete="SET NULL", - ) - op.create_index( - op.f("ix_loyalty_cards_enrolled_at_store_id"), - "loyalty_cards", - ["enrolled_at_store_id"], - unique=False, - ) - - # Add last_activity_at - op.add_column( - "loyalty_cards", - sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), - ) - - # ========================================================================= - # 4. Modify loyalty_transactions: add merchant_id, related_transaction_id - # ========================================================================= - - # Add merchant_id - op.add_column( - "loyalty_transactions", - sa.Column("merchant_id", sa.Integer(), nullable=True), - ) - - # Migrate data (from card's merchant) - op.execute( - """ - UPDATE loyalty_transactions lt - SET merchant_id = lc.merchant_id - FROM loyalty_cards lc - WHERE lc.id = lt.card_id - """ - ) - - op.alter_column("loyalty_transactions", "merchant_id", nullable=False) - - op.create_foreign_key( - "fk_loyalty_transactions_merchant_id", - "loyalty_transactions", - "merchants", - ["merchant_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_index( - op.f("ix_loyalty_transactions_merchant_id"), - "loyalty_transactions", - ["merchant_id"], - unique=False, - ) - op.create_index( - "idx_loyalty_tx_merchant_date", - "loyalty_transactions", - ["merchant_id", "transaction_at"], - ) - op.create_index( - "idx_loyalty_tx_merchant_store", - "loyalty_transactions", - ["merchant_id", "store_id"], - ) - - # Make store_id nullable and change FK to SET NULL - op.drop_constraint( - "loyalty_transactions_store_id_fkey", - "loyalty_transactions", - type_="foreignkey", - ) - op.alter_column("loyalty_transactions", "store_id", nullable=True) - op.create_foreign_key( - "fk_loyalty_transactions_store_id", - "loyalty_transactions", - "stores", - ["store_id"], - ["id"], - ondelete="SET NULL", - ) - - # Add related_transaction_id (for void linkage) - op.add_column( - "loyalty_transactions", - sa.Column("related_transaction_id", sa.Integer(), nullable=True), - ) - op.create_foreign_key( - "fk_loyalty_tx_related", - "loyalty_transactions", - "loyalty_transactions", - ["related_transaction_id"], - ["id"], - ondelete="SET NULL", - ) - op.create_index( - op.f("ix_loyalty_transactions_related_transaction_id"), - "loyalty_transactions", - ["related_transaction_id"], - unique=False, - ) - - # ========================================================================= - # 5. Modify staff_pins: add merchant_id - # ========================================================================= - - op.add_column( - "staff_pins", sa.Column("merchant_id", sa.Integer(), nullable=True) - ) - - # Migrate data (from store's merchant) - op.execute( - """ - UPDATE staff_pins sp - SET merchant_id = v.merchant_id - FROM stores v - WHERE v.id = sp.store_id - """ - ) - - op.alter_column("staff_pins", "merchant_id", nullable=False) - - op.create_foreign_key( - "fk_staff_pins_merchant_id", - "staff_pins", - "merchants", - ["merchant_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_index( - op.f("ix_staff_pins_merchant_id"), - "staff_pins", - ["merchant_id"], - unique=False, - ) - op.create_index( - "idx_staff_pin_merchant_active", - "staff_pins", - ["merchant_id", "is_active"], - ) - - -def downgrade() -> None: - # ========================================================================= - # 5. Revert staff_pins - # ========================================================================= - op.drop_index("idx_staff_pin_merchant_active", table_name="staff_pins") - op.drop_index(op.f("ix_staff_pins_merchant_id"), table_name="staff_pins") - op.drop_constraint("fk_staff_pins_merchant_id", "staff_pins", type_="foreignkey") - op.drop_column("staff_pins", "merchant_id") - - # ========================================================================= - # 4. Revert loyalty_transactions - # ========================================================================= - op.drop_index( - op.f("ix_loyalty_transactions_related_transaction_id"), - table_name="loyalty_transactions", - ) - op.drop_constraint( - "fk_loyalty_tx_related", "loyalty_transactions", type_="foreignkey" - ) - op.drop_column("loyalty_transactions", "related_transaction_id") - - op.drop_constraint( - "fk_loyalty_transactions_store_id", - "loyalty_transactions", - type_="foreignkey", - ) - op.alter_column("loyalty_transactions", "store_id", nullable=False) - op.create_foreign_key( - "loyalty_transactions_store_id_fkey", - "loyalty_transactions", - "stores", - ["store_id"], - ["id"], - ondelete="CASCADE", - ) - - op.drop_index( - "idx_loyalty_tx_merchant_store", table_name="loyalty_transactions" - ) - op.drop_index( - "idx_loyalty_tx_merchant_date", table_name="loyalty_transactions" - ) - op.drop_index( - op.f("ix_loyalty_transactions_merchant_id"), - table_name="loyalty_transactions", - ) - op.drop_constraint( - "fk_loyalty_transactions_merchant_id", - "loyalty_transactions", - type_="foreignkey", - ) - op.drop_column("loyalty_transactions", "merchant_id") - - # ========================================================================= - # 3. Revert loyalty_cards - # ========================================================================= - op.drop_column("loyalty_cards", "last_activity_at") - - op.drop_index( - op.f("ix_loyalty_cards_enrolled_at_store_id"), table_name="loyalty_cards" - ) - op.drop_constraint( - "fk_loyalty_cards_enrolled_store", "loyalty_cards", type_="foreignkey" - ) - op.alter_column( - "loyalty_cards", - "enrolled_at_store_id", - new_column_name="store_id", - nullable=False, - ) - op.create_foreign_key( - "loyalty_cards_store_id_fkey", - "loyalty_cards", - "stores", - ["store_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_index( - op.f("ix_loyalty_cards_store_id"), - "loyalty_cards", - ["store_id"], - unique=False, - ) - op.create_index( - "idx_loyalty_card_store_active", - "loyalty_cards", - ["store_id", "is_active"], - ) - - op.drop_index( - "idx_loyalty_card_merchant_customer", table_name="loyalty_cards" - ) - op.drop_index( - "idx_loyalty_card_merchant_active", table_name="loyalty_cards" - ) - op.drop_index( - op.f("ix_loyalty_cards_merchant_id"), table_name="loyalty_cards" - ) - op.drop_constraint( - "fk_loyalty_cards_merchant_id", "loyalty_cards", type_="foreignkey" - ) - op.drop_column("loyalty_cards", "merchant_id") - - # ========================================================================= - # 2. Revert loyalty_programs - # ========================================================================= - op.add_column( - "loyalty_programs", - sa.Column("store_id", sa.Integer(), nullable=True), - ) - # Note: data migration back not possible if merchant had multiple stores - op.create_foreign_key( - "loyalty_programs_store_id_fkey", - "loyalty_programs", - "stores", - ["store_id"], - ["id"], - ondelete="CASCADE", - ) - op.create_index( - op.f("ix_loyalty_programs_store_id"), - "loyalty_programs", - ["store_id"], - unique=True, - ) - op.create_index( - "idx_loyalty_program_store_active", - "loyalty_programs", - ["store_id", "is_active"], - ) - - op.drop_column("loyalty_programs", "tier_config") - op.drop_column("loyalty_programs", "minimum_purchase_cents") - op.drop_column("loyalty_programs", "minimum_redemption_points") - op.drop_column("loyalty_programs", "welcome_bonus_points") - op.drop_column("loyalty_programs", "points_expiration_days") - - op.drop_index( - "idx_loyalty_program_merchant_active", table_name="loyalty_programs" - ) - op.drop_index( - op.f("ix_loyalty_programs_merchant_id"), table_name="loyalty_programs" - ) - op.drop_constraint( - "fk_loyalty_programs_merchant_id", "loyalty_programs", type_="foreignkey" - ) - op.drop_column("loyalty_programs", "merchant_id") - - # ========================================================================= - # 1. Drop merchant_loyalty_settings table - # ========================================================================= - op.drop_index( - op.f("ix_merchant_loyalty_settings_merchant_id"), - table_name="merchant_loyalty_settings", - ) - op.drop_index( - op.f("ix_merchant_loyalty_settings_id"), - table_name="merchant_loyalty_settings", - ) - op.drop_table("merchant_loyalty_settings") diff --git a/app/modules/marketplace/definition.py b/app/modules/marketplace/definition.py index b4f4b383..d6299fd3 100644 --- a/app/modules/marketplace/definition.py +++ b/app/modules/marketplace/definition.py @@ -156,6 +156,7 @@ marketplace_module = ModuleDefinition( schemas_path="app.modules.marketplace.schemas", exceptions_path="app.modules.marketplace.exceptions", tasks_path="app.modules.marketplace.tasks", + migrations_path="migrations", # ========================================================================= # Scheduled Tasks # ========================================================================= diff --git a/app/modules/marketplace/migrations/versions/marketplace_001_initial.py b/app/modules/marketplace/migrations/versions/marketplace_001_initial.py new file mode 100644 index 00000000..cb526e73 --- /dev/null +++ b/app/modules/marketplace/migrations/versions/marketplace_001_initial.py @@ -0,0 +1,299 @@ +"""marketplace initial - marketplace products, imports, letzshop integration + +Revision ID: marketplace_001 +Revises: billing_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "marketplace_001" +down_revision = "billing_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- marketplace_products --- + op.create_table( + "marketplace_products", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("marketplace_product_id", sa.String(), unique=True, nullable=False, index=True), + sa.Column("gtin", sa.String(), nullable=True, index=True), + sa.Column("mpn", sa.String(), nullable=True, index=True), + sa.Column("sku", sa.String(), nullable=True, index=True), + sa.Column("marketplace", sa.String(), nullable=True, server_default="letzshop", index=True), + sa.Column("source_url", sa.String(), nullable=True), + sa.Column("store_name", sa.String(), nullable=True, index=True), + sa.Column("product_type_enum", sa.String(20), nullable=False, server_default="physical"), + sa.Column("is_digital", sa.Boolean(), nullable=True, server_default="false", index=True), + sa.Column("digital_delivery_method", sa.String(20), nullable=True), + sa.Column("platform", sa.String(50), nullable=True, index=True), + sa.Column("region_restrictions", sa.JSON(), nullable=True), + sa.Column("license_type", sa.String(50), nullable=True), + sa.Column("brand", sa.String(), nullable=True, index=True), + sa.Column("google_product_category", sa.String(), nullable=True, index=True), + sa.Column("category_path", sa.String(), nullable=True), + sa.Column("condition", sa.String(), nullable=True), + sa.Column("price", sa.String(), nullable=True), + sa.Column("price_cents", sa.Integer(), nullable=True), + sa.Column("sale_price", sa.String(), nullable=True), + sa.Column("sale_price_cents", sa.Integer(), nullable=True), + sa.Column("currency", sa.String(3), nullable=True, server_default="EUR"), + sa.Column("tax_rate_percent", sa.Integer(), nullable=False, server_default="17"), + sa.Column("image_link", sa.String(), nullable=True), + sa.Column("additional_image_link", sa.String(), nullable=True), + sa.Column("additional_images", sa.JSON(), nullable=True), + sa.Column("attributes", sa.JSON(), nullable=True), + sa.Column("weight_grams", sa.Integer(), nullable=True), + sa.Column("weight_unit", sa.String(10), nullable=True, server_default="kg"), + sa.Column("dimensions", sa.JSON(), nullable=True), + sa.Column("link", sa.String(), nullable=True), + sa.Column("availability", sa.String(), nullable=True, index=True), + sa.Column("adult", sa.String(), nullable=True), + sa.Column("multipack", sa.Integer(), nullable=True), + sa.Column("is_bundle", sa.String(), nullable=True), + sa.Column("age_group", sa.String(), nullable=True), + sa.Column("color", sa.String(), nullable=True), + sa.Column("gender", sa.String(), nullable=True), + sa.Column("material", sa.String(), nullable=True), + sa.Column("pattern", sa.String(), nullable=True), + sa.Column("size", sa.String(), nullable=True), + sa.Column("size_type", sa.String(), nullable=True), + sa.Column("size_system", sa.String(), nullable=True), + sa.Column("item_group_id", sa.String(), nullable=True), + sa.Column("product_type_raw", sa.String(), nullable=True), + sa.Column("custom_label_0", sa.String(), nullable=True), + sa.Column("custom_label_1", sa.String(), nullable=True), + sa.Column("custom_label_2", sa.String(), nullable=True), + sa.Column("custom_label_3", sa.String(), nullable=True), + sa.Column("custom_label_4", sa.String(), nullable=True), + sa.Column("unit_pricing_measure", sa.String(), nullable=True), + sa.Column("unit_pricing_base_measure", sa.String(), nullable=True), + sa.Column("identifier_exists", sa.String(), nullable=True), + sa.Column("shipping", sa.String(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="true", index=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_marketplace_store", "marketplace_products", ["marketplace", "store_name"]) + op.create_index("idx_marketplace_brand", "marketplace_products", ["marketplace", "brand"]) + op.create_index("idx_mp_gtin_marketplace", "marketplace_products", ["gtin", "marketplace"]) + op.create_index("idx_mp_product_type", "marketplace_products", ["product_type_enum", "is_digital"]) + + # --- marketplace_product_translations --- + op.create_table( + "marketplace_product_translations", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("marketplace_product_id", sa.Integer(), sa.ForeignKey("marketplace_products.id", ondelete="CASCADE"), nullable=False), + sa.Column("language", sa.String(5), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("short_description", sa.String(500), nullable=True), + sa.Column("meta_title", sa.String(70), nullable=True), + sa.Column("meta_description", sa.String(160), nullable=True), + sa.Column("url_slug", sa.String(255), nullable=True), + sa.Column("source_import_id", sa.Integer(), nullable=True), + sa.Column("source_file", sa.String(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("marketplace_product_id", "language", name="uq_marketplace_product_translation"), + ) + op.create_index("idx_mpt_marketplace_product_id", "marketplace_product_translations", ["marketplace_product_id"]) + op.create_index("idx_mpt_language", "marketplace_product_translations", ["language"]) + + # --- marketplace_import_jobs --- + op.create_table( + "marketplace_import_jobs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("marketplace", sa.String(), nullable=False, server_default="Letzshop", index=True), + sa.Column("source_url", sa.String(), nullable=False), + sa.Column("language", sa.String(5), nullable=False, server_default="en"), + sa.Column("status", sa.String(), nullable=False, server_default="pending"), + sa.Column("imported_count", sa.Integer(), nullable=True, server_default="0"), + sa.Column("updated_count", sa.Integer(), nullable=True, server_default="0"), + sa.Column("error_count", sa.Integer(), nullable=True, server_default="0"), + sa.Column("total_processed", sa.Integer(), nullable=True, server_default="0"), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("celery_task_id", sa.String(255), nullable=True, index=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_import_store_status", "marketplace_import_jobs", ["store_id", "status"]) + op.create_index("idx_import_store_created", "marketplace_import_jobs", ["store_id", "created_at"]) + op.create_index("idx_import_user_marketplace", "marketplace_import_jobs", ["user_id", "marketplace"]) + + # --- marketplace_import_errors --- + op.create_table( + "marketplace_import_errors", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("import_job_id", sa.Integer(), sa.ForeignKey("marketplace_import_jobs.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("row_number", sa.Integer(), nullable=False), + sa.Column("identifier", sa.String(), nullable=True), + sa.Column("error_type", sa.String(50), nullable=False), + sa.Column("error_message", sa.Text(), nullable=False), + sa.Column("row_data", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_import_error_job_id", "marketplace_import_errors", ["import_job_id"]) + op.create_index("idx_import_error_type", "marketplace_import_errors", ["error_type"]) + + # --- store_letzshop_credentials --- + op.create_table( + "store_letzshop_credentials", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), unique=True, nullable=False, index=True), + sa.Column("api_key_encrypted", sa.Text(), nullable=False), + sa.Column("api_endpoint", sa.String(255), nullable=True, server_default="https://letzshop.lu/graphql"), + sa.Column("auto_sync_enabled", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("sync_interval_minutes", sa.Integer(), nullable=True, server_default="15"), + sa.Column("test_mode_enabled", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("default_carrier", sa.String(50), nullable=True), + sa.Column("carrier_greco_label_url", sa.String(500), nullable=True, server_default="https://dispatchweb.fr/Tracky/Home/"), + sa.Column("carrier_colissimo_label_url", sa.String(500), nullable=True), + sa.Column("carrier_xpresslogistics_label_url", sa.String(500), nullable=True), + sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_sync_status", sa.String(50), nullable=True), + sa.Column("last_sync_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- letzshop_sync_logs --- + op.create_table( + "letzshop_sync_logs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("operation_type", sa.String(50), nullable=False), + sa.Column("direction", sa.String(10), nullable=False), + sa.Column("status", sa.String(50), nullable=False), + sa.Column("records_processed", sa.Integer(), nullable=True, server_default="0"), + sa.Column("records_succeeded", sa.Integer(), nullable=True, server_default="0"), + sa.Column("records_failed", sa.Integer(), nullable=True, server_default="0"), + sa.Column("error_details", sa.JSON(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("duration_seconds", sa.Integer(), nullable=True), + sa.Column("triggered_by", sa.String(100), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_sync_log_store_type", "letzshop_sync_logs", ["store_id", "operation_type"]) + op.create_index("idx_sync_log_store_date", "letzshop_sync_logs", ["store_id", "started_at"]) + + # --- letzshop_store_cache --- + op.create_table( + "letzshop_store_cache", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("letzshop_id", sa.String(50), unique=True, nullable=False, index=True), + sa.Column("slug", sa.String(200), unique=True, nullable=False, index=True), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("merchant_name", sa.String(255), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=True, server_default="true"), + sa.Column("description_en", sa.Text(), nullable=True), + sa.Column("description_fr", sa.Text(), nullable=True), + sa.Column("description_de", sa.Text(), nullable=True), + sa.Column("email", sa.String(255), nullable=True), + sa.Column("phone", sa.String(50), nullable=True), + sa.Column("fax", sa.String(50), nullable=True), + sa.Column("website", sa.String(500), nullable=True), + sa.Column("street", sa.String(255), nullable=True), + sa.Column("street_number", sa.String(50), nullable=True), + sa.Column("city", sa.String(100), nullable=True), + sa.Column("zipcode", sa.String(20), nullable=True), + sa.Column("country_iso", sa.String(5), nullable=True, server_default="LU"), + sa.Column("latitude", sa.String(20), nullable=True), + sa.Column("longitude", sa.String(20), nullable=True), + sa.Column("categories", sa.JSON(), nullable=True), + sa.Column("background_image_url", sa.String(500), nullable=True), + sa.Column("social_media_links", sa.JSON(), nullable=True), + sa.Column("opening_hours_en", sa.Text(), nullable=True), + sa.Column("opening_hours_fr", sa.Text(), nullable=True), + sa.Column("opening_hours_de", sa.Text(), nullable=True), + sa.Column("representative_name", sa.String(255), nullable=True), + sa.Column("representative_title", sa.String(100), nullable=True), + sa.Column("claimed_by_store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True, index=True), + sa.Column("claimed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_synced_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("raw_data", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_letzshop_cache_city", "letzshop_store_cache", ["city"]) + op.create_index("idx_letzshop_cache_claimed", "letzshop_store_cache", ["claimed_by_store_id"]) + op.create_index("idx_letzshop_cache_active", "letzshop_store_cache", ["is_active"]) + + # --- letzshop_historical_import_jobs --- + op.create_table( + "letzshop_historical_import_jobs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("status", sa.String(50), nullable=False, server_default="pending"), + sa.Column("current_phase", sa.String(20), nullable=True), + sa.Column("current_page", sa.Integer(), nullable=True, server_default="0"), + sa.Column("total_pages", sa.Integer(), nullable=True), + sa.Column("shipments_fetched", sa.Integer(), nullable=True, server_default="0"), + sa.Column("orders_processed", sa.Integer(), nullable=True, server_default="0"), + sa.Column("orders_imported", sa.Integer(), nullable=True, server_default="0"), + sa.Column("orders_updated", sa.Integer(), nullable=True, server_default="0"), + sa.Column("orders_skipped", sa.Integer(), nullable=True, server_default="0"), + sa.Column("products_matched", sa.Integer(), nullable=True, server_default="0"), + sa.Column("products_not_found", sa.Integer(), nullable=True, server_default="0"), + sa.Column("confirmed_stats", sa.JSON(), nullable=True), + sa.Column("declined_stats", sa.JSON(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("celery_task_id", sa.String(255), nullable=True, index=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_historical_import_store", "letzshop_historical_import_jobs", ["store_id", "status"]) + + # --- store_onboarding --- + op.create_table( + "store_onboarding", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), unique=True, nullable=False, index=True), + sa.Column("status", sa.String(20), nullable=False, server_default="not_started", index=True), + sa.Column("current_step", sa.String(30), nullable=False, server_default="merchant_profile"), + sa.Column("step_merchant_profile_completed", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("step_merchant_profile_completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("step_merchant_profile_data", sa.JSON(), nullable=True), + sa.Column("step_letzshop_api_completed", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("step_letzshop_api_completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("step_letzshop_api_connection_verified", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("step_product_import_completed", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("step_product_import_completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("step_product_import_csv_url_set", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("step_order_sync_completed", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("step_order_sync_completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("step_order_sync_job_id", sa.Integer(), nullable=True), + sa.Column("started_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("skipped_by_admin", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("skipped_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("skipped_reason", sa.Text(), nullable=True), + sa.Column("skipped_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_onboarding_store_status", "store_onboarding", ["store_id", "status"]) + + +def downgrade() -> None: + op.drop_table("store_onboarding") + op.drop_table("letzshop_historical_import_jobs") + op.drop_table("letzshop_store_cache") + op.drop_table("letzshop_sync_logs") + op.drop_table("store_letzshop_credentials") + op.drop_table("marketplace_import_errors") + op.drop_table("marketplace_import_jobs") + op.drop_table("marketplace_product_translations") + op.drop_table("marketplace_products") diff --git a/app/modules/marketplace/models/letzshop.py b/app/modules/marketplace/models/letzshop.py index fbe09f94..d341e38c 100644 --- a/app/modules/marketplace/models/letzshop.py +++ b/app/modules/marketplace/models/letzshop.py @@ -263,9 +263,9 @@ class LetzshopStoreCache(Base, TimestampMixin): claimed_store = relationship("Store", foreign_keys=[claimed_by_store_id]) __table_args__ = ( - Index("idx_vendor_cache_city", "city"), - Index("idx_vendor_cache_claimed", "claimed_by_store_id"), - Index("idx_vendor_cache_active", "is_active"), + Index("idx_letzshop_cache_city", "city"), + Index("idx_letzshop_cache_claimed", "claimed_by_store_id"), + Index("idx_letzshop_cache_active", "is_active"), ) def __repr__(self): diff --git a/app/modules/messaging/definition.py b/app/modules/messaging/definition.py index e7666697..f7d41043 100644 --- a/app/modules/messaging/definition.py +++ b/app/modules/messaging/definition.py @@ -176,6 +176,7 @@ messaging_module = ModuleDefinition( models_path="app.modules.messaging.models", schemas_path="app.modules.messaging.schemas", exceptions_path="app.modules.messaging.exceptions", + migrations_path="migrations", # Feature provider for feature flags feature_provider=_get_feature_provider, ) diff --git a/app/modules/messaging/migrations/versions/messaging_001_initial.py b/app/modules/messaging/migrations/versions/messaging_001_initial.py new file mode 100644 index 00000000..9621033b --- /dev/null +++ b/app/modules/messaging/migrations/versions/messaging_001_initial.py @@ -0,0 +1,229 @@ +"""messaging initial - email settings, templates, logs, conversations, notifications + +Revision ID: messaging_001 +Revises: cart_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "messaging_001" +down_revision = "cart_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- store_email_settings --- + op.create_table( + "store_email_settings", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), unique=True, nullable=False, index=True), + sa.Column("from_email", sa.String(255), nullable=False), + sa.Column("from_name", sa.String(100), nullable=False), + sa.Column("reply_to_email", sa.String(255), nullable=True), + sa.Column("signature_text", sa.Text(), nullable=True), + sa.Column("signature_html", sa.Text(), nullable=True), + sa.Column("provider", sa.String(20), nullable=False, server_default="smtp"), + sa.Column("smtp_host", sa.String(255), nullable=True), + sa.Column("smtp_port", sa.Integer(), nullable=True, server_default="587"), + sa.Column("smtp_username", sa.String(255), nullable=True), + sa.Column("smtp_password", sa.String(500), nullable=True), + sa.Column("smtp_use_tls", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("smtp_use_ssl", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("sendgrid_api_key", sa.String(500), nullable=True), + sa.Column("mailgun_api_key", sa.String(500), nullable=True), + sa.Column("mailgun_domain", sa.String(255), nullable=True), + sa.Column("ses_access_key_id", sa.String(100), nullable=True), + sa.Column("ses_secret_access_key", sa.String(500), nullable=True), + sa.Column("ses_region", sa.String(50), nullable=True, server_default="eu-west-1"), + sa.Column("is_configured", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("is_verified", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("last_verified_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("verification_error", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_store_email_settings_configured", "store_email_settings", ["store_id", "is_configured"]) + + # --- store_email_templates --- + op.create_table( + "store_email_templates", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("template_code", sa.String(100), nullable=False, index=True), + sa.Column("language", sa.String(5), nullable=False, server_default="en"), + sa.Column("name", sa.String(255), nullable=True), + sa.Column("subject", sa.String(500), nullable=False), + sa.Column("body_html", sa.Text(), nullable=False), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("store_id", "template_code", "language", name="uq_store_email_template_code_language"), + ) + + # --- email_templates (system/platform templates) --- + op.create_table( + "email_templates", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("code", sa.String(100), nullable=False, index=True), + sa.Column("language", sa.String(5), nullable=False, server_default="en"), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("category", sa.String(50), nullable=False, server_default="system", index=True), + sa.Column("subject", sa.String(500), nullable=False), + sa.Column("body_html", sa.Text(), nullable=False), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("variables", sa.Text(), nullable=True), + sa.Column("required_variables", sa.Text(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("is_platform_only", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_email_templates_code_language", "email_templates", ["code", "language"], unique=True) + + # --- email_logs --- + op.create_table( + "email_logs", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("template_code", sa.String(100), nullable=True, index=True), + sa.Column("template_id", sa.Integer(), sa.ForeignKey("email_templates.id"), nullable=True), + sa.Column("recipient_email", sa.String(255), nullable=False, index=True), + sa.Column("recipient_name", sa.String(255), nullable=True), + sa.Column("subject", sa.String(500), nullable=False), + sa.Column("body_html", sa.Text(), nullable=True), + sa.Column("body_text", sa.Text(), nullable=True), + sa.Column("from_email", sa.String(255), nullable=False), + sa.Column("from_name", sa.String(255), nullable=True), + sa.Column("reply_to", sa.String(255), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending", index=True), + sa.Column("sent_at", sa.DateTime(), nullable=True), + sa.Column("delivered_at", sa.DateTime(), nullable=True), + sa.Column("opened_at", sa.DateTime(), nullable=True), + sa.Column("clicked_at", sa.DateTime(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("provider", sa.String(50), nullable=True), + sa.Column("provider_message_id", sa.String(255), nullable=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True, index=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True, index=True), + sa.Column("related_type", sa.String(50), nullable=True), + sa.Column("related_id", sa.Integer(), nullable=True), + sa.Column("extra_data", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- conversations --- + conversation_type_enum = sa.Enum( + "admin_store", "store_customer", "admin_customer", + name="conversationtype", + ) + participant_type_enum = sa.Enum( + "admin", "store", "customer", + name="participanttype", + ) + op.create_table( + "conversations", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("conversation_type", conversation_type_enum, nullable=False, index=True), + sa.Column("subject", sa.String(500), nullable=False), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True, index=True), + sa.Column("is_closed", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("closed_at", sa.DateTime(), nullable=True), + sa.Column("closed_by_type", participant_type_enum, nullable=True), + sa.Column("closed_by_id", sa.Integer(), nullable=True), + sa.Column("last_message_at", sa.DateTime(), nullable=True, index=True), + sa.Column("message_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_conversations_type_store", "conversations", ["conversation_type", "store_id"]) + + # --- conversation_participants --- + op.create_table( + "conversation_participants", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("conversation_id", sa.Integer(), sa.ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("participant_type", sa.Enum("admin", "store", "customer", name="participanttype", create_type=False), nullable=False), + sa.Column("participant_id", sa.Integer(), nullable=False, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=True), + sa.Column("unread_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("last_read_at", sa.DateTime(), nullable=True), + sa.Column("email_notifications", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("muted", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.UniqueConstraint("conversation_id", "participant_type", "participant_id", name="uq_conversation_participant"), + ) + op.create_index("ix_participant_lookup", "conversation_participants", ["participant_type", "participant_id"]) + + # --- messages --- + op.create_table( + "messages", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("conversation_id", sa.Integer(), sa.ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("sender_type", sa.Enum("admin", "store", "customer", name="participanttype", create_type=False), nullable=False), + sa.Column("sender_id", sa.Integer(), nullable=False, index=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("is_system_message", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("deleted_by_type", sa.Enum("admin", "store", "customer", name="participanttype", create_type=False), nullable=True), + sa.Column("deleted_by_id", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("ix_messages_conversation_created", "messages", ["conversation_id", "created_at"]) + + # --- message_attachments --- + op.create_table( + "message_attachments", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("message_id", sa.Integer(), sa.ForeignKey("messages.id", ondelete="CASCADE"), nullable=False, index=True), + sa.Column("filename", sa.String(255), nullable=False), + sa.Column("original_filename", sa.String(255), nullable=False), + sa.Column("file_path", sa.String(1000), nullable=False), + sa.Column("file_size", sa.Integer(), nullable=False), + sa.Column("mime_type", sa.String(100), nullable=False), + sa.Column("is_image", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("image_width", sa.Integer(), nullable=True), + sa.Column("image_height", sa.Integer(), nullable=True), + sa.Column("thumbnail_path", sa.String(1000), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- admin_notifications --- + op.create_table( + "admin_notifications", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("type", sa.String(50), nullable=False, index=True), + sa.Column("priority", sa.String(20), nullable=True, server_default="normal", index=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("message", sa.Text(), nullable=False), + sa.Column("is_read", sa.Boolean(), nullable=True, server_default="false", index=True), + sa.Column("read_at", sa.DateTime(), nullable=True), + sa.Column("read_by_user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("action_required", sa.Boolean(), nullable=True, server_default="false", index=True), + sa.Column("action_url", sa.String(500), nullable=True), + sa.Column("notification_metadata", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + +def downgrade() -> None: + op.drop_table("admin_notifications") + op.drop_table("message_attachments") + op.drop_table("messages") + op.drop_table("conversation_participants") + op.drop_table("conversations") + op.drop_table("email_logs") + op.drop_table("email_templates") + op.drop_table("store_email_templates") + op.drop_table("store_email_settings") + sa.Enum(name="conversationtype").drop(op.get_bind(), checkfirst=True) + sa.Enum(name="participanttype").drop(op.get_bind(), checkfirst=True) diff --git a/app/modules/messaging/models/store_email_settings.py b/app/modules/messaging/models/store_email_settings.py index 20407b82..1c88f610 100644 --- a/app/modules/messaging/models/store_email_settings.py +++ b/app/modules/messaging/models/store_email_settings.py @@ -136,7 +136,7 @@ class StoreEmailSettings(Base, TimestampMixin): # Indexes # ========================================================================= __table_args__ = ( - Index("idx_vendor_email_settings_configured", "store_id", "is_configured"), + Index("idx_store_email_settings_configured", "store_id", "is_configured"), ) def __repr__(self) -> str: diff --git a/app/modules/messaging/models/store_email_template.py b/app/modules/messaging/models/store_email_template.py index f367cb61..c709b9b3 100644 --- a/app/modules/messaging/models/store_email_template.py +++ b/app/modules/messaging/models/store_email_template.py @@ -75,7 +75,7 @@ class StoreEmailTemplate(Base, TimestampMixin): "store_id", "template_code", "language", - name="uq_vendor_email_template_code_language", + name="uq_store_email_template_code_language", ), {"sqlite_autoincrement": True}, ) diff --git a/app/modules/orders/definition.py b/app/modules/orders/definition.py index 73c98d5d..061e8912 100644 --- a/app/modules/orders/definition.py +++ b/app/modules/orders/definition.py @@ -147,6 +147,7 @@ orders_module = ModuleDefinition( models_path="app.modules.orders.models", schemas_path="app.modules.orders.schemas", exceptions_path="app.modules.orders.exceptions", + migrations_path="migrations", # Metrics provider for dashboard statistics metrics_provider=_get_metrics_provider, feature_provider=_get_feature_provider, diff --git a/app/modules/orders/migrations/versions/orders_001_initial.py b/app/modules/orders/migrations/versions/orders_001_initial.py new file mode 100644 index 00000000..203a4205 --- /dev/null +++ b/app/modules/orders/migrations/versions/orders_001_initial.py @@ -0,0 +1,216 @@ +"""orders initial - orders, order items, exceptions, invoices, fulfillment queue + +Revision ID: orders_001 +Revises: customers_001 +Create Date: 2026-02-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "orders_001" +down_revision = "customers_001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # --- orders --- + op.create_table( + "orders", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("customer_id", sa.Integer(), sa.ForeignKey("customers.id"), nullable=False, index=True), + sa.Column("order_number", sa.String(100), unique=True, nullable=False, index=True), + sa.Column("channel", sa.String(50), nullable=False, server_default="direct", index=True), + sa.Column("external_order_id", sa.String(100), nullable=True, index=True), + sa.Column("external_shipment_id", sa.String(100), nullable=True, index=True), + sa.Column("external_order_number", sa.String(100), nullable=True), + sa.Column("external_data", sa.JSON(), nullable=True), + sa.Column("status", sa.String(50), nullable=False, server_default="pending", index=True), + sa.Column("subtotal_cents", sa.Integer(), nullable=True), + sa.Column("tax_amount_cents", sa.Integer(), nullable=True), + sa.Column("shipping_amount_cents", sa.Integer(), nullable=True), + sa.Column("discount_amount_cents", sa.Integer(), nullable=True), + sa.Column("total_amount_cents", sa.Integer(), nullable=False), + sa.Column("currency", sa.String(10), nullable=True, server_default="EUR"), + sa.Column("vat_regime", sa.String(20), nullable=True), + sa.Column("vat_rate", sa.Numeric(5, 2), nullable=True), + sa.Column("vat_rate_label", sa.String(100), nullable=True), + sa.Column("vat_destination_country", sa.String(2), nullable=True), + sa.Column("customer_first_name", sa.String(100), nullable=False), + sa.Column("customer_last_name", sa.String(100), nullable=False), + sa.Column("customer_email", sa.String(255), nullable=False), + sa.Column("customer_phone", sa.String(50), nullable=True), + sa.Column("customer_locale", sa.String(10), nullable=True), + sa.Column("ship_first_name", sa.String(100), nullable=False), + sa.Column("ship_last_name", sa.String(100), nullable=False), + sa.Column("ship_company", sa.String(200), nullable=True), + sa.Column("ship_address_line_1", sa.String(255), nullable=False), + sa.Column("ship_address_line_2", sa.String(255), nullable=True), + sa.Column("ship_city", sa.String(100), nullable=False), + sa.Column("ship_postal_code", sa.String(20), nullable=False), + sa.Column("ship_country_iso", sa.String(5), nullable=False), + sa.Column("bill_first_name", sa.String(100), nullable=False), + sa.Column("bill_last_name", sa.String(100), nullable=False), + sa.Column("bill_company", sa.String(200), nullable=True), + sa.Column("bill_address_line_1", sa.String(255), nullable=False), + sa.Column("bill_address_line_2", sa.String(255), nullable=True), + sa.Column("bill_city", sa.String(100), nullable=False), + sa.Column("bill_postal_code", sa.String(20), nullable=False), + sa.Column("bill_country_iso", sa.String(5), nullable=False), + sa.Column("shipping_method", sa.String(100), nullable=True), + sa.Column("tracking_number", sa.String(100), nullable=True), + sa.Column("tracking_provider", sa.String(100), nullable=True), + sa.Column("tracking_url", sa.String(500), nullable=True), + sa.Column("shipment_number", sa.String(100), nullable=True), + sa.Column("shipping_carrier", sa.String(50), nullable=True), + sa.Column("customer_notes", sa.Text(), nullable=True), + sa.Column("internal_notes", sa.Text(), nullable=True), + sa.Column("order_date", sa.DateTime(timezone=True), nullable=False), + sa.Column("confirmed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("shipped_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("cancelled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_order_store_status", "orders", ["store_id", "status"]) + op.create_index("idx_order_store_channel", "orders", ["store_id", "channel"]) + op.create_index("idx_order_store_date", "orders", ["store_id", "order_date"]) + + # --- order_items --- + op.create_table( + "order_items", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("order_id", sa.Integer(), sa.ForeignKey("orders.id"), nullable=False, index=True), + sa.Column("product_id", sa.Integer(), sa.ForeignKey("products.id"), nullable=False), + sa.Column("product_name", sa.String(255), nullable=False), + sa.Column("product_sku", sa.String(100), nullable=True), + sa.Column("gtin", sa.String(50), nullable=True), + sa.Column("gtin_type", sa.String(20), nullable=True), + sa.Column("quantity", sa.Integer(), nullable=False), + sa.Column("unit_price_cents", sa.Integer(), nullable=False), + sa.Column("total_price_cents", sa.Integer(), nullable=False), + sa.Column("external_item_id", sa.String(100), nullable=True), + sa.Column("external_variant_id", sa.String(100), nullable=True), + sa.Column("item_state", sa.String(50), nullable=True), + sa.Column("inventory_reserved", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("inventory_fulfilled", sa.Boolean(), nullable=True, server_default="false"), + sa.Column("shipped_quantity", sa.Integer(), nullable=False, server_default="0"), + sa.Column("needs_product_match", sa.Boolean(), nullable=True, server_default="false", index=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- order_item_exceptions --- + op.create_table( + "order_item_exceptions", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("order_item_id", sa.Integer(), sa.ForeignKey("order_items.id", ondelete="CASCADE"), unique=True, nullable=False), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("original_gtin", sa.String(50), nullable=True, index=True), + sa.Column("original_product_name", sa.String(500), nullable=True), + sa.Column("original_sku", sa.String(100), nullable=True), + sa.Column("exception_type", sa.String(50), nullable=False, server_default="product_not_found"), + sa.Column("status", sa.String(50), nullable=False, server_default="pending", index=True), + sa.Column("resolved_product_id", sa.Integer(), sa.ForeignKey("products.id"), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_by", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("resolution_notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_exception_store_status", "order_item_exceptions", ["store_id", "status"]) + op.create_index("idx_exception_gtin", "order_item_exceptions", ["store_id", "original_gtin"]) + + # --- store_invoice_settings --- + op.create_table( + "store_invoice_settings", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), unique=True, nullable=False, index=True), + sa.Column("merchant_name", sa.String(255), nullable=False), + sa.Column("merchant_address", sa.String(255), nullable=True), + sa.Column("merchant_city", sa.String(100), nullable=True), + sa.Column("merchant_postal_code", sa.String(20), nullable=True), + sa.Column("merchant_country", sa.String(2), nullable=False, server_default="LU"), + sa.Column("vat_number", sa.String(50), nullable=True), + sa.Column("is_vat_registered", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("is_oss_registered", sa.Boolean(), nullable=False, server_default="false"), + sa.Column("oss_registration_country", sa.String(2), nullable=True), + sa.Column("invoice_prefix", sa.String(20), nullable=False, server_default="INV"), + sa.Column("invoice_next_number", sa.Integer(), nullable=False, server_default="1"), + sa.Column("invoice_number_padding", sa.Integer(), nullable=False, server_default="5"), + sa.Column("payment_terms", sa.Text(), nullable=True), + sa.Column("bank_name", sa.String(255), nullable=True), + sa.Column("bank_iban", sa.String(50), nullable=True), + sa.Column("bank_bic", sa.String(20), nullable=True), + sa.Column("footer_text", sa.Text(), nullable=True), + sa.Column("default_vat_rate", sa.Numeric(5, 2), nullable=False, server_default="17.00"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + + # --- invoices --- + op.create_table( + "invoices", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("order_id", sa.Integer(), sa.ForeignKey("orders.id"), nullable=True, index=True), + sa.Column("invoice_number", sa.String(50), nullable=False), + sa.Column("invoice_date", sa.DateTime(timezone=True), nullable=False), + sa.Column("status", sa.String(20), nullable=False, server_default="draft"), + sa.Column("seller_details", sa.JSON(), nullable=False), + sa.Column("buyer_details", sa.JSON(), nullable=False), + sa.Column("line_items", sa.JSON(), nullable=False), + sa.Column("vat_regime", sa.String(20), nullable=False, server_default="domestic"), + sa.Column("destination_country", sa.String(2), nullable=True), + sa.Column("vat_rate", sa.Numeric(5, 2), nullable=False), + sa.Column("vat_rate_label", sa.String(50), nullable=True), + sa.Column("currency", sa.String(3), nullable=False, server_default="EUR"), + sa.Column("subtotal_cents", sa.Integer(), nullable=False), + sa.Column("vat_amount_cents", sa.Integer(), nullable=False), + sa.Column("total_cents", sa.Integer(), nullable=False), + sa.Column("payment_terms", sa.Text(), nullable=True), + sa.Column("bank_details", sa.JSON(), nullable=True), + sa.Column("footer_text", sa.Text(), nullable=True), + sa.Column("pdf_generated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("pdf_path", sa.String(500), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_invoice_store_number", "invoices", ["store_id", "invoice_number"], unique=True) + op.create_index("idx_invoice_store_date", "invoices", ["store_id", "invoice_date"]) + op.create_index("idx_invoice_status", "invoices", ["store_id", "status"]) + + # --- letzshop_fulfillment_queue (depends on orders + stores) --- + op.create_table( + "letzshop_fulfillment_queue", + sa.Column("id", sa.Integer(), primary_key=True, index=True), + sa.Column("store_id", sa.Integer(), sa.ForeignKey("stores.id"), nullable=False, index=True), + sa.Column("order_id", sa.Integer(), sa.ForeignKey("orders.id"), nullable=False, index=True), + sa.Column("operation", sa.String(50), nullable=False), + sa.Column("payload", sa.JSON(), nullable=False), + sa.Column("status", sa.String(50), nullable=True, server_default="pending"), + sa.Column("attempts", sa.Integer(), nullable=True, server_default="0"), + sa.Column("max_attempts", sa.Integer(), nullable=True, server_default="3"), + sa.Column("last_attempt_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("next_retry_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("response_data", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False), + ) + op.create_index("idx_fulfillment_queue_status", "letzshop_fulfillment_queue", ["status", "store_id"]) + op.create_index("idx_fulfillment_queue_retry", "letzshop_fulfillment_queue", ["status", "next_retry_at"]) + op.create_index("idx_fulfillment_queue_order", "letzshop_fulfillment_queue", ["order_id"]) + + +def downgrade() -> None: + op.drop_table("letzshop_fulfillment_queue") + op.drop_table("invoices") + op.drop_table("store_invoice_settings") + op.drop_table("order_item_exceptions") + op.drop_table("order_items") + op.drop_table("orders") diff --git a/app/modules/tenancy/models/store_domain.py b/app/modules/tenancy/models/store_domain.py index 96da33ef..6a1b279e 100644 --- a/app/modules/tenancy/models/store_domain.py +++ b/app/modules/tenancy/models/store_domain.py @@ -57,9 +57,9 @@ class StoreDomain(Base, TimestampMixin): # Constraints __table_args__ = ( - UniqueConstraint("store_id", "domain", name="uq_vendor_domain"), + UniqueConstraint("store_id", "domain", name="uq_store_domain"), Index("idx_domain_active", "domain", "is_active"), - Index("idx_vendor_primary", "store_id", "is_primary"), + Index("idx_store_domain_primary", "store_id", "is_primary"), ) def __repr__(self): diff --git a/app/modules/tenancy/models/store_platform.py b/app/modules/tenancy/models/store_platform.py index 731ca1d2..4bb3f849 100644 --- a/app/modules/tenancy/models/store_platform.py +++ b/app/modules/tenancy/models/store_platform.py @@ -150,17 +150,17 @@ class StorePlatform(Base, TimestampMixin): UniqueConstraint( "store_id", "platform_id", - name="uq_vendor_platform", + name="uq_store_platform", ), # Performance indexes Index( - "idx_vendor_platform_active", + "idx_store_platform_active", "store_id", "platform_id", "is_active", ), Index( - "idx_vendor_platform_primary", + "idx_store_platform_primary", "store_id", "is_primary", ), diff --git a/scripts/squash_migrations.py b/scripts/squash_migrations.py index 839caff0..165748d2 100644 --- a/scripts/squash_migrations.py +++ b/scripts/squash_migrations.py @@ -2,7 +2,7 @@ """ Migration Squash Script -This script squashes all existing migrations into a single initial migration. +This script squashes all existing migrations into fresh per-module initial migrations. Run this after setting up PostgreSQL to simplify the migration history. Prerequisites: @@ -13,13 +13,13 @@ Usage: python scripts/squash_migrations.py What this script does: -1. Backs up existing migrations to alembic/versions_backup_YYYYMMDD/ +1. Backs up existing migrations from all version_locations to a timestamped backup 2. Creates a fresh initial migration from current models 3. Stamps the database as being at the new migration After running: -1. Review the new migration in alembic/versions/ -2. Test with: make migrate-up (on a fresh database) +1. Review the new migration files +2. Test with: make db-reset (drops schema, runs all migrations, seeds data) 3. If satisfied, delete the backup directory """ @@ -36,6 +36,22 @@ sys.path.insert(0, str(project_root)) VERSIONS_DIR = project_root / "alembic" / "versions" +# All migration version directories (core + modules) +MODULE_MIGRATION_DIRS = [ + project_root / "alembic" / "versions", + project_root / "app" / "modules" / "billing" / "migrations" / "versions", + project_root / "app" / "modules" / "cart" / "migrations" / "versions", + project_root / "app" / "modules" / "catalog" / "migrations" / "versions", + project_root / "app" / "modules" / "cms" / "migrations" / "versions", + project_root / "app" / "modules" / "customers" / "migrations" / "versions", + project_root / "app" / "modules" / "dev_tools" / "migrations" / "versions", + project_root / "app" / "modules" / "inventory" / "migrations" / "versions", + project_root / "app" / "modules" / "loyalty" / "migrations" / "versions", + project_root / "app" / "modules" / "marketplace" / "migrations" / "versions", + project_root / "app" / "modules" / "messaging" / "migrations" / "versions", + project_root / "app" / "modules" / "orders" / "migrations" / "versions", +] + def check_prerequisites(): """Verify PostgreSQL is configured.""" @@ -53,28 +69,42 @@ def check_prerequisites(): def backup_migrations(): - """Backup existing migrations.""" + """Backup existing migrations from all version locations.""" timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_dir = project_root / "alembic" / f"versions_backup_{timestamp}" + backup_dir.mkdir(parents=True, exist_ok=True) - if not VERSIONS_DIR.exists(): - print("No existing migrations to backup") + total_backed_up = 0 + + for versions_dir in MODULE_MIGRATION_DIRS: + if not versions_dir.exists(): + continue + + migration_files = [f for f in versions_dir.glob("*.py") if f.name != "__init__.py"] + if not migration_files: + continue + + # Create a subdirectory in backup matching the source path + rel_path = versions_dir.relative_to(project_root) + target_dir = backup_dir / str(rel_path).replace("/", "_") + target_dir.mkdir(parents=True, exist_ok=True) + + for f in migration_files: + shutil.copy2(f, target_dir / f.name) + total_backed_up += 1 + + # Remove migration files from source (keep __init__.py) + for f in migration_files: + f.unlink() + + if total_backed_up > 0: + print(f"Backed up {total_backed_up} migrations to {backup_dir.name}/") + return backup_dir + else: + print("No migration files found to backup") + backup_dir.rmdir() return None - migration_files = list(VERSIONS_DIR.glob("*.py")) - if not migration_files: - print("No migration files found") - return None - - print(f"Backing up {len(migration_files)} migrations to {backup_dir.name}/") - shutil.copytree(VERSIONS_DIR, backup_dir) - - # Clear versions directory (keep __pycache__ if exists) - for f in VERSIONS_DIR.glob("*.py"): - f.unlink() - - return backup_dir - def create_fresh_migration(): """Generate fresh initial migration from models.""" @@ -116,7 +146,6 @@ def clean_migration_file(migration_path: Path): # Remove batch_alter_table references (not needed for PostgreSQL) if "batch_alter_table" in content: print("Note: Migration contains batch_alter_table - this is not needed for PostgreSQL") - # We don't auto-remove as it might be intentional print(f"Review migration at: {migration_path}") @@ -178,14 +207,14 @@ def main(): print(f"Backup location: {backup_dir}") print("") print("Next steps:") - print("1. Review the new migration file") - print("2. On a fresh database, run: make migrate-up") + print("1. Review the new migration files") + print("2. On a fresh database, run: make db-reset") print("3. Verify all tables are created correctly") print("4. If satisfied, delete the backup directory") print("") - print("To restore from backup:") - print(f" rm -rf alembic/versions/*.py") - print(f" cp -r {backup_dir}/* alembic/versions/") + if backup_dir: + print("To restore from backup:") + print(f" Check {backup_dir}/ for backed up migration files") if __name__ == "__main__":