feat(loyalty): implement complete loyalty module MVP
Add stamp-based and points-based loyalty programs for vendors with: Database Models (5 tables): - loyalty_programs: Vendor program configuration - loyalty_cards: Customer cards with stamp/point balances - loyalty_transactions: Immutable audit log - staff_pins: Fraud prevention PINs (bcrypt hashed) - apple_device_registrations: Apple Wallet push tokens Services: - program_service: Program CRUD and statistics - card_service: Customer enrollment and card lookup - stamp_service: Stamp operations with anti-fraud checks - points_service: Points earning and redemption - pin_service: Staff PIN management with lockout - wallet_service: Unified wallet abstraction - google_wallet_service: Google Wallet API integration - apple_wallet_service: Apple Wallet .pkpass generation API Routes: - Admin: /api/v1/admin/loyalty/* (programs list, stats) - Vendor: /api/v1/vendor/loyalty/* (stamp, points, cards, PINs) - Public: /api/v1/loyalty/* (enrollment, Apple Web Service) Anti-Fraud Features: - Staff PIN verification (configurable per program) - Cooldown period between stamps (default 15 min) - Daily stamp limits (default 5/day) - PIN lockout after failed attempts Wallet Integration: - Google Wallet: LoyaltyClass and LoyaltyObject management - Apple Wallet: .pkpass generation with PKCS#7 signing - Apple Web Service endpoints for device registration/updates Also includes: - Alembic migration for all tables with indexes - Localization files (en, fr, de, lu) - Module documentation - Phase 2 interface and user journey plan Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
650
alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py
Normal file
650
alembic/versions/0fb5d6d6ff97_add_loyalty_module_tables.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""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('vendor_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 vendor 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(['vendor_id'], ['vendors.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index('idx_loyalty_program_vendor_active', 'loyalty_programs', ['vendor_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_vendor_id'), 'loyalty_programs', ['vendor_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('vendor_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(['vendor_id'], ['vendors.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_vendor_active', 'loyalty_cards', ['vendor_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_vendor_id'), 'loyalty_cards', ['vendor_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('vendor_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(['vendor_id'], ['vendors.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_vendor_active', 'staff_pins', ['vendor_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_vendor_id'), 'staff_pins', ['vendor_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('vendor_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(['vendor_id'], ['vendors.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_vendor_date', 'loyalty_transactions', ['vendor_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_vendor_id'), 'loyalty_transactions', ['vendor_id'], unique=False)
|
||||
op.alter_column('admin_menu_configs', 'platform_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Platform scope - applies to users/vendors 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', 'vendor_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Vendor 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 = vendor 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('vendor_platforms', 'vendor_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Reference to the vendor',
|
||||
existing_nullable=False)
|
||||
op.alter_column('vendor_platforms', 'platform_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Reference to the platform',
|
||||
existing_nullable=False)
|
||||
op.alter_column('vendor_platforms', 'tier_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment='Platform-specific subscription tier',
|
||||
existing_nullable=True)
|
||||
op.alter_column('vendor_platforms', 'is_active',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
comment='Whether the vendor is active on this platform',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('true'))
|
||||
op.alter_column('vendor_platforms', 'is_primary',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
comment="Whether this is the vendor's primary platform",
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('vendor_platforms', 'custom_subdomain',
|
||||
existing_type=sa.VARCHAR(length=100),
|
||||
comment='Platform-specific subdomain (if different from main subdomain)',
|
||||
existing_nullable=True)
|
||||
op.alter_column('vendor_platforms', 'settings',
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
comment='Platform-specific vendor settings',
|
||||
existing_nullable=True)
|
||||
op.alter_column('vendor_platforms', 'joined_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
comment='When the vendor joined this platform',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('vendor_platforms', 'created_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
type_=sa.DateTime(),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('vendor_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_vendor_platforms_id'), 'vendor_platforms', ['id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_vendor_platforms_id'), table_name='vendor_platforms')
|
||||
op.alter_column('vendor_platforms', 'updated_at',
|
||||
existing_type=sa.DateTime(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('vendor_platforms', 'created_at',
|
||||
existing_type=sa.DateTime(),
|
||||
type_=postgresql.TIMESTAMP(timezone=True),
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('vendor_platforms', 'joined_at',
|
||||
existing_type=postgresql.TIMESTAMP(timezone=True),
|
||||
comment=None,
|
||||
existing_comment='When the vendor joined this platform',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('now()'))
|
||||
op.alter_column('vendor_platforms', 'settings',
|
||||
existing_type=postgresql.JSON(astext_type=sa.Text()),
|
||||
comment=None,
|
||||
existing_comment='Platform-specific vendor settings',
|
||||
existing_nullable=True)
|
||||
op.alter_column('vendor_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('vendor_platforms', 'is_primary',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
comment=None,
|
||||
existing_comment="Whether this is the vendor's primary platform",
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('vendor_platforms', 'is_active',
|
||||
existing_type=sa.BOOLEAN(),
|
||||
comment=None,
|
||||
existing_comment='Whether the vendor is active on this platform',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('true'))
|
||||
op.alter_column('vendor_platforms', 'tier_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='Platform-specific subscription tier',
|
||||
existing_nullable=True)
|
||||
op.alter_column('vendor_platforms', 'platform_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='Reference to the platform',
|
||||
existing_nullable=False)
|
||||
op.alter_column('vendor_platforms', 'vendor_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='Reference to the vendor',
|
||||
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 = vendor default or override',
|
||||
existing_nullable=False,
|
||||
existing_server_default=sa.text('false'))
|
||||
op.alter_column('content_pages', 'vendor_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
comment=None,
|
||||
existing_comment='Vendor 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/vendors of this platform',
|
||||
existing_nullable=True)
|
||||
op.drop_index(op.f('ix_loyalty_transactions_vendor_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_vendor_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_vendor_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_vendor_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_vendor_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_vendor_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_vendor_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_vendor_active', table_name='loyalty_programs')
|
||||
op.drop_table('loyalty_programs')
|
||||
# ### end Alembic commands ###
|
||||
Reference in New Issue
Block a user