fix(loyalty): route prefix, module migrations, and wallet barcode tests
- Fix 404 on /admin/loyalty/* and /vendor/loyalty/* by applying ROUTE_CONFIG custom_prefix when registering page routers in main.py (admin, vendor, and storefront registrations all updated) - Move loyalty alembic migrations from central alembic/versions/ into app/modules/loyalty/migrations/versions/ with proper naming convention - Add migrations_path="migrations" to loyalty module definition so the auto-discovery system finds them - Add unit tests for Apple/Google Wallet Code 128 barcode configuration (6 Apple tests, 4 Google tests) - Add integration tests for module migration auto-discovery (4 tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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('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 ###
|
||||
@@ -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": """<div class="hero-section">
|
||||
<h1>Build Customer Loyalty That Lasts</h1>
|
||||
<p class="lead">Reward your customers, increase retention, and grow your business with Loyalty+</p>
|
||||
</div>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature">
|
||||
<h3>Points & Rewards</h3>
|
||||
<p>Create custom point systems that incentivize repeat purchases and customer engagement.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Member Tiers</h3>
|
||||
<p>Reward your best customers with exclusive benefits and VIP treatment.</p>
|
||||
</div>
|
||||
<div class="feature">
|
||||
<h3>Real-time Analytics</h3>
|
||||
<p>Track program performance and customer behavior with detailed insights.</p>
|
||||
</div>
|
||||
</div>""",
|
||||
"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": """<div class="pricing-header">
|
||||
<h1>Simple, Transparent Pricing</h1>
|
||||
<p>Choose the plan that fits your business</p>
|
||||
</div>
|
||||
|
||||
<div class="pricing-grid">
|
||||
<div class="pricing-card">
|
||||
<h3>Starter</h3>
|
||||
<div class="price">€49<span>/month</span></div>
|
||||
<ul>
|
||||
<li>Up to 500 members</li>
|
||||
<li>Basic point system</li>
|
||||
<li>Email support</li>
|
||||
<li>Standard rewards</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pricing-card featured">
|
||||
<h3>Growth</h3>
|
||||
<div class="price">€149<span>/month</span></div>
|
||||
<ul>
|
||||
<li>Up to 5,000 members</li>
|
||||
<li>Advanced point rules</li>
|
||||
<li>Priority support</li>
|
||||
<li>Custom rewards</li>
|
||||
<li>Member tiers</li>
|
||||
<li>Analytics dashboard</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="pricing-card">
|
||||
<h3>Enterprise</h3>
|
||||
<div class="price">Custom</div>
|
||||
<ul>
|
||||
<li>Unlimited members</li>
|
||||
<li>Full API access</li>
|
||||
<li>Dedicated support</li>
|
||||
<li>Custom integrations</li>
|
||||
<li>White-label options</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>""",
|
||||
"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": """<div class="features-header">
|
||||
<h1>Powerful Features for Modern Loyalty</h1>
|
||||
<p>Everything you need to build and manage a successful loyalty program</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-section">
|
||||
<h2>Points & Earning Rules</h2>
|
||||
<p>Create flexible point systems with custom earning rules based on purchases, actions, or special events.</p>
|
||||
<ul>
|
||||
<li>Points per euro spent</li>
|
||||
<li>Bonus point campaigns</li>
|
||||
<li>Birthday & anniversary rewards</li>
|
||||
<li>Referral bonuses</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-section">
|
||||
<h2>Rewards Catalog</h2>
|
||||
<p>Offer enticing rewards that keep customers coming back.</p>
|
||||
<ul>
|
||||
<li>Discount vouchers</li>
|
||||
<li>Free products</li>
|
||||
<li>Exclusive experiences</li>
|
||||
<li>Partner rewards</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-section">
|
||||
<h2>Member Tiers</h2>
|
||||
<p>Recognize and reward your most loyal customers with tiered benefits.</p>
|
||||
<ul>
|
||||
<li>Bronze, Silver, Gold, Platinum levels</li>
|
||||
<li>Automatic tier progression</li>
|
||||
<li>Exclusive tier benefits</li>
|
||||
<li>VIP experiences</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="feature-section">
|
||||
<h2>Analytics & Insights</h2>
|
||||
<p>Make data-driven decisions with comprehensive analytics.</p>
|
||||
<ul>
|
||||
<li>Member activity tracking</li>
|
||||
<li>Redemption analytics</li>
|
||||
<li>ROI calculations</li>
|
||||
<li>Custom reports</li>
|
||||
</ul>
|
||||
</div>""",
|
||||
"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": """<div class="how-header">
|
||||
<h1>Getting Started is Easy</h1>
|
||||
<p>Launch your loyalty program in just a few steps</p>
|
||||
</div>
|
||||
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-number">1</div>
|
||||
<h3>Sign Up</h3>
|
||||
<p>Create your account and choose your plan. No credit card required for the free trial.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">2</div>
|
||||
<h3>Configure Your Program</h3>
|
||||
<p>Set up your point rules, rewards, and member tiers using our intuitive dashboard.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">3</div>
|
||||
<h3>Integrate</h3>
|
||||
<p>Connect Loyalty+ to your POS, e-commerce, or app using our APIs and plugins.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-number">4</div>
|
||||
<h3>Launch & Grow</h3>
|
||||
<p>Invite your customers and watch your loyalty program drive results.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cta-section">
|
||||
<h2>Ready to Build Customer Loyalty?</h2>
|
||||
<p>Start your free 14-day trial today.</p>
|
||||
<a href="/loyalty/signup" class="btn-primary">Get Started Free</a>
|
||||
</div>""",
|
||||
"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": """<div class="about-page">
|
||||
<h1>About Our Loyalty Program</h1>
|
||||
<p>Welcome to our customer loyalty program! We value your continued support and want to reward you for being part of our community.</p>
|
||||
|
||||
<h2>Why Join?</h2>
|
||||
<ul>
|
||||
<li><strong>Earn Points:</strong> Get points on every purchase</li>
|
||||
<li><strong>Exclusive Rewards:</strong> Redeem points for discounts and special offers</li>
|
||||
<li><strong>Member Benefits:</strong> Access exclusive deals and early sales</li>
|
||||
<li><strong>Birthday Surprises:</strong> Special rewards on your birthday</li>
|
||||
</ul>
|
||||
|
||||
<h2>How It Works</h2>
|
||||
<p>Simply sign up, start earning points with every purchase, and redeem them for rewards you'll love.</p>
|
||||
</div>""",
|
||||
"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": """<div class="rewards-page">
|
||||
<h1>Rewards Catalog</h1>
|
||||
<p>Browse our selection of rewards and redeem your hard-earned points!</p>
|
||||
|
||||
<div class="rewards-grid">
|
||||
<div class="reward-placeholder">
|
||||
<p>Your rewards catalog will appear here once configured.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>How to Redeem</h2>
|
||||
<ol>
|
||||
<li>Check your point balance in your account</li>
|
||||
<li>Browse available rewards</li>
|
||||
<li>Click "Redeem" on your chosen reward</li>
|
||||
<li>Use your reward code at checkout</li>
|
||||
</ol>
|
||||
</div>""",
|
||||
"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": """<div class="terms-page">
|
||||
<h1>Loyalty Program Terms & Conditions</h1>
|
||||
<p class="last-updated">Last updated: January 2026</p>
|
||||
|
||||
<h2>1. Program Membership</h2>
|
||||
<p>Membership in our loyalty program is free and open to all customers who meet the eligibility requirements.</p>
|
||||
|
||||
<h2>2. Earning Points</h2>
|
||||
<p>Points are earned on qualifying purchases. The earning rate and qualifying purchases are determined by the program operator and may change with notice.</p>
|
||||
|
||||
<h2>3. Redeeming Points</h2>
|
||||
<p>Points can be redeemed for rewards as shown in the rewards catalog. Minimum point thresholds may apply.</p>
|
||||
|
||||
<h2>4. Point Expiration</h2>
|
||||
<p>Points may expire after a period of account inactivity. Members will be notified before points expire.</p>
|
||||
|
||||
<h2>5. Program Changes</h2>
|
||||
<p>We reserve the right to modify, suspend, or terminate the program with reasonable notice to members.</p>
|
||||
|
||||
<h2>6. Privacy</h2>
|
||||
<p>Your personal information is handled in accordance with our Privacy Policy.</p>
|
||||
</div>""",
|
||||
"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": """<div class="privacy-page">
|
||||
<h1>Privacy Policy</h1>
|
||||
<p class="last-updated">Last updated: January 2026</p>
|
||||
|
||||
<h2>Information We Collect</h2>
|
||||
<p>We collect information you provide when joining our loyalty program, including:</p>
|
||||
<ul>
|
||||
<li>Name and contact information</li>
|
||||
<li>Purchase history and preferences</li>
|
||||
<li>Point balance and redemption history</li>
|
||||
</ul>
|
||||
|
||||
<h2>How We Use Your Information</h2>
|
||||
<p>Your information helps us:</p>
|
||||
<ul>
|
||||
<li>Manage your loyalty account</li>
|
||||
<li>Process point earnings and redemptions</li>
|
||||
<li>Send program updates and personalized offers</li>
|
||||
<li>Improve our services</li>
|
||||
</ul>
|
||||
|
||||
<h2>Data Protection</h2>
|
||||
<p>We implement appropriate security measures to protect your personal information in accordance with GDPR and Luxembourg data protection laws.</p>
|
||||
|
||||
<h2>Your Rights</h2>
|
||||
<p>You have the right to access, correct, or delete your personal data. Contact us to exercise these rights.</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>For privacy inquiries, please contact our data protection officer.</p>
|
||||
</div>""",
|
||||
"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'"))
|
||||
Reference in New Issue
Block a user