updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
160
Backend/alembic/versions/add_guest_profile_crm_tables.py
Normal file
160
Backend/alembic/versions/add_guest_profile_crm_tables.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""add guest profile crm tables
|
||||
|
||||
Revision ID: add_guest_profile_crm
|
||||
Revises: ff515d77abbe
|
||||
Create Date: 2024-01-01 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_guest_profile_crm'
|
||||
down_revision = '9bb08492a382'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add new fields to users table
|
||||
op.add_column('users', sa.Column('is_vip', sa.Boolean(), nullable=False, server_default='0'))
|
||||
op.add_column('users', sa.Column('lifetime_value', sa.Numeric(10, 2), nullable=True, server_default='0'))
|
||||
op.add_column('users', sa.Column('satisfaction_score', sa.Numeric(3, 2), nullable=True))
|
||||
op.add_column('users', sa.Column('last_visit_date', sa.DateTime(), nullable=True))
|
||||
op.add_column('users', sa.Column('total_visits', sa.Integer(), nullable=False, server_default='0'))
|
||||
|
||||
# Create guest_preferences table
|
||||
op.create_table(
|
||||
'guest_preferences',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('preferred_room_location', sa.String(length=100), nullable=True),
|
||||
sa.Column('preferred_floor', sa.Integer(), nullable=True),
|
||||
sa.Column('preferred_room_type_id', sa.Integer(), nullable=True),
|
||||
sa.Column('preferred_amenities', sa.JSON(), nullable=True),
|
||||
sa.Column('special_requests', sa.Text(), nullable=True),
|
||||
sa.Column('preferred_services', sa.JSON(), nullable=True),
|
||||
sa.Column('preferred_contact_method', sa.String(length=50), nullable=True),
|
||||
sa.Column('preferred_language', sa.String(length=10), nullable=True, server_default='en'),
|
||||
sa.Column('dietary_restrictions', sa.JSON(), nullable=True),
|
||||
sa.Column('additional_preferences', sa.JSON(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['preferred_room_type_id'], ['room_types.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_guest_preferences_id'), 'guest_preferences', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_preferences_user_id'), 'guest_preferences', ['user_id'], unique=False)
|
||||
|
||||
# Create guest_notes table
|
||||
op.create_table(
|
||||
'guest_notes',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||
sa.Column('note', sa.Text(), nullable=False),
|
||||
sa.Column('is_important', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('is_private', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_guest_notes_id'), 'guest_notes', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_notes_user_id'), 'guest_notes', ['user_id'], unique=False)
|
||||
|
||||
# Create guest_tags table
|
||||
op.create_table(
|
||||
'guest_tags',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=50), nullable=False),
|
||||
sa.Column('color', sa.String(length=7), nullable=True, server_default='#3B82F6'),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_guest_tags_id'), 'guest_tags', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_tags_name'), 'guest_tags', ['name'], unique=True)
|
||||
|
||||
# Create guest_tag_associations table
|
||||
op.create_table(
|
||||
'guest_tag_associations',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('tag_id', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['tag_id'], ['guest_tags.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id', 'tag_id')
|
||||
)
|
||||
|
||||
# Create guest_communications table
|
||||
op.create_table(
|
||||
'guest_communications',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('staff_id', sa.Integer(), nullable=True),
|
||||
sa.Column('communication_type', sa.Enum('email', 'phone', 'sms', 'chat', 'in_person', 'other', name='communicationtype'), nullable=False),
|
||||
sa.Column('direction', sa.Enum('inbound', 'outbound', name='communicationdirection'), nullable=False),
|
||||
sa.Column('subject', sa.String(length=255), nullable=True),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('booking_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_automated', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('communication_metadata', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
|
||||
sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_guest_communications_id'), 'guest_communications', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_communications_user_id'), 'guest_communications', ['user_id'], unique=False)
|
||||
|
||||
# Create guest_segments table
|
||||
op.create_table(
|
||||
'guest_segments',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('criteria', sa.JSON(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('name')
|
||||
)
|
||||
op.create_index(op.f('ix_guest_segments_id'), 'guest_segments', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_guest_segments_name'), 'guest_segments', ['name'], unique=True)
|
||||
|
||||
# Create guest_segment_associations table
|
||||
op.create_table(
|
||||
'guest_segment_associations',
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('segment_id', sa.Integer(), nullable=False),
|
||||
sa.Column('assigned_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['segment_id'], ['guest_segments.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('user_id', 'segment_id')
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop tables in reverse order
|
||||
op.drop_table('guest_segment_associations')
|
||||
op.drop_table('guest_segments')
|
||||
op.drop_table('guest_communications')
|
||||
op.drop_table('guest_tag_associations')
|
||||
op.drop_table('guest_tags')
|
||||
op.drop_table('guest_notes')
|
||||
op.drop_table('guest_preferences')
|
||||
|
||||
# Remove columns from users table
|
||||
op.drop_column('users', 'total_visits')
|
||||
op.drop_column('users', 'last_visit_date')
|
||||
op.drop_column('users', 'satisfaction_score')
|
||||
op.drop_column('users', 'lifetime_value')
|
||||
op.drop_column('users', 'is_vip')
|
||||
|
||||
215
Backend/alembic/versions/add_loyalty_system_tables.py
Normal file
215
Backend/alembic/versions/add_loyalty_system_tables.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""add loyalty system tables
|
||||
|
||||
Revision ID: add_loyalty_tables_001
|
||||
Revises: ff515d77abbe
|
||||
Create Date: 2024-01-01 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_loyalty_tables_001'
|
||||
down_revision = 'ff515d77abbe'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create loyalty_tiers table
|
||||
op.create_table(
|
||||
'loyalty_tiers',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('level', sa.Enum('bronze', 'silver', 'gold', 'platinum', name='tierlevel'), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('min_points', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('points_earn_rate', sa.Numeric(precision=5, scale=2), nullable=False, server_default='1.0'),
|
||||
sa.Column('discount_percentage', sa.Numeric(precision=5, scale=2), nullable=True),
|
||||
sa.Column('benefits', sa.Text(), nullable=True),
|
||||
sa.Column('icon', sa.String(length=255), nullable=True),
|
||||
sa.Column('color', sa.String(length=50), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('level')
|
||||
)
|
||||
op.create_index(op.f('ix_loyalty_tiers_id'), 'loyalty_tiers', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_loyalty_tiers_level'), 'loyalty_tiers', ['level'], unique=True)
|
||||
|
||||
# Create user_loyalty table
|
||||
op.create_table(
|
||||
'user_loyalty',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('tier_id', sa.Integer(), nullable=False),
|
||||
sa.Column('total_points', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('lifetime_points', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('available_points', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('expired_points', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('referral_code', sa.String(length=50), nullable=True),
|
||||
sa.Column('referral_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('birthday', sa.Date(), nullable=True),
|
||||
sa.Column('anniversary_date', sa.Date(), nullable=True),
|
||||
sa.Column('last_points_earned_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('tier_started_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('next_tier_points_needed', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['tier_id'], ['loyalty_tiers.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('user_id')
|
||||
)
|
||||
op.create_index(op.f('ix_user_loyalty_id'), 'user_loyalty', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_user_loyalty_user_id'), 'user_loyalty', ['user_id'], unique=True)
|
||||
op.create_index(op.f('ix_user_loyalty_tier_id'), 'user_loyalty', ['tier_id'], unique=False)
|
||||
op.create_index(op.f('ix_user_loyalty_referral_code'), 'user_loyalty', ['referral_code'], unique=True)
|
||||
|
||||
# Create loyalty_point_transactions table
|
||||
op.create_table(
|
||||
'loyalty_point_transactions',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_loyalty_id', sa.Integer(), nullable=False),
|
||||
sa.Column('booking_id', sa.Integer(), nullable=True),
|
||||
sa.Column('transaction_type', sa.Enum('earned', 'redeemed', 'expired', 'bonus', 'adjustment', name='transactiontype'), nullable=False),
|
||||
sa.Column('source', sa.Enum('booking', 'referral', 'birthday', 'anniversary', 'redemption', 'promotion', 'manual', name='transactionsource'), nullable=False),
|
||||
sa.Column('points', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('reference_number', sa.String(length=100), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_loyalty_id'], ['user_loyalty.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_loyalty_point_transactions_id'), 'loyalty_point_transactions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_loyalty_point_transactions_user_loyalty_id'), 'loyalty_point_transactions', ['user_loyalty_id'], unique=False)
|
||||
op.create_index(op.f('ix_loyalty_point_transactions_booking_id'), 'loyalty_point_transactions', ['booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_loyalty_point_transactions_transaction_type'), 'loyalty_point_transactions', ['transaction_type'], unique=False)
|
||||
op.create_index(op.f('ix_loyalty_point_transactions_created_at'), 'loyalty_point_transactions', ['created_at'], unique=False)
|
||||
|
||||
# Create loyalty_rewards table
|
||||
op.create_table(
|
||||
'loyalty_rewards',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('reward_type', sa.Enum('discount', 'room_upgrade', 'amenity', 'cashback', 'voucher', name='rewardtype'), nullable=False),
|
||||
sa.Column('points_cost', sa.Integer(), nullable=False),
|
||||
sa.Column('discount_percentage', sa.Numeric(precision=5, scale=2), nullable=True),
|
||||
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('max_discount_amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('applicable_tier_id', sa.Integer(), nullable=True),
|
||||
sa.Column('min_booking_amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('icon', sa.String(length=255), nullable=True),
|
||||
sa.Column('image', sa.String(length=255), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('stock_quantity', sa.Integer(), nullable=True),
|
||||
sa.Column('redeemed_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('valid_from', sa.DateTime(), nullable=True),
|
||||
sa.Column('valid_until', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['applicable_tier_id'], ['loyalty_tiers.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_loyalty_rewards_id'), 'loyalty_rewards', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_loyalty_rewards_reward_type'), 'loyalty_rewards', ['reward_type'], unique=False)
|
||||
|
||||
# Create reward_redemptions table
|
||||
op.create_table(
|
||||
'reward_redemptions',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('user_loyalty_id', sa.Integer(), nullable=False),
|
||||
sa.Column('reward_id', sa.Integer(), nullable=False),
|
||||
sa.Column('booking_id', sa.Integer(), nullable=True),
|
||||
sa.Column('points_used', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.Enum('pending', 'active', 'used', 'expired', 'cancelled', name='redemptionstatus'), nullable=False, server_default='pending'),
|
||||
sa.Column('code', sa.String(length=50), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('used_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_loyalty_id'], ['user_loyalty.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['reward_id'], ['loyalty_rewards.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('code')
|
||||
)
|
||||
op.create_index(op.f('ix_reward_redemptions_id'), 'reward_redemptions', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_reward_redemptions_user_loyalty_id'), 'reward_redemptions', ['user_loyalty_id'], unique=False)
|
||||
op.create_index(op.f('ix_reward_redemptions_reward_id'), 'reward_redemptions', ['reward_id'], unique=False)
|
||||
op.create_index(op.f('ix_reward_redemptions_booking_id'), 'reward_redemptions', ['booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_reward_redemptions_status'), 'reward_redemptions', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_reward_redemptions_code'), 'reward_redemptions', ['code'], unique=True)
|
||||
|
||||
# Create referrals table
|
||||
op.create_table(
|
||||
'referrals',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('referrer_id', sa.Integer(), nullable=False),
|
||||
sa.Column('referred_user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('referral_code', sa.String(length=50), nullable=False),
|
||||
sa.Column('booking_id', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.Enum('pending', 'completed', 'rewarded', name='referralstatus'), nullable=False, server_default='pending'),
|
||||
sa.Column('referrer_points_earned', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('referred_points_earned', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('rewarded_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['referrer_id'], ['user_loyalty.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['referred_user_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_referrals_id'), 'referrals', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_referrals_referrer_id'), 'referrals', ['referrer_id'], unique=False)
|
||||
op.create_index(op.f('ix_referrals_referred_user_id'), 'referrals', ['referred_user_id'], unique=False)
|
||||
op.create_index(op.f('ix_referrals_referral_code'), 'referrals', ['referral_code'], unique=False)
|
||||
op.create_index(op.f('ix_referrals_booking_id'), 'referrals', ['booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_referrals_status'), 'referrals', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_referrals_created_at'), 'referrals', ['created_at'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_referrals_created_at'), table_name='referrals')
|
||||
op.drop_index(op.f('ix_referrals_status'), table_name='referrals')
|
||||
op.drop_index(op.f('ix_referrals_booking_id'), table_name='referrals')
|
||||
op.drop_index(op.f('ix_referrals_referral_code'), table_name='referrals')
|
||||
op.drop_index(op.f('ix_referrals_referred_user_id'), table_name='referrals')
|
||||
op.drop_index(op.f('ix_referrals_referrer_id'), table_name='referrals')
|
||||
op.drop_index(op.f('ix_referrals_id'), table_name='referrals')
|
||||
op.drop_table('referrals')
|
||||
|
||||
op.drop_index(op.f('ix_reward_redemptions_code'), table_name='reward_redemptions')
|
||||
op.drop_index(op.f('ix_reward_redemptions_status'), table_name='reward_redemptions')
|
||||
op.drop_index(op.f('ix_reward_redemptions_booking_id'), table_name='reward_redemptions')
|
||||
op.drop_index(op.f('ix_reward_redemptions_reward_id'), table_name='reward_redemptions')
|
||||
op.drop_index(op.f('ix_reward_redemptions_user_loyalty_id'), table_name='reward_redemptions')
|
||||
op.drop_index(op.f('ix_reward_redemptions_id'), table_name='reward_redemptions')
|
||||
op.drop_table('reward_redemptions')
|
||||
|
||||
op.drop_index(op.f('ix_loyalty_rewards_reward_type'), table_name='loyalty_rewards')
|
||||
op.drop_index(op.f('ix_loyalty_rewards_id'), table_name='loyalty_rewards')
|
||||
op.drop_table('loyalty_rewards')
|
||||
|
||||
op.drop_index(op.f('ix_loyalty_point_transactions_created_at'), table_name='loyalty_point_transactions')
|
||||
op.drop_index(op.f('ix_loyalty_point_transactions_transaction_type'), table_name='loyalty_point_transactions')
|
||||
op.drop_index(op.f('ix_loyalty_point_transactions_booking_id'), table_name='loyalty_point_transactions')
|
||||
op.drop_index(op.f('ix_loyalty_point_transactions_user_loyalty_id'), table_name='loyalty_point_transactions')
|
||||
op.drop_index(op.f('ix_loyalty_point_transactions_id'), table_name='loyalty_point_transactions')
|
||||
op.drop_table('loyalty_point_transactions')
|
||||
|
||||
op.drop_index(op.f('ix_user_loyalty_referral_code'), table_name='user_loyalty')
|
||||
op.drop_index(op.f('ix_user_loyalty_tier_id'), table_name='user_loyalty')
|
||||
op.drop_index(op.f('ix_user_loyalty_user_id'), table_name='user_loyalty')
|
||||
op.drop_index(op.f('ix_user_loyalty_id'), table_name='user_loyalty')
|
||||
op.drop_table('user_loyalty')
|
||||
|
||||
op.drop_index(op.f('ix_loyalty_tiers_level'), table_name='loyalty_tiers')
|
||||
op.drop_index(op.f('ix_loyalty_tiers_id'), table_name='loyalty_tiers')
|
||||
op.drop_table('loyalty_tiers')
|
||||
|
||||
303
Backend/seeds_data/seed_loyalty_rewards.py
Normal file
303
Backend/seeds_data/seed_loyalty_rewards.py
Normal file
@@ -0,0 +1,303 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from sqlalchemy.orm import Session
|
||||
from src.config.database import SessionLocal
|
||||
from src.models.loyalty_reward import LoyaltyReward, RewardType
|
||||
from src.models.loyalty_tier import LoyaltyTier, TierLevel
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
return db
|
||||
finally:
|
||||
pass
|
||||
|
||||
def seed_loyalty_rewards(db: Session):
|
||||
print('=' * 80)
|
||||
print('SEEDING LOYALTY REWARDS')
|
||||
print('=' * 80)
|
||||
|
||||
# Get tier IDs for tier-specific rewards
|
||||
bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first()
|
||||
silver_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.silver).first()
|
||||
gold_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.gold).first()
|
||||
platinum_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.platinum).first()
|
||||
|
||||
# Create default tiers if they don't exist
|
||||
if not bronze_tier:
|
||||
from src.services.loyalty_service import LoyaltyService
|
||||
LoyaltyService.create_default_tiers(db)
|
||||
db.refresh()
|
||||
bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first()
|
||||
silver_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.silver).first()
|
||||
gold_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.gold).first()
|
||||
platinum_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.platinum).first()
|
||||
|
||||
# Sample rewards data
|
||||
rewards_data = [
|
||||
# Discount Rewards (Available to all tiers)
|
||||
{
|
||||
'name': '5% Booking Discount',
|
||||
'description': 'Get 5% off your next booking. Valid for bookings over $100.',
|
||||
'reward_type': RewardType.discount,
|
||||
'points_cost': 5000,
|
||||
'discount_percentage': 5.0,
|
||||
'max_discount_amount': 50.0,
|
||||
'min_booking_amount': 100.0,
|
||||
'applicable_tier_id': None, # Available to all tiers
|
||||
'stock_quantity': None, # Unlimited
|
||||
'icon': '🎫',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': '10% Booking Discount',
|
||||
'description': 'Get 10% off your next booking. Valid for bookings over $200.',
|
||||
'reward_type': RewardType.discount,
|
||||
'points_cost': 10000,
|
||||
'discount_percentage': 10.0,
|
||||
'max_discount_amount': 100.0,
|
||||
'min_booking_amount': 200.0,
|
||||
'applicable_tier_id': silver_tier.id if silver_tier else None,
|
||||
'stock_quantity': None,
|
||||
'icon': '🎫',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': '15% Premium Discount',
|
||||
'description': 'Get 15% off your next booking. Maximum discount $150. Valid for bookings over $300.',
|
||||
'reward_type': RewardType.discount,
|
||||
'points_cost': 15000,
|
||||
'discount_percentage': 15.0,
|
||||
'max_discount_amount': 150.0,
|
||||
'min_booking_amount': 300.0,
|
||||
'applicable_tier_id': gold_tier.id if gold_tier else None,
|
||||
'stock_quantity': None,
|
||||
'icon': '💎',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': '20% VIP Discount',
|
||||
'description': 'Exclusive 20% discount for Platinum members. Maximum discount $200. Valid for bookings over $500.',
|
||||
'reward_type': RewardType.discount,
|
||||
'points_cost': 25000,
|
||||
'discount_percentage': 20.0,
|
||||
'max_discount_amount': 200.0,
|
||||
'min_booking_amount': 500.0,
|
||||
'applicable_tier_id': platinum_tier.id if platinum_tier else None,
|
||||
'stock_quantity': None,
|
||||
'icon': '👑',
|
||||
'is_active': True
|
||||
},
|
||||
|
||||
# Room Upgrade Rewards
|
||||
{
|
||||
'name': 'Complimentary Room Upgrade',
|
||||
'description': 'Upgrade to the next room category at no extra cost. Subject to availability at check-in.',
|
||||
'reward_type': RewardType.room_upgrade,
|
||||
'points_cost': 20000,
|
||||
'applicable_tier_id': silver_tier.id if silver_tier else None,
|
||||
'stock_quantity': 50, # Limited stock
|
||||
'icon': '🛏️',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Premium Suite Upgrade',
|
||||
'description': 'Upgrade to a premium suite or executive room. Subject to availability.',
|
||||
'reward_type': RewardType.room_upgrade,
|
||||
'points_cost': 35000,
|
||||
'applicable_tier_id': gold_tier.id if gold_tier else None,
|
||||
'stock_quantity': 30,
|
||||
'icon': '🏨',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Luxury Suite Upgrade',
|
||||
'description': 'Upgrade to our most luxurious suite category. Ultimate comfort guaranteed. Subject to availability.',
|
||||
'reward_type': RewardType.room_upgrade,
|
||||
'points_cost': 50000,
|
||||
'applicable_tier_id': platinum_tier.id if platinum_tier else None,
|
||||
'stock_quantity': 20,
|
||||
'icon': '✨',
|
||||
'is_active': True
|
||||
},
|
||||
|
||||
# Amenity Rewards
|
||||
{
|
||||
'name': 'Welcome Amenity Package',
|
||||
'description': 'Complimentary welcome basket with fruits, chocolates, and wine upon arrival.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 3000,
|
||||
'applicable_tier_id': None,
|
||||
'stock_quantity': None,
|
||||
'icon': '🍾',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Breakfast for Two',
|
||||
'description': 'Complimentary breakfast buffet for two guests during your stay.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 8000,
|
||||
'applicable_tier_id': None,
|
||||
'stock_quantity': None,
|
||||
'icon': '🍳',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Spa Treatment Voucher',
|
||||
'description': 'One complimentary spa treatment of your choice (60 minutes). Valid for 90 days.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 12000,
|
||||
'applicable_tier_id': silver_tier.id if silver_tier else None,
|
||||
'stock_quantity': 40,
|
||||
'icon': '💆',
|
||||
'is_active': True,
|
||||
'valid_until': datetime.utcnow() + timedelta(days=90)
|
||||
},
|
||||
{
|
||||
'name': 'Romantic Dinner Package',
|
||||
'description': 'Private romantic dinner for two with wine pairing at our fine dining restaurant.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 18000,
|
||||
'applicable_tier_id': gold_tier.id if gold_tier else None,
|
||||
'stock_quantity': 25,
|
||||
'icon': '🍽️',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'VIP Airport Transfer',
|
||||
'description': 'Complimentary luxury airport transfer (one-way) in premium vehicle.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 15000,
|
||||
'applicable_tier_id': platinum_tier.id if platinum_tier else None,
|
||||
'stock_quantity': 20,
|
||||
'icon': '🚗',
|
||||
'is_active': True
|
||||
},
|
||||
|
||||
# Voucher Rewards
|
||||
{
|
||||
'name': '$50 Hotel Credit',
|
||||
'description': 'Redeem for $50 credit towards room service, spa, or dining at the hotel. Valid for 6 months.',
|
||||
'reward_type': RewardType.voucher,
|
||||
'points_cost': 10000,
|
||||
'discount_amount': 50.0,
|
||||
'applicable_tier_id': None,
|
||||
'stock_quantity': None,
|
||||
'icon': '💳',
|
||||
'is_active': True,
|
||||
'valid_until': datetime.utcnow() + timedelta(days=180)
|
||||
},
|
||||
{
|
||||
'name': '$100 Hotel Credit',
|
||||
'description': 'Redeem for $100 credit towards any hotel service. Valid for 6 months.',
|
||||
'reward_type': RewardType.voucher,
|
||||
'points_cost': 20000,
|
||||
'discount_amount': 100.0,
|
||||
'applicable_tier_id': silver_tier.id if silver_tier else None,
|
||||
'stock_quantity': None,
|
||||
'icon': '💵',
|
||||
'is_active': True,
|
||||
'valid_until': datetime.utcnow() + timedelta(days=180)
|
||||
},
|
||||
{
|
||||
'name': '$200 Premium Credit',
|
||||
'description': 'Redeem for $200 credit towards premium services. Perfect for special occasions. Valid for 1 year.',
|
||||
'reward_type': RewardType.voucher,
|
||||
'points_cost': 40000,
|
||||
'discount_amount': 200.0,
|
||||
'applicable_tier_id': gold_tier.id if gold_tier else None,
|
||||
'stock_quantity': None,
|
||||
'icon': '💰',
|
||||
'is_active': True,
|
||||
'valid_until': datetime.utcnow() + timedelta(days=365)
|
||||
},
|
||||
|
||||
# Cashback Rewards
|
||||
{
|
||||
'name': 'Early Check-in Benefit',
|
||||
'description': 'Guaranteed early check-in (12:00 PM) at no extra charge. Subject to availability.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 2000,
|
||||
'applicable_tier_id': None,
|
||||
'stock_quantity': None,
|
||||
'icon': '⏰',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Late Check-out Benefit',
|
||||
'description': 'Extended check-out until 2:00 PM at no extra charge. Subject to availability.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 2000,
|
||||
'applicable_tier_id': None,
|
||||
'stock_quantity': None,
|
||||
'icon': '🕐',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'name': 'Free Night Stay',
|
||||
'description': 'One complimentary night stay in a standard room. Valid for bookings of 2+ nights.',
|
||||
'reward_type': RewardType.voucher,
|
||||
'points_cost': 30000,
|
||||
'applicable_tier_id': gold_tier.id if gold_tier else None,
|
||||
'stock_quantity': 15,
|
||||
'icon': '🌙',
|
||||
'is_active': True,
|
||||
'valid_until': datetime.utcnow() + timedelta(days=180)
|
||||
},
|
||||
{
|
||||
'name': 'Complimentary Room Service',
|
||||
'description': '$75 credit for room service orders. Valid for one stay.',
|
||||
'reward_type': RewardType.amenity,
|
||||
'points_cost': 6000,
|
||||
'discount_amount': 75.0,
|
||||
'applicable_tier_id': None,
|
||||
'stock_quantity': None,
|
||||
'icon': '🍽️',
|
||||
'is_active': True
|
||||
},
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for reward_data in rewards_data:
|
||||
# Check if reward already exists by name
|
||||
existing = db.query(LoyaltyReward).filter(LoyaltyReward.name == reward_data['name']).first()
|
||||
|
||||
if existing:
|
||||
print(f' ⚠️ Reward "{reward_data["name"]}" already exists, skipping...')
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create reward object
|
||||
reward = LoyaltyReward(**reward_data)
|
||||
db.add(reward)
|
||||
print(f' ✓ Created reward: {reward_data["name"]} ({reward_data["points_cost"]:,} points)')
|
||||
created_count += 1
|
||||
|
||||
db.commit()
|
||||
print('\n✓ Loyalty rewards seeded successfully!')
|
||||
print(f' - Created: {created_count} reward(s)')
|
||||
print(f' - Skipped: {skipped_count} reward(s) (already exist)')
|
||||
print('=' * 80)
|
||||
|
||||
def main():
|
||||
db = get_db()
|
||||
try:
|
||||
seed_loyalty_rewards(db)
|
||||
print('\n✅ Loyalty rewards seeding completed successfully!')
|
||||
print('=' * 80)
|
||||
except Exception as e:
|
||||
print(f'\n❌ Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Binary file not shown.
@@ -95,7 +95,7 @@ async def metrics():
|
||||
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
|
||||
app.include_router(auth_routes.router, prefix='/api')
|
||||
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes
|
||||
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes
|
||||
app.include_router(room_routes.router, prefix='/api')
|
||||
app.include_router(booking_routes.router, prefix='/api')
|
||||
app.include_router(payment_routes.router, prefix='/api')
|
||||
@@ -123,6 +123,8 @@ app.include_router(cancellation_routes.router, prefix='/api')
|
||||
app.include_router(accessibility_routes.router, prefix='/api')
|
||||
app.include_router(faq_routes.router, prefix='/api')
|
||||
app.include_router(chat_routes.router, prefix='/api')
|
||||
app.include_router(loyalty_routes.router, prefix='/api')
|
||||
app.include_router(guest_profile_routes.router, prefix='/api')
|
||||
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
@@ -150,6 +152,8 @@ app.include_router(cancellation_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(accessibility_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(page_content_routes.router, prefix='/api')
|
||||
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
logger.info('All routes registered successfully')
|
||||
|
||||
@@ -21,4 +21,15 @@ from .system_settings import SystemSettings
|
||||
from .invoice import Invoice, InvoiceItem
|
||||
from .page_content import PageContent, PageType
|
||||
from .chat import Chat, ChatMessage, ChatStatus
|
||||
__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus']
|
||||
from .loyalty_tier import LoyaltyTier, TierLevel
|
||||
from .user_loyalty import UserLoyalty
|
||||
from .loyalty_point_transaction import LoyaltyPointTransaction, TransactionType, TransactionSource
|
||||
from .loyalty_reward import LoyaltyReward, RewardType, RewardStatus
|
||||
from .reward_redemption import RewardRedemption, RedemptionStatus
|
||||
from .referral import Referral, ReferralStatus
|
||||
from .guest_preference import GuestPreference
|
||||
from .guest_note import GuestNote
|
||||
from .guest_tag import GuestTag, guest_tag_association
|
||||
from .guest_communication import GuestCommunication, CommunicationType, CommunicationDirection
|
||||
from .guest_segment import GuestSegment, guest_segment_association
|
||||
__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus', 'LoyaltyTier', 'TierLevel', 'UserLoyalty', 'LoyaltyPointTransaction', 'TransactionType', 'TransactionSource', 'LoyaltyReward', 'RewardType', 'RewardStatus', 'RewardRedemption', 'RedemptionStatus', 'Referral', 'ReferralStatus', 'GuestPreference', 'GuestNote', 'GuestTag', 'guest_tag_association', 'GuestCommunication', 'CommunicationType', 'CommunicationDirection', 'GuestSegment', 'guest_segment_association']
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_note.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_note.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_preference.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_preference.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_segment.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_segment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/guest_tag.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/guest_tag.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/loyalty_reward.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/loyalty_tier.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/referral.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/referral.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/reward_redemption.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/user_loyalty.cpython-312.pyc
Normal file
Binary file not shown.
36
Backend/src/models/guest_communication.py
Normal file
36
Backend/src/models/guest_communication.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class CommunicationType(str, enum.Enum):
|
||||
email = 'email'
|
||||
phone = 'phone'
|
||||
sms = 'sms'
|
||||
chat = 'chat'
|
||||
in_person = 'in_person'
|
||||
other = 'other'
|
||||
|
||||
class CommunicationDirection(str, enum.Enum):
|
||||
inbound = 'inbound' # Guest to hotel
|
||||
outbound = 'outbound' # Hotel to guest
|
||||
|
||||
class GuestCommunication(Base):
|
||||
__tablename__ = 'guest_communications'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
staff_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Staff member who handled communication
|
||||
communication_type = Column(Enum(CommunicationType), nullable=False)
|
||||
direction = Column(Enum(CommunicationDirection), nullable=False)
|
||||
subject = Column(String(255), nullable=True)
|
||||
content = Column(Text, nullable=False)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True) # Related booking if applicable
|
||||
is_automated = Column(Boolean, nullable=False, default=False) # If this was an automated communication
|
||||
communication_metadata = Column(Text, nullable=True) # JSON string for additional data
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship('User', foreign_keys=[user_id], back_populates='guest_communications')
|
||||
staff = relationship('User', foreign_keys=[staff_id])
|
||||
booking = relationship('Booking')
|
||||
|
||||
19
Backend/src/models/guest_note.py
Normal file
19
Backend/src/models/guest_note.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
class GuestNote(Base):
|
||||
__tablename__ = 'guest_notes'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False) # Staff/admin who created the note
|
||||
note = Column(Text, nullable=False)
|
||||
is_important = Column(Boolean, nullable=False, default=False)
|
||||
is_private = Column(Boolean, nullable=False, default=False) # Private notes only visible to admins
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship('User', foreign_keys=[user_id], back_populates='guest_notes')
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
40
Backend/src/models/guest_preference.py
Normal file
40
Backend/src/models/guest_preference.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
class GuestPreference(Base):
|
||||
__tablename__ = 'guest_preferences'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Room preferences
|
||||
preferred_room_location = Column(String(100), nullable=True) # e.g., "high floor", "near elevator", "ocean view"
|
||||
preferred_floor = Column(Integer, nullable=True)
|
||||
preferred_room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=True)
|
||||
|
||||
# Amenity preferences
|
||||
preferred_amenities = Column(JSON, nullable=True) # Array of amenity names
|
||||
|
||||
# Special requests
|
||||
special_requests = Column(Text, nullable=True) # General special requests
|
||||
|
||||
# Service preferences
|
||||
preferred_services = Column(JSON, nullable=True) # Array of service preferences
|
||||
|
||||
# Communication preferences
|
||||
preferred_contact_method = Column(String(50), nullable=True) # email, phone, sms
|
||||
preferred_language = Column(String(10), nullable=True, default='en')
|
||||
|
||||
# Dietary preferences
|
||||
dietary_restrictions = Column(JSON, nullable=True) # Array of dietary restrictions
|
||||
|
||||
# Other preferences
|
||||
additional_preferences = Column(JSON, nullable=True) # Flexible JSON for other preferences
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship('User', back_populates='guest_preferences')
|
||||
preferred_room_type = relationship('RoomType')
|
||||
|
||||
26
Backend/src/models/guest_segment.py
Normal file
26
Backend/src/models/guest_segment.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean, JSON, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
# Association table for many-to-many relationship between users and segments
|
||||
guest_segment_association = Table(
|
||||
'guest_segment_associations',
|
||||
Base.metadata,
|
||||
Column('user_id', Integer, ForeignKey('users.id'), primary_key=True),
|
||||
Column('segment_id', Integer, ForeignKey('guest_segments.id'), primary_key=True),
|
||||
Column('assigned_at', DateTime, default=datetime.utcnow, nullable=False)
|
||||
)
|
||||
|
||||
class GuestSegment(Base):
|
||||
__tablename__ = 'guest_segments'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
criteria = Column(JSON, nullable=True) # JSON object defining segment criteria
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
users = relationship('User', secondary=guest_segment_association, back_populates='guest_segments')
|
||||
|
||||
24
Backend/src/models/guest_tag.py
Normal file
24
Backend/src/models/guest_tag.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
# Association table for many-to-many relationship between users and tags
|
||||
guest_tag_association = Table(
|
||||
'guest_tag_associations',
|
||||
Base.metadata,
|
||||
Column('user_id', Integer, ForeignKey('users.id'), primary_key=True),
|
||||
Column('tag_id', Integer, ForeignKey('guest_tags.id'), primary_key=True),
|
||||
Column('created_at', DateTime, default=datetime.utcnow, nullable=False)
|
||||
)
|
||||
|
||||
class GuestTag(Base):
|
||||
__tablename__ = 'guest_tags'
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(50), unique=True, nullable=False, index=True)
|
||||
color = Column(String(7), nullable=True, default='#3B82F6') # Hex color code
|
||||
description = Column(String(255), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
users = relationship('User', secondary=guest_tag_association, back_populates='guest_tags')
|
||||
|
||||
40
Backend/src/models/loyalty_point_transaction.py
Normal file
40
Backend/src/models/loyalty_point_transaction.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class TransactionType(str, enum.Enum):
|
||||
earned = 'earned'
|
||||
redeemed = 'redeemed'
|
||||
expired = 'expired'
|
||||
bonus = 'bonus'
|
||||
adjustment = 'adjustment'
|
||||
|
||||
class TransactionSource(str, enum.Enum):
|
||||
booking = 'booking'
|
||||
referral = 'referral'
|
||||
birthday = 'birthday'
|
||||
anniversary = 'anniversary'
|
||||
redemption = 'redemption'
|
||||
promotion = 'promotion'
|
||||
manual = 'manual'
|
||||
|
||||
class LoyaltyPointTransaction(Base):
|
||||
__tablename__ = 'loyalty_point_transactions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_loyalty_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||
transaction_type = Column(Enum(TransactionType), nullable=False, index=True)
|
||||
source = Column(Enum(TransactionSource), nullable=False)
|
||||
points = Column(Integer, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
reference_number = Column(String(100), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
user_loyalty = relationship('UserLoyalty', back_populates='point_transactions')
|
||||
booking = relationship('Booking', foreign_keys=[booking_id])
|
||||
|
||||
89
Backend/src/models/loyalty_reward.py
Normal file
89
Backend/src/models/loyalty_reward.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, Enum, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class RewardType(str, enum.Enum):
|
||||
discount = 'discount'
|
||||
room_upgrade = 'room_upgrade'
|
||||
amenity = 'amenity'
|
||||
cashback = 'cashback'
|
||||
voucher = 'voucher'
|
||||
|
||||
class RewardStatus(str, enum.Enum):
|
||||
available = 'available'
|
||||
redeemed = 'redeemed'
|
||||
expired = 'expired'
|
||||
|
||||
class LoyaltyReward(Base):
|
||||
__tablename__ = 'loyalty_rewards'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
reward_type = Column(Enum(RewardType), nullable=False, index=True)
|
||||
points_cost = Column(Integer, nullable=False)
|
||||
discount_percentage = Column(Numeric(5, 2), nullable=True)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=True)
|
||||
max_discount_amount = Column(Numeric(10, 2), nullable=True)
|
||||
applicable_tier_id = Column(Integer, ForeignKey('loyalty_tiers.id'), nullable=True)
|
||||
min_booking_amount = Column(Numeric(10, 2), nullable=True)
|
||||
icon = Column(String(255), nullable=True)
|
||||
image = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
stock_quantity = Column(Integer, nullable=True)
|
||||
redeemed_count = Column(Integer, nullable=False, default=0)
|
||||
valid_from = Column(DateTime, nullable=True)
|
||||
valid_until = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
applicable_tier = relationship('LoyaltyTier', foreign_keys=[applicable_tier_id])
|
||||
redemptions = relationship('RewardRedemption', back_populates='reward')
|
||||
|
||||
def is_available(self, user_tier_id=None, user_tier_min_points=None):
|
||||
"""
|
||||
Check if reward is available for user
|
||||
|
||||
Args:
|
||||
user_tier_id: User's current tier ID
|
||||
user_tier_min_points: User's tier minimum points (for tier level comparison)
|
||||
"""
|
||||
from datetime import datetime
|
||||
now = datetime.utcnow()
|
||||
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
# If reward has a tier requirement, check if user's tier qualifies
|
||||
if self.applicable_tier_id:
|
||||
if user_tier_id is None:
|
||||
return False
|
||||
|
||||
# Exact tier match - always allowed
|
||||
if user_tier_id == self.applicable_tier_id:
|
||||
pass # Continue to other checks
|
||||
# If we have tier point info, allow users with higher tier level (more points)
|
||||
elif user_tier_min_points is not None and hasattr(self, '_required_tier_min_points'):
|
||||
# User tier must have equal or more minimum points (higher tier)
|
||||
if user_tier_min_points < self._required_tier_min_points:
|
||||
return False
|
||||
else:
|
||||
# Without tier point info, use exact match only
|
||||
# Convert to int for comparison to avoid type mismatch issues
|
||||
try:
|
||||
if int(user_tier_id) != int(self.applicable_tier_id):
|
||||
return False
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
if self.valid_from and now < self.valid_from:
|
||||
return False
|
||||
if self.valid_until and now > self.valid_until:
|
||||
return False
|
||||
if self.stock_quantity is not None and self.redeemed_count >= self.stock_quantity:
|
||||
return False
|
||||
return True
|
||||
|
||||
32
Backend/src/models/loyalty_tier.py
Normal file
32
Backend/src/models/loyalty_tier.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class TierLevel(str, enum.Enum):
|
||||
bronze = 'bronze'
|
||||
silver = 'silver'
|
||||
gold = 'gold'
|
||||
platinum = 'platinum'
|
||||
|
||||
class LoyaltyTier(Base):
|
||||
__tablename__ = 'loyalty_tiers'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
level = Column(Enum(TierLevel), unique=True, nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
min_points = Column(Integer, nullable=False, default=0)
|
||||
points_earn_rate = Column(Numeric(5, 2), nullable=False, default=1.0)
|
||||
discount_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
benefits = Column(Text, nullable=True)
|
||||
icon = Column(String(255), nullable=True)
|
||||
color = Column(String(50), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user_tiers = relationship('UserLoyalty', back_populates='tier')
|
||||
|
||||
30
Backend/src/models/referral.py
Normal file
30
Backend/src/models/referral.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class ReferralStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
completed = 'completed'
|
||||
rewarded = 'rewarded'
|
||||
|
||||
class Referral(Base):
|
||||
__tablename__ = 'referrals'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
referrer_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True)
|
||||
referred_user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
referral_code = Column(String(50), nullable=False, index=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||
status = Column(Enum(ReferralStatus), nullable=False, default=ReferralStatus.pending, index=True)
|
||||
referrer_points_earned = Column(Integer, nullable=False, default=0)
|
||||
referred_points_earned = Column(Integer, nullable=False, default=0)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
rewarded_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
referrer = relationship('UserLoyalty', foreign_keys=[referrer_id], back_populates='referrals')
|
||||
referred_user = relationship('User', foreign_keys=[referred_user_id])
|
||||
booking = relationship('Booking', foreign_keys=[booking_id])
|
||||
|
||||
33
Backend/src/models/reward_redemption.py
Normal file
33
Backend/src/models/reward_redemption.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class RedemptionStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
active = 'active'
|
||||
used = 'used'
|
||||
expired = 'expired'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class RewardRedemption(Base):
|
||||
__tablename__ = 'reward_redemptions'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_loyalty_id = Column(Integer, ForeignKey('user_loyalty.id'), nullable=False, index=True)
|
||||
reward_id = Column(Integer, ForeignKey('loyalty_rewards.id'), nullable=False, index=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||
points_used = Column(Integer, nullable=False)
|
||||
status = Column(Enum(RedemptionStatus), nullable=False, default=RedemptionStatus.pending, index=True)
|
||||
code = Column(String(50), unique=True, nullable=True, index=True)
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
used_at = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user_loyalty = relationship('UserLoyalty', foreign_keys=[user_loyalty_id])
|
||||
reward = relationship('LoyaltyReward', back_populates='redemptions')
|
||||
booking = relationship('Booking', foreign_keys=[booking_id])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Text, ForeignKey, DateTime, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
@@ -18,6 +18,14 @@ class User(Base):
|
||||
mfa_enabled = Column(Boolean, nullable=False, default=False)
|
||||
mfa_secret = Column(String(255), nullable=True)
|
||||
mfa_backup_codes = Column(Text, nullable=True)
|
||||
|
||||
# Guest Profile & CRM fields
|
||||
is_vip = Column(Boolean, nullable=False, default=False)
|
||||
lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest
|
||||
satisfaction_score = Column(Numeric(3, 2), nullable=True) # Average satisfaction score (0-5)
|
||||
last_visit_date = Column(DateTime, nullable=True) # Last booking check-in date
|
||||
total_visits = Column(Integer, nullable=False, default=0) # Total number of bookings
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
role = relationship('Role', back_populates='users')
|
||||
@@ -29,4 +37,13 @@ class User(Base):
|
||||
favorites = relationship('Favorite', back_populates='user', cascade='all, delete-orphan')
|
||||
service_bookings = relationship('ServiceBooking', back_populates='user')
|
||||
visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor')
|
||||
staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff')
|
||||
staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff')
|
||||
loyalty = relationship('UserLoyalty', back_populates='user', uselist=False, cascade='all, delete-orphan')
|
||||
referrals = relationship('Referral', foreign_keys='Referral.referred_user_id', back_populates='referred_user')
|
||||
|
||||
# Guest Profile & CRM relationships
|
||||
guest_preferences = relationship('GuestPreference', back_populates='user', uselist=False, cascade='all, delete-orphan')
|
||||
guest_notes = relationship('GuestNote', foreign_keys='GuestNote.user_id', back_populates='user', cascade='all, delete-orphan')
|
||||
guest_tags = relationship('GuestTag', secondary='guest_tag_associations', back_populates='users')
|
||||
guest_communications = relationship('GuestCommunication', foreign_keys='GuestCommunication.user_id', back_populates='user', cascade='all, delete-orphan')
|
||||
guest_segments = relationship('GuestSegment', secondary='guest_segment_associations', back_populates='users')
|
||||
31
Backend/src/models/user_loyalty.py
Normal file
31
Backend/src/models/user_loyalty.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, DateTime, ForeignKey, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
class UserLoyalty(Base):
|
||||
__tablename__ = 'user_loyalty'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), unique=True, nullable=False, index=True)
|
||||
tier_id = Column(Integer, ForeignKey('loyalty_tiers.id'), nullable=False, index=True)
|
||||
total_points = Column(Integer, nullable=False, default=0)
|
||||
lifetime_points = Column(Integer, nullable=False, default=0)
|
||||
available_points = Column(Integer, nullable=False, default=0)
|
||||
expired_points = Column(Integer, nullable=False, default=0)
|
||||
referral_code = Column(String(50), unique=True, nullable=True, index=True)
|
||||
referral_count = Column(Integer, nullable=False, default=0)
|
||||
birthday = Column(Date, nullable=True)
|
||||
anniversary_date = Column(Date, nullable=True)
|
||||
last_points_earned_date = Column(DateTime, nullable=True)
|
||||
tier_started_date = Column(DateTime, nullable=True)
|
||||
next_tier_points_needed = Column(Integer, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='loyalty')
|
||||
tier = relationship('LoyaltyTier', back_populates='user_tiers')
|
||||
point_transactions = relationship('LoyaltyPointTransaction', back_populates='user_loyalty', cascade='all, delete-orphan')
|
||||
referrals = relationship('Referral', foreign_keys='Referral.referrer_id', back_populates='referrer')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/loyalty_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
@@ -15,10 +15,13 @@ from ..models.room import Room, RoomStatus
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.service_usage import ServiceUsage
|
||||
from ..models.user_loyalty import UserLoyalty
|
||||
from ..models.referral import Referral, ReferralStatus
|
||||
from ..services.room_service import normalize_images, get_base_url
|
||||
from fastapi import Request
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template
|
||||
from ..services.loyalty_service import LoyaltyService
|
||||
router = APIRouter(prefix='/bookings', tags=['bookings'])
|
||||
|
||||
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
|
||||
@@ -161,6 +164,7 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
|
||||
notes = booking_data.get('notes')
|
||||
payment_method = booking_data.get('payment_method', 'cash')
|
||||
promotion_code = booking_data.get('promotion_code')
|
||||
referral_code = booking_data.get('referral_code')
|
||||
invoice_info = booking_data.get('invoice_info', {})
|
||||
missing_fields = []
|
||||
if not room_id:
|
||||
@@ -262,6 +266,33 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
|
||||
)
|
||||
db.add(booking)
|
||||
db.flush()
|
||||
|
||||
# Process referral code if provided
|
||||
if referral_code:
|
||||
try:
|
||||
from ..services.loyalty_service import LoyaltyService
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
# Check if loyalty program is enabled
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
is_enabled = True # Default to enabled
|
||||
if setting:
|
||||
is_enabled = setting.value.lower() == 'true'
|
||||
|
||||
if is_enabled:
|
||||
# Process referral code - this will create referral record and award points when booking is confirmed
|
||||
LoyaltyService.process_referral(
|
||||
db,
|
||||
current_user.id,
|
||||
referral_code.upper().strip(),
|
||||
booking.id
|
||||
)
|
||||
logger.info(f"Referral code {referral_code} processed for booking {booking.id}")
|
||||
except Exception as referral_error:
|
||||
logger.warning(f"Failed to process referral code {referral_code}: {referral_error}")
|
||||
# Don't fail the booking if referral processing fails
|
||||
if payment_method in ['stripe', 'paypal']:
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType
|
||||
if payment_method == 'stripe':
|
||||
@@ -586,6 +617,42 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
|
||||
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=guest_name, room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=booking.requires_deposit, deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None, original_price=float(booking.original_price) if booking.original_price else None, discount_amount=float(booking.discount_amount) if booking.discount_amount else None, promotion_code=booking.promotion_code, client_url=client_url, currency_symbol=currency_symbol)
|
||||
if guest_email:
|
||||
await send_email(to=guest_email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
|
||||
|
||||
# Award loyalty points for confirmed booking
|
||||
if booking.user:
|
||||
try:
|
||||
# Check if booking already earned points
|
||||
from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource
|
||||
existing_points = db.query(LoyaltyPointTransaction).filter(
|
||||
LoyaltyPointTransaction.booking_id == booking.id,
|
||||
LoyaltyPointTransaction.source == TransactionSource.booking
|
||||
).first()
|
||||
|
||||
if not existing_points:
|
||||
# Award points based on total price paid
|
||||
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
|
||||
if total_paid > 0:
|
||||
LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid)
|
||||
|
||||
# Process referral if applicable - referral is already processed when booking is created
|
||||
# This section is for backward compatibility with existing referrals
|
||||
if booking.user:
|
||||
user_loyalty = db.query(UserLoyalty).filter(UserLoyalty.user_id == booking.user_id).first()
|
||||
if user_loyalty and user_loyalty.referral_code:
|
||||
# Check if there's a referral for this user that hasn't been rewarded yet
|
||||
from ..models.referral import Referral
|
||||
referral = db.query(Referral).filter(
|
||||
Referral.referred_user_id == booking.user_id,
|
||||
Referral.booking_id == booking.id,
|
||||
Referral.status.in_([ReferralStatus.pending, ReferralStatus.completed])
|
||||
).first()
|
||||
if referral and referral.status == ReferralStatus.pending:
|
||||
# Award points now that booking is confirmed
|
||||
LoyaltyService.process_referral(db, booking.user_id, referral.referral_code, booking.id)
|
||||
except Exception as loyalty_error:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Failed to award loyalty points: {loyalty_error}')
|
||||
elif booking.status == BookingStatus.cancelled:
|
||||
guest_name = booking.user.full_name if booking.user else 'Guest'
|
||||
guest_email = booking.user.email if booking.user else None
|
||||
|
||||
564
Backend/src/routes/guest_profile_routes.py
Normal file
564
Backend/src/routes/guest_profile_routes.py
Normal file
@@ -0,0 +1,564 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.guest_preference import GuestPreference
|
||||
from ..models.guest_note import GuestNote
|
||||
from ..models.guest_tag import GuestTag
|
||||
from ..models.guest_communication import GuestCommunication, CommunicationType, CommunicationDirection
|
||||
from ..models.guest_segment import GuestSegment
|
||||
from ..services.guest_profile_service import GuestProfileService
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix='/guest-profiles', tags=['guest-profiles'])
|
||||
|
||||
# Guest Search and List
|
||||
@router.get('/')
|
||||
async def search_guests(
|
||||
search: Optional[str] = Query(None),
|
||||
is_vip: Optional[bool] = Query(None),
|
||||
segment_id: Optional[int] = Query(None),
|
||||
min_lifetime_value: Optional[float] = Query(None),
|
||||
min_satisfaction_score: Optional[float] = Query(None),
|
||||
tag_id: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Search and filter guests"""
|
||||
try:
|
||||
result = GuestProfileService.search_guests(
|
||||
db=db,
|
||||
search=search,
|
||||
is_vip=is_vip,
|
||||
segment_id=segment_id,
|
||||
min_lifetime_value=min_lifetime_value,
|
||||
min_satisfaction_score=min_satisfaction_score,
|
||||
tag_id=tag_id,
|
||||
page=page,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
guests_data = []
|
||||
for guest in result['guests']:
|
||||
guest_dict = {
|
||||
'id': guest.id,
|
||||
'full_name': guest.full_name,
|
||||
'email': guest.email,
|
||||
'phone': guest.phone,
|
||||
'is_vip': guest.is_vip,
|
||||
'lifetime_value': float(guest.lifetime_value) if guest.lifetime_value else 0,
|
||||
'satisfaction_score': float(guest.satisfaction_score) if guest.satisfaction_score else None,
|
||||
'total_visits': guest.total_visits,
|
||||
'last_visit_date': guest.last_visit_date.isoformat() if guest.last_visit_date else None,
|
||||
'tags': [{'id': tag.id, 'name': tag.name, 'color': tag.color} for tag in guest.guest_tags],
|
||||
'segments': [{'id': seg.id, 'name': seg.name} for seg in guest.guest_segments]
|
||||
}
|
||||
guests_data.append(guest_dict)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'guests': guests_data,
|
||||
'pagination': {
|
||||
'total': result['total'],
|
||||
'page': result['page'],
|
||||
'limit': result['limit'],
|
||||
'total_pages': result['total_pages']
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Get Guest Profile Details
|
||||
@router.get('/{user_id}')
|
||||
async def get_guest_profile(
|
||||
user_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get comprehensive guest profile"""
|
||||
try:
|
||||
# First check if user exists at all
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail=f'User with ID {user_id} not found')
|
||||
|
||||
# Check if user is a customer (role_id == 3)
|
||||
if user.role_id != 3:
|
||||
raise HTTPException(status_code=404, detail=f'User with ID {user_id} is not a guest (customer)')
|
||||
|
||||
# Get analytics
|
||||
analytics = GuestProfileService.get_guest_analytics(user_id, db)
|
||||
|
||||
# Get preferences
|
||||
preferences = db.query(GuestPreference).filter(GuestPreference.user_id == user_id).first()
|
||||
|
||||
# Get notes
|
||||
notes = db.query(GuestNote).filter(GuestNote.user_id == user_id).order_by(GuestNote.created_at.desc()).all()
|
||||
|
||||
# Get communications
|
||||
communications = db.query(GuestCommunication).filter(
|
||||
GuestCommunication.user_id == user_id
|
||||
).order_by(GuestCommunication.created_at.desc()).limit(20).all()
|
||||
|
||||
# Get booking history
|
||||
bookings = GuestProfileService.get_booking_history(user_id, db, limit=10)
|
||||
|
||||
# Safely access relationships
|
||||
try:
|
||||
tags = [{'id': tag.id, 'name': tag.name, 'color': tag.color} for tag in (user.guest_tags or [])]
|
||||
except Exception:
|
||||
tags = []
|
||||
|
||||
try:
|
||||
segments = [{'id': seg.id, 'name': seg.name, 'description': seg.description} for seg in (user.guest_segments or [])]
|
||||
except Exception:
|
||||
segments = []
|
||||
|
||||
profile_data = {
|
||||
'id': user.id,
|
||||
'full_name': user.full_name,
|
||||
'email': user.email,
|
||||
'phone': user.phone,
|
||||
'address': user.address,
|
||||
'avatar': user.avatar,
|
||||
'is_vip': getattr(user, 'is_vip', False),
|
||||
'lifetime_value': float(user.lifetime_value) if hasattr(user, 'lifetime_value') and user.lifetime_value else 0,
|
||||
'satisfaction_score': float(user.satisfaction_score) if hasattr(user, 'satisfaction_score') and user.satisfaction_score else None,
|
||||
'total_visits': getattr(user, 'total_visits', 0),
|
||||
'last_visit_date': user.last_visit_date.isoformat() if hasattr(user, 'last_visit_date') and user.last_visit_date else None,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'analytics': analytics,
|
||||
'preferences': {
|
||||
'preferred_room_location': preferences.preferred_room_location if preferences else None,
|
||||
'preferred_floor': preferences.preferred_floor if preferences else None,
|
||||
'preferred_amenities': preferences.preferred_amenities if preferences else None,
|
||||
'special_requests': preferences.special_requests if preferences else None,
|
||||
'preferred_contact_method': preferences.preferred_contact_method if preferences else None,
|
||||
'dietary_restrictions': preferences.dietary_restrictions if preferences else None,
|
||||
} if preferences else None,
|
||||
'tags': tags,
|
||||
'segments': segments,
|
||||
'notes': [{
|
||||
'id': note.id,
|
||||
'note': note.note,
|
||||
'is_important': note.is_important,
|
||||
'is_private': note.is_private,
|
||||
'created_by': note.creator.full_name if note.creator else None,
|
||||
'created_at': note.created_at.isoformat() if note.created_at else None
|
||||
} for note in notes],
|
||||
'communications': [{
|
||||
'id': comm.id,
|
||||
'communication_type': comm.communication_type.value,
|
||||
'direction': comm.direction.value,
|
||||
'subject': comm.subject,
|
||||
'content': comm.content,
|
||||
'staff_name': comm.staff.full_name if comm.staff else None,
|
||||
'created_at': comm.created_at.isoformat() if comm.created_at else None
|
||||
} for comm in communications],
|
||||
'recent_bookings': [{
|
||||
'id': booking.id,
|
||||
'booking_number': booking.booking_number,
|
||||
'check_in_date': booking.check_in_date.isoformat() if booking.check_in_date else None,
|
||||
'check_out_date': booking.check_out_date.isoformat() if booking.check_out_date else None,
|
||||
'status': booking.status.value if hasattr(booking.status, 'value') else str(booking.status),
|
||||
'total_price': float(booking.total_price) if booking.total_price else 0
|
||||
} for booking in bookings]
|
||||
}
|
||||
|
||||
return {'status': 'success', 'data': {'profile': profile_data}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_detail = f'Error fetching guest profile: {str(e)}\n{traceback.format_exc()}'
|
||||
raise HTTPException(status_code=500, detail=error_detail)
|
||||
|
||||
# Update Guest Preferences
|
||||
@router.put('/{user_id}/preferences')
|
||||
async def update_guest_preferences(
|
||||
user_id: int,
|
||||
preferences_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update guest preferences"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
preferences = db.query(GuestPreference).filter(GuestPreference.user_id == user_id).first()
|
||||
|
||||
if not preferences:
|
||||
preferences = GuestPreference(user_id=user_id)
|
||||
db.add(preferences)
|
||||
|
||||
if 'preferred_room_location' in preferences_data:
|
||||
preferences.preferred_room_location = preferences_data['preferred_room_location']
|
||||
if 'preferred_floor' in preferences_data:
|
||||
preferences.preferred_floor = preferences_data['preferred_floor']
|
||||
if 'preferred_room_type_id' in preferences_data:
|
||||
preferences.preferred_room_type_id = preferences_data['preferred_room_type_id']
|
||||
if 'preferred_amenities' in preferences_data:
|
||||
preferences.preferred_amenities = preferences_data['preferred_amenities']
|
||||
if 'special_requests' in preferences_data:
|
||||
preferences.special_requests = preferences_data['special_requests']
|
||||
if 'preferred_services' in preferences_data:
|
||||
preferences.preferred_services = preferences_data['preferred_services']
|
||||
if 'preferred_contact_method' in preferences_data:
|
||||
preferences.preferred_contact_method = preferences_data['preferred_contact_method']
|
||||
if 'preferred_language' in preferences_data:
|
||||
preferences.preferred_language = preferences_data['preferred_language']
|
||||
if 'dietary_restrictions' in preferences_data:
|
||||
preferences.dietary_restrictions = preferences_data['dietary_restrictions']
|
||||
if 'additional_preferences' in preferences_data:
|
||||
preferences.additional_preferences = preferences_data['additional_preferences']
|
||||
|
||||
db.commit()
|
||||
db.refresh(preferences)
|
||||
|
||||
return {'status': 'success', 'message': 'Preferences updated successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Create Guest Note
|
||||
@router.post('/{user_id}/notes')
|
||||
async def create_guest_note(
|
||||
user_id: int,
|
||||
note_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a note for a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
note = GuestNote(
|
||||
user_id=user_id,
|
||||
created_by=current_user.id,
|
||||
note=note_data.get('note'),
|
||||
is_important=note_data.get('is_important', False),
|
||||
is_private=note_data.get('is_private', False)
|
||||
)
|
||||
db.add(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return {'status': 'success', 'message': 'Note created successfully', 'data': {'note': {
|
||||
'id': note.id,
|
||||
'note': note.note,
|
||||
'is_important': note.is_important,
|
||||
'is_private': note.is_private,
|
||||
'created_at': note.created_at.isoformat() if note.created_at else None
|
||||
}}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Delete Guest Note
|
||||
@router.delete('/{user_id}/notes/{note_id}')
|
||||
async def delete_guest_note(
|
||||
user_id: int,
|
||||
note_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a guest note"""
|
||||
try:
|
||||
note = db.query(GuestNote).filter(GuestNote.id == note_id, GuestNote.user_id == user_id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail='Note not found')
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Note deleted successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Toggle VIP Status
|
||||
@router.put('/{user_id}/vip-status')
|
||||
async def toggle_vip_status(
|
||||
user_id: int,
|
||||
vip_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Toggle VIP status for a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
user.is_vip = vip_data.get('is_vip', False)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {'status': 'success', 'message': 'VIP status updated successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Add Tag to Guest
|
||||
@router.post('/{user_id}/tags')
|
||||
async def add_tag_to_guest(
|
||||
user_id: int,
|
||||
tag_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a tag to a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
tag_id = tag_data.get('tag_id')
|
||||
tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail='Tag not found')
|
||||
|
||||
if tag not in user.guest_tags:
|
||||
user.guest_tags.append(tag)
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Tag added successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Remove Tag from Guest
|
||||
@router.delete('/{user_id}/tags/{tag_id}')
|
||||
async def remove_tag_from_guest(
|
||||
user_id: int,
|
||||
tag_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a tag from a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail='Tag not found')
|
||||
|
||||
if tag in user.guest_tags:
|
||||
user.guest_tags.remove(tag)
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Tag removed successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Create Communication Record
|
||||
@router.post('/{user_id}/communications')
|
||||
async def create_communication(
|
||||
user_id: int,
|
||||
communication_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a communication record"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
comm = GuestCommunication(
|
||||
user_id=user_id,
|
||||
staff_id=current_user.id,
|
||||
communication_type=CommunicationType(communication_data.get('communication_type')),
|
||||
direction=CommunicationDirection(communication_data.get('direction')),
|
||||
subject=communication_data.get('subject'),
|
||||
content=communication_data.get('content'),
|
||||
booking_id=communication_data.get('booking_id'),
|
||||
is_automated=communication_data.get('is_automated', False)
|
||||
)
|
||||
db.add(comm)
|
||||
db.commit()
|
||||
db.refresh(comm)
|
||||
|
||||
return {'status': 'success', 'message': 'Communication recorded successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Get Guest Analytics
|
||||
@router.get('/{user_id}/analytics')
|
||||
async def get_guest_analytics(
|
||||
user_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get guest analytics"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
analytics = GuestProfileService.get_guest_analytics(user_id, db)
|
||||
|
||||
return {'status': 'success', 'data': {'analytics': analytics}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Update Guest Metrics
|
||||
@router.post('/{user_id}/update-metrics')
|
||||
async def update_guest_metrics(
|
||||
user_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update guest metrics (lifetime value, satisfaction score, etc.)"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
metrics = GuestProfileService.update_guest_metrics(user_id, db)
|
||||
|
||||
return {'status': 'success', 'message': 'Metrics updated successfully', 'data': {'metrics': metrics}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Tag Management Routes
|
||||
@router.get('/tags/all')
|
||||
async def get_all_tags(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all available tags"""
|
||||
try:
|
||||
tags = db.query(GuestTag).all()
|
||||
tags_data = [{
|
||||
'id': tag.id,
|
||||
'name': tag.name,
|
||||
'color': tag.color,
|
||||
'description': tag.description
|
||||
} for tag in tags]
|
||||
|
||||
return {'status': 'success', 'data': {'tags': tags_data}}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/tags')
|
||||
async def create_tag(
|
||||
tag_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new tag"""
|
||||
try:
|
||||
tag = GuestTag(
|
||||
name=tag_data.get('name'),
|
||||
color=tag_data.get('color', '#3B82F6'),
|
||||
description=tag_data.get('description')
|
||||
)
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
|
||||
return {'status': 'success', 'message': 'Tag created successfully', 'data': {'tag': {
|
||||
'id': tag.id,
|
||||
'name': tag.name,
|
||||
'color': tag.color,
|
||||
'description': tag.description
|
||||
}}}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Segment Management Routes
|
||||
@router.get('/segments/all')
|
||||
async def get_all_segments(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all available segments"""
|
||||
try:
|
||||
segments = db.query(GuestSegment).filter(GuestSegment.is_active == True).all()
|
||||
segments_data = [{
|
||||
'id': seg.id,
|
||||
'name': seg.name,
|
||||
'description': seg.description,
|
||||
'criteria': seg.criteria
|
||||
} for seg in segments]
|
||||
|
||||
return {'status': 'success', 'data': {'segments': segments_data}}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{user_id}/segments')
|
||||
async def assign_segment(
|
||||
user_id: int,
|
||||
segment_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Assign a guest to a segment"""
|
||||
try:
|
||||
segment_id = segment_data.get('segment_id')
|
||||
success = GuestProfileService.assign_segment(user_id, segment_id, db)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail='Guest or segment not found')
|
||||
|
||||
return {'status': 'success', 'message': 'Segment assigned successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{user_id}/segments/{segment_id}')
|
||||
async def remove_segment(
|
||||
user_id: int,
|
||||
segment_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a guest from a segment"""
|
||||
try:
|
||||
success = GuestProfileService.remove_segment(user_id, segment_id, db)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail='Guest or segment not found')
|
||||
|
||||
return {'status': 'success', 'message': 'Segment removed successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
981
Backend/src/routes/loyalty_routes.py
Normal file
981
Backend/src/routes/loyalty_routes.py
Normal file
@@ -0,0 +1,981 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, desc
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
import logging
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.user_loyalty import UserLoyalty
|
||||
from ..models.loyalty_tier import LoyaltyTier, TierLevel
|
||||
from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionType, TransactionSource
|
||||
from ..models.loyalty_reward import LoyaltyReward, RewardType, RewardStatus
|
||||
from ..models.reward_redemption import RewardRedemption, RedemptionStatus
|
||||
from ..models.referral import Referral, ReferralStatus
|
||||
from ..services.loyalty_service import LoyaltyService
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional as Opt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix='/loyalty', tags=['loyalty'])
|
||||
|
||||
# Pydantic schemas for request/response
|
||||
class UpdateUserLoyaltyRequest(BaseModel):
|
||||
birthday: Optional[str] = None
|
||||
anniversary_date: Optional[str] = None
|
||||
|
||||
class RedeemRewardRequest(BaseModel):
|
||||
reward_id: int
|
||||
booking_id: Optional[int] = None
|
||||
|
||||
class ApplyReferralRequest(BaseModel):
|
||||
referral_code: str
|
||||
|
||||
@router.get('/my-status')
|
||||
async def get_my_loyalty_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's loyalty status"""
|
||||
try:
|
||||
# Check if loyalty program is enabled
|
||||
from ..models.system_settings import SystemSettings
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
is_enabled = True # Default to enabled
|
||||
if setting:
|
||||
is_enabled = setting.value.lower() == 'true'
|
||||
|
||||
if not is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail='Loyalty program is currently disabled'
|
||||
)
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first()
|
||||
|
||||
# Get next tier
|
||||
next_tier = LoyaltyService.get_next_tier(db, tier) if tier else None
|
||||
|
||||
# Calculate points needed for next tier
|
||||
points_needed = None
|
||||
if next_tier:
|
||||
points_needed = next_tier.min_points - user_loyalty.lifetime_points
|
||||
|
||||
tier_dict = None
|
||||
if tier:
|
||||
tier_dict = {
|
||||
'id': tier.id,
|
||||
'level': tier.level.value if hasattr(tier.level, 'value') else tier.level,
|
||||
'name': tier.name,
|
||||
'description': tier.description,
|
||||
'points_earn_rate': float(tier.points_earn_rate) if tier.points_earn_rate else 1.0,
|
||||
'discount_percentage': float(tier.discount_percentage) if tier.discount_percentage else 0,
|
||||
'benefits': tier.benefits,
|
||||
'icon': tier.icon,
|
||||
'color': tier.color
|
||||
}
|
||||
|
||||
next_tier_dict = None
|
||||
if next_tier:
|
||||
next_tier_dict = {
|
||||
'id': next_tier.id,
|
||||
'level': next_tier.level.value if hasattr(next_tier.level, 'value') else next_tier.level,
|
||||
'name': next_tier.name,
|
||||
'min_points': next_tier.min_points,
|
||||
'points_earn_rate': float(next_tier.points_earn_rate) if next_tier.points_earn_rate else 1.0,
|
||||
'discount_percentage': float(next_tier.discount_percentage) if next_tier.discount_percentage else 0,
|
||||
'points_needed': points_needed
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'total_points': user_loyalty.total_points,
|
||||
'lifetime_points': user_loyalty.lifetime_points,
|
||||
'available_points': user_loyalty.available_points,
|
||||
'expired_points': user_loyalty.expired_points,
|
||||
'referral_code': user_loyalty.referral_code,
|
||||
'referral_count': user_loyalty.referral_count,
|
||||
'birthday': user_loyalty.birthday.isoformat() if user_loyalty.birthday else None,
|
||||
'anniversary_date': user_loyalty.anniversary_date.isoformat() if user_loyalty.anniversary_date else None,
|
||||
'tier': tier_dict,
|
||||
'next_tier': next_tier_dict,
|
||||
'points_needed_for_next_tier': points_needed,
|
||||
'tier_started_date': user_loyalty.tier_started_date.isoformat() if user_loyalty.tier_started_date else None
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
# Re-raise HTTPException (like 503 for disabled program) without modification
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/points/history')
|
||||
async def get_points_history(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
transaction_type: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get points transaction history"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
query = db.query(LoyaltyPointTransaction).filter(
|
||||
LoyaltyPointTransaction.user_loyalty_id == user_loyalty.id
|
||||
)
|
||||
|
||||
if transaction_type:
|
||||
try:
|
||||
query = query.filter(
|
||||
LoyaltyPointTransaction.transaction_type == TransactionType(transaction_type)
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
transactions = query.order_by(desc(LoyaltyPointTransaction.created_at)).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for transaction in transactions:
|
||||
result.append({
|
||||
'id': transaction.id,
|
||||
'transaction_type': transaction.transaction_type.value if hasattr(transaction.transaction_type, 'value') else transaction.transaction_type,
|
||||
'source': transaction.source.value if hasattr(transaction.source, 'value') else transaction.source,
|
||||
'points': transaction.points,
|
||||
'description': transaction.description,
|
||||
'reference_number': transaction.reference_number,
|
||||
'expires_at': transaction.expires_at.isoformat() if transaction.expires_at else None,
|
||||
'created_at': transaction.created_at.isoformat() if transaction.created_at else None,
|
||||
'booking_id': transaction.booking_id
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'transactions': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/my-status')
|
||||
async def update_my_loyalty_status(
|
||||
request: UpdateUserLoyaltyRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update user loyalty information (birthday, anniversary)"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
# Store original values to detect if date actually changed
|
||||
original_birthday = user_loyalty.birthday
|
||||
original_anniversary = user_loyalty.anniversary_date
|
||||
|
||||
birthday_changed = False
|
||||
anniversary_changed = False
|
||||
|
||||
if request.birthday:
|
||||
try:
|
||||
new_birthday = datetime.fromisoformat(request.birthday).date()
|
||||
# Check if birthday actually changed
|
||||
if new_birthday != original_birthday:
|
||||
birthday_changed = True
|
||||
user_loyalty.birthday = new_birthday
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid birthday format. Use YYYY-MM-DD")
|
||||
|
||||
if request.anniversary_date:
|
||||
try:
|
||||
new_anniversary = datetime.fromisoformat(request.anniversary_date).date()
|
||||
# Check if anniversary actually changed
|
||||
if new_anniversary != original_anniversary:
|
||||
anniversary_changed = True
|
||||
user_loyalty.anniversary_date = new_anniversary
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid anniversary date format. Use YYYY-MM-DD")
|
||||
|
||||
db.commit()
|
||||
db.refresh(user_loyalty)
|
||||
|
||||
# IMPORTANT: Only check for rewards if date was NOT just changed (prevent abuse)
|
||||
# If date was just changed, block rewards to prevent users from gaming the system
|
||||
# by repeatedly changing their birthday/anniversary to today's date
|
||||
if request.birthday and not birthday_changed:
|
||||
# Date wasn't changed, safe to check for rewards
|
||||
LoyaltyService.check_birthday_reward(db, current_user.id, prevent_recent_update=False)
|
||||
elif request.birthday and birthday_changed:
|
||||
# Date was just changed - block immediate rewards to prevent abuse
|
||||
logger.warning(f"Birthday reward blocked for user {current_user.id}: birthday was just updated")
|
||||
|
||||
if request.anniversary_date and not anniversary_changed:
|
||||
# Date wasn't changed, safe to check for rewards
|
||||
LoyaltyService.check_anniversary_reward(db, current_user.id, prevent_recent_update=False)
|
||||
elif request.anniversary_date and anniversary_changed:
|
||||
# Date was just changed - block immediate rewards to prevent abuse
|
||||
logger.warning(f"Anniversary reward blocked for user {current_user.id}: anniversary was just updated")
|
||||
|
||||
return {'status': 'success', 'message': 'Loyalty information updated successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/rewards')
|
||||
async def get_available_rewards(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get available rewards for current user"""
|
||||
try:
|
||||
# Check if loyalty program is enabled
|
||||
from ..models.system_settings import SystemSettings
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
is_enabled = True # Default to enabled
|
||||
if setting:
|
||||
is_enabled = setting.value.lower() == 'true'
|
||||
|
||||
if not is_enabled:
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'rewards': [],
|
||||
'available_points': 0,
|
||||
'program_enabled': False
|
||||
}
|
||||
}
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
rewards = db.query(LoyaltyReward).filter(LoyaltyReward.is_active == True).all()
|
||||
|
||||
result = []
|
||||
for reward in rewards:
|
||||
# Get user's tier info for better availability checking
|
||||
user_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first()
|
||||
user_tier_min_points = user_tier.min_points if user_tier else None
|
||||
|
||||
# If reward requires a specific tier, pre-load tier info for comparison
|
||||
if reward.applicable_tier_id:
|
||||
required_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == reward.applicable_tier_id).first()
|
||||
if required_tier:
|
||||
reward._required_tier_min_points = required_tier.min_points
|
||||
|
||||
is_available = reward.is_available(user_loyalty.tier_id, user_tier_min_points)
|
||||
can_afford = user_loyalty.available_points >= reward.points_cost
|
||||
|
||||
result.append({
|
||||
'id': reward.id,
|
||||
'name': reward.name,
|
||||
'description': reward.description,
|
||||
'reward_type': reward.reward_type.value if hasattr(reward.reward_type, 'value') else reward.reward_type,
|
||||
'points_cost': reward.points_cost,
|
||||
'discount_percentage': float(reward.discount_percentage) if reward.discount_percentage else None,
|
||||
'discount_amount': float(reward.discount_amount) if reward.discount_amount else None,
|
||||
'max_discount_amount': float(reward.max_discount_amount) if reward.max_discount_amount else None,
|
||||
'min_booking_amount': float(reward.min_booking_amount) if reward.min_booking_amount else None,
|
||||
'icon': reward.icon,
|
||||
'image': reward.image,
|
||||
'is_available': is_available,
|
||||
'can_afford': can_afford,
|
||||
'stock_remaining': reward.stock_quantity - reward.redeemed_count if reward.stock_quantity else None,
|
||||
'valid_from': reward.valid_from.isoformat() if reward.valid_from else None,
|
||||
'valid_until': reward.valid_until.isoformat() if reward.valid_until else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'rewards': result,
|
||||
'available_points': user_loyalty.available_points
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/rewards/redeem')
|
||||
async def redeem_reward(
|
||||
request: RedeemRewardRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Redeem points for a reward"""
|
||||
try:
|
||||
# Check if loyalty program is enabled
|
||||
from ..models.system_settings import SystemSettings
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
is_enabled = True # Default to enabled
|
||||
if setting:
|
||||
is_enabled = setting.value.lower() == 'true'
|
||||
|
||||
if not is_enabled:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail='Loyalty program is currently disabled. Cannot redeem rewards.'
|
||||
)
|
||||
redemption = LoyaltyService.redeem_points(
|
||||
db,
|
||||
current_user.id,
|
||||
request.reward_id,
|
||||
request.booking_id
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Reward redeemed successfully',
|
||||
'data': {
|
||||
'redemption_id': redemption.id,
|
||||
'code': redemption.code,
|
||||
'points_used': redemption.points_used,
|
||||
'status': redemption.status.value if hasattr(redemption.status, 'value') else redemption.status,
|
||||
'expires_at': redemption.expires_at.isoformat() if redemption.expires_at else None
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
# Re-raise HTTPException (like 503 for disabled program) without modification
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/rewards/my-redemptions')
|
||||
async def get_my_redemptions(
|
||||
status_filter: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's reward redemptions"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
query = db.query(RewardRedemption).filter(
|
||||
RewardRedemption.user_loyalty_id == user_loyalty.id
|
||||
).join(LoyaltyReward)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(RewardRedemption.status == RedemptionStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
redemptions = query.order_by(desc(RewardRedemption.created_at)).all()
|
||||
|
||||
result = []
|
||||
for redemption in redemptions:
|
||||
reward = redemption.reward
|
||||
result.append({
|
||||
'id': redemption.id,
|
||||
'reward': {
|
||||
'id': reward.id,
|
||||
'name': reward.name,
|
||||
'description': reward.description,
|
||||
'reward_type': reward.reward_type.value if hasattr(reward.reward_type, 'value') else reward.reward_type,
|
||||
'discount_percentage': float(reward.discount_percentage) if reward.discount_percentage else None,
|
||||
'discount_amount': float(reward.discount_amount) if reward.discount_amount else None,
|
||||
'max_discount_amount': float(reward.max_discount_amount) if reward.max_discount_amount else None,
|
||||
'min_booking_amount': float(reward.min_booking_amount) if reward.min_booking_amount else None
|
||||
},
|
||||
'points_used': redemption.points_used,
|
||||
'status': redemption.status.value if hasattr(redemption.status, 'value') else redemption.status,
|
||||
'code': redemption.code,
|
||||
'booking_id': redemption.booking_id,
|
||||
'expires_at': redemption.expires_at.isoformat() if redemption.expires_at else None,
|
||||
'used_at': redemption.used_at.isoformat() if redemption.used_at else None,
|
||||
'created_at': redemption.created_at.isoformat() if redemption.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'redemptions': result
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/referral/apply')
|
||||
async def apply_referral_code(
|
||||
request: ApplyReferralRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Apply referral code (typically during registration or first booking)"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
# Check if user already has referrals (can't use referral code if already referred)
|
||||
existing_referral = db.query(Referral).filter(
|
||||
Referral.referred_user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if existing_referral:
|
||||
raise HTTPException(status_code=400, detail="You have already used a referral code")
|
||||
|
||||
# Process referral (will be completed when booking is made)
|
||||
LoyaltyService.process_referral(
|
||||
db,
|
||||
current_user.id,
|
||||
request.referral_code,
|
||||
None # No booking yet
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Referral code applied successfully. You will receive bonus points when you complete your first booking.'
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/referral/my-referrals')
|
||||
async def get_my_referrals(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user's referrals (people they referred)"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, current_user.id)
|
||||
|
||||
referrals = db.query(Referral).filter(
|
||||
Referral.referrer_id == user_loyalty.id
|
||||
).order_by(desc(Referral.created_at)).all()
|
||||
|
||||
result = []
|
||||
for referral in referrals:
|
||||
referred_user = referral.referred_user
|
||||
result.append({
|
||||
'id': referral.id,
|
||||
'referred_user': {
|
||||
'id': referred_user.id,
|
||||
'name': referred_user.full_name,
|
||||
'email': referred_user.email
|
||||
} if referred_user else None,
|
||||
'referral_code': referral.referral_code,
|
||||
'status': referral.status.value if hasattr(referral.status, 'value') else referral.status,
|
||||
'referrer_points_earned': referral.referrer_points_earned,
|
||||
'referred_points_earned': referral.referred_points_earned,
|
||||
'completed_at': referral.completed_at.isoformat() if referral.completed_at else None,
|
||||
'rewarded_at': referral.rewarded_at.isoformat() if referral.rewarded_at else None,
|
||||
'created_at': referral.created_at.isoformat() if referral.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'referrals': result,
|
||||
'total_referrals': len(result),
|
||||
'total_points_earned': sum(r.referrer_points_earned for r in referrals)
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Admin routes - Program Control
|
||||
@router.get('/admin/status')
|
||||
async def get_loyalty_program_status(
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get loyalty program enabled/disabled status"""
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
|
||||
is_enabled = True # Default to enabled
|
||||
if setting:
|
||||
is_enabled = setting.value.lower() == 'true'
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'enabled': is_enabled,
|
||||
'updated_at': setting.updated_at.isoformat() if setting and setting.updated_at else None,
|
||||
'updated_by': setting.updated_by.full_name if setting and setting.updated_by else None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/admin/status')
|
||||
async def update_loyalty_program_status(
|
||||
status_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Enable/disable loyalty program"""
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
enabled = status_data.get('enabled', True)
|
||||
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = str(enabled).lower()
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key='loyalty_program_enabled',
|
||||
value=str(enabled).lower(),
|
||||
description='Enable or disable the loyalty program',
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': f'Loyalty program {"enabled" if enabled else "disabled"} successfully',
|
||||
'data': {
|
||||
'enabled': enabled,
|
||||
'updated_at': setting.updated_at.isoformat() if setting.updated_at else None
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Admin routes - Tiers Management
|
||||
@router.get('/admin/tiers')
|
||||
async def get_all_tiers(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all loyalty tiers (admin)"""
|
||||
try:
|
||||
tiers = db.query(LoyaltyTier).order_by(LoyaltyTier.min_points.asc()).all()
|
||||
|
||||
result = []
|
||||
for tier in tiers:
|
||||
result.append({
|
||||
'id': tier.id,
|
||||
'level': tier.level.value if hasattr(tier.level, 'value') else tier.level,
|
||||
'name': tier.name,
|
||||
'description': tier.description,
|
||||
'min_points': tier.min_points,
|
||||
'points_earn_rate': float(tier.points_earn_rate) if tier.points_earn_rate else 1.0,
|
||||
'discount_percentage': float(tier.discount_percentage) if tier.discount_percentage else 0,
|
||||
'benefits': tier.benefits,
|
||||
'icon': tier.icon,
|
||||
'color': tier.color,
|
||||
'is_active': tier.is_active,
|
||||
'created_at': tier.created_at.isoformat() if tier.created_at else None,
|
||||
'updated_at': tier.updated_at.isoformat() if tier.updated_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'tiers': result
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/admin/tiers')
|
||||
async def create_tier(
|
||||
tier_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new loyalty tier"""
|
||||
try:
|
||||
# Check if tier level already exists
|
||||
existing = db.query(LoyaltyTier).filter(
|
||||
LoyaltyTier.level == TierLevel(tier_data['level'])
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Tier level {tier_data['level']} already exists")
|
||||
|
||||
tier = LoyaltyTier(
|
||||
level=TierLevel(tier_data['level']),
|
||||
name=tier_data['name'],
|
||||
description=tier_data.get('description'),
|
||||
min_points=tier_data.get('min_points', 0),
|
||||
points_earn_rate=tier_data.get('points_earn_rate', 1.0),
|
||||
discount_percentage=tier_data.get('discount_percentage', 0),
|
||||
benefits=tier_data.get('benefits'),
|
||||
icon=tier_data.get('icon'),
|
||||
color=tier_data.get('color'),
|
||||
is_active=tier_data.get('is_active', True)
|
||||
)
|
||||
db.add(tier)
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Tier created successfully',
|
||||
'data': {
|
||||
'id': tier.id,
|
||||
'level': tier.level.value if hasattr(tier.level, 'value') else tier.level,
|
||||
'name': tier.name
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/admin/tiers/{tier_id}')
|
||||
async def update_tier(
|
||||
tier_id: int,
|
||||
tier_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a loyalty tier"""
|
||||
try:
|
||||
tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == tier_id).first()
|
||||
if not tier:
|
||||
raise HTTPException(status_code=404, detail='Tier not found')
|
||||
|
||||
# Check if level is being changed and if new level exists
|
||||
if 'level' in tier_data and tier_data['level'] != tier.level.value:
|
||||
existing = db.query(LoyaltyTier).filter(
|
||||
LoyaltyTier.level == TierLevel(tier_data['level']),
|
||||
LoyaltyTier.id != tier_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Tier level {tier_data['level']} already exists")
|
||||
tier.level = TierLevel(tier_data['level'])
|
||||
|
||||
if 'name' in tier_data:
|
||||
tier.name = tier_data['name']
|
||||
if 'description' in tier_data:
|
||||
tier.description = tier_data['description']
|
||||
if 'min_points' in tier_data:
|
||||
tier.min_points = tier_data['min_points']
|
||||
if 'points_earn_rate' in tier_data:
|
||||
tier.points_earn_rate = tier_data['points_earn_rate']
|
||||
if 'discount_percentage' in tier_data:
|
||||
tier.discount_percentage = tier_data['discount_percentage']
|
||||
if 'benefits' in tier_data:
|
||||
tier.benefits = tier_data['benefits']
|
||||
if 'icon' in tier_data:
|
||||
tier.icon = tier_data['icon']
|
||||
if 'color' in tier_data:
|
||||
tier.color = tier_data['color']
|
||||
if 'is_active' in tier_data:
|
||||
tier.is_active = tier_data['is_active']
|
||||
|
||||
db.commit()
|
||||
db.refresh(tier)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Tier updated successfully',
|
||||
'data': {
|
||||
'id': tier.id,
|
||||
'level': tier.level.value if hasattr(tier.level, 'value') else tier.level,
|
||||
'name': tier.name
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/admin/tiers/{tier_id}')
|
||||
async def delete_tier(
|
||||
tier_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a loyalty tier"""
|
||||
try:
|
||||
tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == tier_id).first()
|
||||
if not tier:
|
||||
raise HTTPException(status_code=404, detail='Tier not found')
|
||||
|
||||
# Check if any users are using this tier
|
||||
users_count = db.query(UserLoyalty).filter(UserLoyalty.tier_id == tier_id).count()
|
||||
if users_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot delete tier. {users_count} user(s) are currently assigned to this tier. Please reassign them first.'
|
||||
)
|
||||
|
||||
db.delete(tier)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Tier deleted successfully'
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/admin/users')
|
||||
async def get_users_loyalty_status(
|
||||
search: Optional[str] = Query(None),
|
||||
tier_id: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all users' loyalty status (admin)"""
|
||||
try:
|
||||
query = db.query(UserLoyalty).join(User)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
User.full_name.like(f'%{search}%') |
|
||||
User.email.like(f'%{search}%')
|
||||
)
|
||||
|
||||
if tier_id:
|
||||
query = query.filter(UserLoyalty.tier_id == tier_id)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
user_loyalties = query.order_by(desc(UserLoyalty.lifetime_points)).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for user_loyalty in user_loyalties:
|
||||
user = user_loyalty.user
|
||||
tier = user_loyalty.tier
|
||||
|
||||
result.append({
|
||||
'user_id': user.id,
|
||||
'user_name': user.full_name,
|
||||
'user_email': user.email,
|
||||
'tier': {
|
||||
'id': tier.id,
|
||||
'name': tier.name,
|
||||
'level': tier.level.value if hasattr(tier.level, 'value') else tier.level
|
||||
} if tier else None,
|
||||
'total_points': user_loyalty.total_points,
|
||||
'lifetime_points': user_loyalty.lifetime_points,
|
||||
'available_points': user_loyalty.available_points,
|
||||
'referral_count': user_loyalty.referral_count,
|
||||
'tier_started_date': user_loyalty.tier_started_date.isoformat() if user_loyalty.tier_started_date else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'users': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Admin routes - Rewards Management
|
||||
@router.get('/admin/rewards')
|
||||
async def get_all_rewards_admin(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all rewards (admin)"""
|
||||
try:
|
||||
rewards = db.query(LoyaltyReward).order_by(LoyaltyReward.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for reward in rewards:
|
||||
result.append({
|
||||
'id': reward.id,
|
||||
'name': reward.name,
|
||||
'description': reward.description,
|
||||
'reward_type': reward.reward_type.value if hasattr(reward.reward_type, 'value') else reward.reward_type,
|
||||
'points_cost': reward.points_cost,
|
||||
'discount_percentage': float(reward.discount_percentage) if reward.discount_percentage else None,
|
||||
'discount_amount': float(reward.discount_amount) if reward.discount_amount else None,
|
||||
'max_discount_amount': float(reward.max_discount_amount) if reward.max_discount_amount else None,
|
||||
'applicable_tier_id': reward.applicable_tier_id,
|
||||
'min_booking_amount': float(reward.min_booking_amount) if reward.min_booking_amount else None,
|
||||
'icon': reward.icon,
|
||||
'image': reward.image,
|
||||
'is_active': reward.is_active,
|
||||
'stock_quantity': reward.stock_quantity,
|
||||
'redeemed_count': reward.redeemed_count,
|
||||
'valid_from': reward.valid_from.isoformat() if reward.valid_from else None,
|
||||
'valid_until': reward.valid_until.isoformat() if reward.valid_until else None,
|
||||
'created_at': reward.created_at.isoformat() if reward.created_at else None,
|
||||
'updated_at': reward.updated_at.isoformat() if reward.updated_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'rewards': result
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/admin/rewards')
|
||||
async def create_reward(
|
||||
reward_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new reward"""
|
||||
try:
|
||||
reward = LoyaltyReward(
|
||||
name=reward_data['name'],
|
||||
description=reward_data.get('description'),
|
||||
reward_type=RewardType(reward_data['reward_type']),
|
||||
points_cost=reward_data['points_cost'],
|
||||
discount_percentage=reward_data.get('discount_percentage'),
|
||||
discount_amount=reward_data.get('discount_amount'),
|
||||
max_discount_amount=reward_data.get('max_discount_amount'),
|
||||
applicable_tier_id=reward_data.get('applicable_tier_id'),
|
||||
min_booking_amount=reward_data.get('min_booking_amount'),
|
||||
icon=reward_data.get('icon'),
|
||||
image=reward_data.get('image'),
|
||||
is_active=reward_data.get('is_active', True),
|
||||
stock_quantity=reward_data.get('stock_quantity'),
|
||||
valid_from=datetime.fromisoformat(reward_data['valid_from']) if reward_data.get('valid_from') else None,
|
||||
valid_until=datetime.fromisoformat(reward_data['valid_until']) if reward_data.get('valid_until') else None
|
||||
)
|
||||
db.add(reward)
|
||||
db.commit()
|
||||
db.refresh(reward)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Reward created successfully',
|
||||
'data': {
|
||||
'id': reward.id,
|
||||
'name': reward.name
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid reward type: {str(e)}')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/admin/rewards/{reward_id}')
|
||||
async def update_reward(
|
||||
reward_id: int,
|
||||
reward_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a reward"""
|
||||
try:
|
||||
reward = db.query(LoyaltyReward).filter(LoyaltyReward.id == reward_id).first()
|
||||
if not reward:
|
||||
raise HTTPException(status_code=404, detail='Reward not found')
|
||||
|
||||
if 'name' in reward_data:
|
||||
reward.name = reward_data['name']
|
||||
if 'description' in reward_data:
|
||||
reward.description = reward_data['description']
|
||||
if 'reward_type' in reward_data:
|
||||
reward.reward_type = RewardType(reward_data['reward_type'])
|
||||
if 'points_cost' in reward_data:
|
||||
reward.points_cost = reward_data['points_cost']
|
||||
if 'discount_percentage' in reward_data:
|
||||
reward.discount_percentage = reward_data['discount_percentage']
|
||||
if 'discount_amount' in reward_data:
|
||||
reward.discount_amount = reward_data['discount_amount']
|
||||
if 'max_discount_amount' in reward_data:
|
||||
reward.max_discount_amount = reward_data['max_discount_amount']
|
||||
if 'applicable_tier_id' in reward_data:
|
||||
reward.applicable_tier_id = reward_data['applicable_tier_id']
|
||||
if 'min_booking_amount' in reward_data:
|
||||
reward.min_booking_amount = reward_data['min_booking_amount']
|
||||
if 'icon' in reward_data:
|
||||
reward.icon = reward_data['icon']
|
||||
if 'image' in reward_data:
|
||||
reward.image = reward_data['image']
|
||||
if 'is_active' in reward_data:
|
||||
reward.is_active = reward_data['is_active']
|
||||
if 'stock_quantity' in reward_data:
|
||||
reward.stock_quantity = reward_data['stock_quantity']
|
||||
if 'valid_from' in reward_data:
|
||||
reward.valid_from = datetime.fromisoformat(reward_data['valid_from']) if reward_data['valid_from'] else None
|
||||
if 'valid_until' in reward_data:
|
||||
reward.valid_until = datetime.fromisoformat(reward_data['valid_until']) if reward_data['valid_until'] else None
|
||||
|
||||
db.commit()
|
||||
db.refresh(reward)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Reward updated successfully',
|
||||
'data': {
|
||||
'id': reward.id,
|
||||
'name': reward.name
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid data: {str(e)}')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/admin/rewards/{reward_id}')
|
||||
async def delete_reward(
|
||||
reward_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a reward"""
|
||||
try:
|
||||
reward = db.query(LoyaltyReward).filter(LoyaltyReward.id == reward_id).first()
|
||||
if not reward:
|
||||
raise HTTPException(status_code=404, detail='Reward not found')
|
||||
|
||||
# Check if any redemptions exist
|
||||
redemptions_count = db.query(RewardRedemption).filter(RewardRedemption.reward_id == reward_id).count()
|
||||
if redemptions_count > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f'Cannot delete reward. {redemptions_count} redemption(s) exist. Deactivate it instead.'
|
||||
)
|
||||
|
||||
db.delete(reward)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Reward deleted successfully'
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
|
||||
from ..services.stripe_service import StripeService
|
||||
from ..services.paypal_service import PayPalService
|
||||
from ..services.loyalty_service import LoyaltyService
|
||||
router = APIRouter(prefix='/payments', tags=['payments'])
|
||||
|
||||
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'):
|
||||
@@ -137,6 +138,29 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr
|
||||
db.add(payment)
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
# Award loyalty points if payment completed and booking is confirmed
|
||||
if payment.payment_status == PaymentStatus.completed and booking:
|
||||
try:
|
||||
db.refresh(booking)
|
||||
if booking.status == BookingStatus.confirmed and booking.user:
|
||||
# Check if booking already earned points
|
||||
from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionSource
|
||||
existing_points = db.query(LoyaltyPointTransaction).filter(
|
||||
LoyaltyPointTransaction.booking_id == booking.id,
|
||||
LoyaltyPointTransaction.source == TransactionSource.booking
|
||||
).first()
|
||||
|
||||
if not existing_points:
|
||||
# Award points based on payment amount
|
||||
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
|
||||
if total_paid > 0:
|
||||
LoyaltyService.earn_points_from_booking(db, booking.user_id, booking, total_paid)
|
||||
except Exception as loyalty_error:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Failed to award loyalty points: {loyalty_error}')
|
||||
|
||||
if payment.payment_status == PaymentStatus.completed and booking.user:
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/loyalty_service.cpython-312.pyc
Normal file
Binary file not shown.
262
Backend/src/services/guest_profile_service.py
Normal file
262
Backend/src/services/guest_profile_service.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, desc
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from ..models.user import User
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.payment import Payment
|
||||
from ..models.review import Review
|
||||
from ..models.guest_preference import GuestPreference
|
||||
from ..models.guest_note import GuestNote
|
||||
from ..models.guest_tag import GuestTag
|
||||
from ..models.guest_communication import GuestCommunication
|
||||
from ..models.guest_segment import GuestSegment, guest_segment_association
|
||||
|
||||
class GuestProfileService:
|
||||
|
||||
@staticmethod
|
||||
def calculate_lifetime_value(user_id: int, db: Session) -> Decimal:
|
||||
"""Calculate guest lifetime value from all bookings and payments"""
|
||||
from ..models.payment import PaymentStatus
|
||||
|
||||
# Get payments through bookings
|
||||
total_revenue = db.query(func.coalesce(func.sum(Payment.amount), 0)).join(
|
||||
Booking, Payment.booking_id == Booking.id
|
||||
).filter(
|
||||
Booking.user_id == user_id,
|
||||
Payment.payment_status == PaymentStatus.completed
|
||||
).scalar()
|
||||
|
||||
# Also include service bookings
|
||||
from ..models.service_booking import ServiceBooking, ServiceBookingStatus
|
||||
service_revenue = db.query(func.coalesce(func.sum(ServiceBooking.total_amount), 0)).filter(
|
||||
ServiceBooking.user_id == user_id,
|
||||
ServiceBooking.status == ServiceBookingStatus.completed
|
||||
).scalar()
|
||||
|
||||
return Decimal(str(total_revenue or 0)) + Decimal(str(service_revenue or 0))
|
||||
|
||||
@staticmethod
|
||||
def calculate_satisfaction_score(user_id: int, db: Session) -> Optional[float]:
|
||||
"""Calculate average satisfaction score from reviews"""
|
||||
avg_rating = db.query(func.avg(Review.rating)).filter(
|
||||
Review.user_id == user_id,
|
||||
Review.status == 'approved'
|
||||
).scalar()
|
||||
|
||||
return float(avg_rating) if avg_rating else None
|
||||
|
||||
@staticmethod
|
||||
def get_booking_history(user_id: int, db: Session, limit: Optional[int] = None) -> List[Booking]:
|
||||
"""Get complete booking history for a guest"""
|
||||
query = db.query(Booking).filter(Booking.user_id == user_id).order_by(desc(Booking.created_at))
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def get_booking_statistics(user_id: int, db: Session) -> Dict:
|
||||
"""Get booking statistics for a guest"""
|
||||
total_bookings = db.query(Booking).filter(Booking.user_id == user_id).count()
|
||||
completed_bookings = db.query(Booking).filter(
|
||||
Booking.user_id == user_id,
|
||||
Booking.status == BookingStatus.checked_out
|
||||
).count()
|
||||
cancelled_bookings = db.query(Booking).filter(
|
||||
Booking.user_id == user_id,
|
||||
Booking.status == BookingStatus.cancelled
|
||||
).count()
|
||||
|
||||
# Get last visit date
|
||||
last_booking = db.query(Booking).filter(
|
||||
Booking.user_id == user_id,
|
||||
Booking.status == BookingStatus.checked_out
|
||||
).order_by(desc(Booking.check_in_date)).first()
|
||||
|
||||
last_visit_date = last_booking.check_in_date if last_booking else None
|
||||
|
||||
# Get total nights stayed
|
||||
total_nights = db.query(
|
||||
func.sum(func.extract('day', Booking.check_out_date - Booking.check_in_date))
|
||||
).filter(
|
||||
Booking.user_id == user_id,
|
||||
Booking.status == BookingStatus.checked_out
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
'total_bookings': total_bookings,
|
||||
'completed_bookings': completed_bookings,
|
||||
'cancelled_bookings': cancelled_bookings,
|
||||
'last_visit_date': last_visit_date.isoformat() if last_visit_date else None,
|
||||
'total_nights_stayed': int(total_nights)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_guest_metrics(user_id: int, db: Session) -> Dict:
|
||||
"""Update guest metrics (lifetime value, satisfaction score, etc.)"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Calculate and update lifetime value
|
||||
lifetime_value = GuestProfileService.calculate_lifetime_value(user_id, db)
|
||||
user.lifetime_value = lifetime_value
|
||||
|
||||
# Calculate and update satisfaction score
|
||||
satisfaction_score = GuestProfileService.calculate_satisfaction_score(user_id, db)
|
||||
if satisfaction_score:
|
||||
user.satisfaction_score = Decimal(str(satisfaction_score))
|
||||
|
||||
# Update total visits
|
||||
stats = GuestProfileService.get_booking_statistics(user_id, db)
|
||||
user.total_visits = stats['completed_bookings']
|
||||
if stats['last_visit_date']:
|
||||
user.last_visit_date = datetime.fromisoformat(stats['last_visit_date'].replace('Z', '+00:00'))
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
'lifetime_value': float(lifetime_value),
|
||||
'satisfaction_score': satisfaction_score,
|
||||
'total_visits': user.total_visits,
|
||||
'last_visit_date': user.last_visit_date.isoformat() if user.last_visit_date else None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_guest_segments(user_id: int, db: Session) -> List[GuestSegment]:
|
||||
"""Get all segments a guest belongs to"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return []
|
||||
return user.guest_segments
|
||||
|
||||
@staticmethod
|
||||
def assign_segment(user_id: int, segment_id: int, db: Session) -> bool:
|
||||
"""Assign a guest to a segment"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
segment = db.query(GuestSegment).filter(GuestSegment.id == segment_id).first()
|
||||
|
||||
if not user or not segment:
|
||||
return False
|
||||
|
||||
if segment not in user.guest_segments:
|
||||
user.guest_segments.append(segment)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def remove_segment(user_id: int, segment_id: int, db: Session) -> bool:
|
||||
"""Remove a guest from a segment"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
segment = db.query(GuestSegment).filter(GuestSegment.id == segment_id).first()
|
||||
|
||||
if not user or not segment:
|
||||
return False
|
||||
|
||||
if segment in user.guest_segments:
|
||||
user.guest_segments.remove(segment)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def get_guest_analytics(user_id: int, db: Session) -> Dict:
|
||||
"""Get comprehensive analytics for a guest"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Get booking statistics
|
||||
booking_stats = GuestProfileService.get_booking_statistics(user_id, db)
|
||||
|
||||
# Get lifetime value
|
||||
lifetime_value = GuestProfileService.calculate_lifetime_value(user_id, db)
|
||||
|
||||
# Get satisfaction score
|
||||
satisfaction_score = GuestProfileService.calculate_satisfaction_score(user_id, db)
|
||||
|
||||
# Get preferred room types
|
||||
bookings = db.query(Booking).filter(Booking.user_id == user_id).all()
|
||||
room_type_counts = {}
|
||||
for booking in bookings:
|
||||
if booking.room and booking.room.room_type:
|
||||
room_type_name = booking.room.room_type.name
|
||||
room_type_counts[room_type_name] = room_type_counts.get(room_type_name, 0) + 1
|
||||
|
||||
preferred_room_type = max(room_type_counts.items(), key=lambda x: x[1])[0] if room_type_counts else None
|
||||
|
||||
# Get average booking value
|
||||
avg_booking_value = db.query(func.avg(Booking.total_price)).filter(
|
||||
Booking.user_id == user_id,
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_out])
|
||||
).scalar() or 0
|
||||
|
||||
# Get communication count
|
||||
communication_count = db.query(GuestCommunication).filter(
|
||||
GuestCommunication.user_id == user_id
|
||||
).count()
|
||||
|
||||
return {
|
||||
'lifetime_value': float(lifetime_value),
|
||||
'satisfaction_score': satisfaction_score,
|
||||
'booking_statistics': booking_stats,
|
||||
'preferred_room_type': preferred_room_type,
|
||||
'average_booking_value': float(avg_booking_value),
|
||||
'communication_count': communication_count,
|
||||
'is_vip': user.is_vip,
|
||||
'total_visits': user.total_visits,
|
||||
'last_visit_date': user.last_visit_date.isoformat() if user.last_visit_date else None
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def search_guests(
|
||||
db: Session,
|
||||
search: Optional[str] = None,
|
||||
is_vip: Optional[bool] = None,
|
||||
segment_id: Optional[int] = None,
|
||||
min_lifetime_value: Optional[float] = None,
|
||||
min_satisfaction_score: Optional[float] = None,
|
||||
tag_id: Optional[int] = None,
|
||||
page: int = 1,
|
||||
limit: int = 10
|
||||
) -> Dict:
|
||||
"""Search and filter guests with various criteria"""
|
||||
query = db.query(User).filter(User.role_id == 3) # Only customers
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
User.full_name.ilike(f'%{search}%'),
|
||||
User.email.ilike(f'%{search}%'),
|
||||
User.phone.ilike(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
if is_vip is not None:
|
||||
query = query.filter(User.is_vip == is_vip)
|
||||
|
||||
if segment_id:
|
||||
query = query.join(User.guest_segments).filter(GuestSegment.id == segment_id)
|
||||
|
||||
if min_lifetime_value is not None:
|
||||
query = query.filter(User.lifetime_value >= Decimal(str(min_lifetime_value)))
|
||||
|
||||
if min_satisfaction_score is not None:
|
||||
query = query.filter(User.satisfaction_score >= Decimal(str(min_satisfaction_score)))
|
||||
|
||||
if tag_id:
|
||||
query = query.join(User.guest_tags).filter(GuestTag.id == tag_id)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
guests = query.order_by(desc(User.lifetime_value)).offset(offset).limit(limit).all()
|
||||
|
||||
return {
|
||||
'guests': guests,
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
|
||||
635
Backend/src/services/loyalty_service.py
Normal file
635
Backend/src/services/loyalty_service.py
Normal file
@@ -0,0 +1,635 @@
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Optional
|
||||
import random
|
||||
import string
|
||||
from ..models.user_loyalty import UserLoyalty
|
||||
from ..models.loyalty_tier import LoyaltyTier, TierLevel
|
||||
from ..models.loyalty_point_transaction import LoyaltyPointTransaction, TransactionType, TransactionSource
|
||||
from ..models.loyalty_reward import LoyaltyReward
|
||||
from ..models.reward_redemption import RewardRedemption, RedemptionStatus
|
||||
from ..models.referral import Referral, ReferralStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.user import User
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class LoyaltyService:
|
||||
# Points earning rates
|
||||
POINTS_PER_DOLLAR = 10 # Base: 10 points per dollar spent
|
||||
BIRTHDAY_BONUS_POINTS = 500
|
||||
ANNIVERSARY_BONUS_POINTS = 1000
|
||||
REFERRER_POINTS = 500 # Points for referrer when referral completes booking
|
||||
REFERRED_BONUS_POINTS = 250 # Bonus points for new referred user
|
||||
|
||||
# Points expiration (in days)
|
||||
POINTS_EXPIRATION_DAYS = 365 # Points expire after 1 year
|
||||
|
||||
@staticmethod
|
||||
def is_loyalty_enabled(db: Session) -> bool:
|
||||
"""Check if loyalty program is enabled"""
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == 'loyalty_program_enabled'
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
return True # Default to enabled
|
||||
|
||||
return setting.value.lower() == 'true'
|
||||
except Exception:
|
||||
return True # Default to enabled on error
|
||||
|
||||
@staticmethod
|
||||
def get_or_create_user_loyalty(db: Session, user_id: int) -> UserLoyalty:
|
||||
"""Get or create loyalty record for user"""
|
||||
user_loyalty = db.query(UserLoyalty).filter(UserLoyalty.user_id == user_id).first()
|
||||
|
||||
if not user_loyalty:
|
||||
# Get bronze tier (lowest tier)
|
||||
bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first()
|
||||
|
||||
if not bronze_tier:
|
||||
# Create default tiers if they don't exist
|
||||
LoyaltyService.create_default_tiers(db)
|
||||
bronze_tier = db.query(LoyaltyTier).filter(LoyaltyTier.level == TierLevel.bronze).first()
|
||||
|
||||
# Generate referral code
|
||||
referral_code = LoyaltyService.generate_referral_code(db, user_id)
|
||||
|
||||
user_loyalty = UserLoyalty(
|
||||
user_id=user_id,
|
||||
tier_id=bronze_tier.id,
|
||||
total_points=0,
|
||||
lifetime_points=0,
|
||||
available_points=0,
|
||||
expired_points=0,
|
||||
referral_code=referral_code,
|
||||
referral_count=0,
|
||||
tier_started_date=datetime.utcnow()
|
||||
)
|
||||
db.add(user_loyalty)
|
||||
db.commit()
|
||||
db.refresh(user_loyalty)
|
||||
|
||||
return user_loyalty
|
||||
|
||||
@staticmethod
|
||||
def generate_referral_code(db: Session, user_id: int, length: int = 8) -> str:
|
||||
"""Generate unique referral code for user"""
|
||||
max_attempts = 10
|
||||
for _ in range(max_attempts):
|
||||
# Generate code: USER1234 format
|
||||
code = f"USER{user_id:04d}{''.join(random.choices(string.ascii_uppercase + string.digits, k=length-8))}"
|
||||
|
||||
# Check if code exists
|
||||
existing = db.query(UserLoyalty).filter(UserLoyalty.referral_code == code).first()
|
||||
if not existing:
|
||||
return code
|
||||
|
||||
# Fallback: timestamp-based
|
||||
return f"REF{int(datetime.utcnow().timestamp())}{user_id}"
|
||||
|
||||
@staticmethod
|
||||
def create_default_tiers(db: Session):
|
||||
"""Create default loyalty tiers if they don't exist"""
|
||||
tiers_data = [
|
||||
{
|
||||
'level': TierLevel.bronze,
|
||||
'name': 'Bronze',
|
||||
'description': 'Starting tier - Earn points on every booking',
|
||||
'min_points': 0,
|
||||
'points_earn_rate': 1.0,
|
||||
'discount_percentage': 0,
|
||||
'benefits': 'Welcome bonus points, Access to basic rewards',
|
||||
'color': '#CD7F32'
|
||||
},
|
||||
{
|
||||
'level': TierLevel.silver,
|
||||
'name': 'Silver',
|
||||
'description': 'Earn points faster with 1.25x multiplier',
|
||||
'min_points': 5000,
|
||||
'points_earn_rate': 1.25,
|
||||
'discount_percentage': 2,
|
||||
'benefits': '25% faster point earning, 2% member discount, Priority customer support',
|
||||
'color': '#C0C0C0'
|
||||
},
|
||||
{
|
||||
'level': TierLevel.gold,
|
||||
'name': 'Gold',
|
||||
'description': 'Premium tier with exclusive benefits',
|
||||
'min_points': 15000,
|
||||
'points_earn_rate': 1.5,
|
||||
'discount_percentage': 5,
|
||||
'benefits': '50% faster point earning, 5% member discount, Room upgrade priority, Exclusive rewards',
|
||||
'color': '#FFD700'
|
||||
},
|
||||
{
|
||||
'level': TierLevel.platinum,
|
||||
'name': 'Platinum',
|
||||
'description': 'Elite tier with maximum benefits',
|
||||
'min_points': 50000,
|
||||
'points_earn_rate': 2.0,
|
||||
'discount_percentage': 10,
|
||||
'benefits': '100% faster point earning, 10% member discount, Guaranteed room upgrades, Concierge service, Birthday & anniversary bonuses',
|
||||
'color': '#E5E4E2'
|
||||
}
|
||||
]
|
||||
|
||||
for tier_data in tiers_data:
|
||||
existing = db.query(LoyaltyTier).filter(LoyaltyTier.level == tier_data['level']).first()
|
||||
if not existing:
|
||||
tier = LoyaltyTier(**tier_data)
|
||||
db.add(tier)
|
||||
|
||||
db.commit()
|
||||
|
||||
@staticmethod
|
||||
def earn_points_from_booking(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
booking: Booking,
|
||||
amount: float
|
||||
) -> int:
|
||||
"""Earn points from completed booking"""
|
||||
try:
|
||||
# Check if loyalty program is enabled
|
||||
if not LoyaltyService.is_loyalty_enabled(db):
|
||||
logger.info("Loyalty program is disabled. Skipping points earning.")
|
||||
return 0
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id)
|
||||
tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first()
|
||||
|
||||
if not tier:
|
||||
logger.error(f"Tier not found for user {user_id}")
|
||||
return 0
|
||||
|
||||
# Calculate base points (points per dollar * amount)
|
||||
base_points = int(amount * LoyaltyService.POINTS_PER_DOLLAR)
|
||||
|
||||
# Apply tier multiplier
|
||||
tier_multiplier = float(tier.points_earn_rate) if tier.points_earn_rate else 1.0
|
||||
points_earned = int(base_points * tier_multiplier)
|
||||
|
||||
# Calculate expiration date
|
||||
expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS)
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=user_loyalty.id,
|
||||
booking_id=booking.id,
|
||||
transaction_type=TransactionType.earned,
|
||||
source=TransactionSource.booking,
|
||||
points=points_earned,
|
||||
description=f"Points earned from booking {booking.booking_number}",
|
||||
expires_at=expires_at,
|
||||
reference_number=booking.booking_number
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Update user loyalty
|
||||
user_loyalty.total_points += points_earned
|
||||
user_loyalty.lifetime_points += points_earned
|
||||
user_loyalty.available_points += points_earned
|
||||
user_loyalty.last_points_earned_date = datetime.utcnow()
|
||||
|
||||
# Check for tier upgrade
|
||||
LoyaltyService.check_and_upgrade_tier(db, user_loyalty)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user_loyalty)
|
||||
|
||||
logger.info(f"User {user_id} earned {points_earned} points from booking {booking.booking_number}")
|
||||
return points_earned
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error earning points from booking: {str(e)}")
|
||||
db.rollback()
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def check_and_upgrade_tier(db: Session, user_loyalty: UserLoyalty):
|
||||
"""Check if user should be upgraded to higher tier"""
|
||||
current_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first()
|
||||
if not current_tier:
|
||||
return
|
||||
|
||||
# Get all tiers ordered by min_points descending
|
||||
all_tiers = db.query(LoyaltyTier).filter(LoyaltyTier.is_active == True).order_by(LoyaltyTier.min_points.desc()).all()
|
||||
|
||||
for tier in all_tiers:
|
||||
if user_loyalty.lifetime_points >= tier.min_points and tier.min_points > current_tier.min_points:
|
||||
# Upgrade to this tier
|
||||
user_loyalty.tier_id = tier.id
|
||||
user_loyalty.tier_started_date = datetime.utcnow()
|
||||
|
||||
# Calculate next tier points
|
||||
next_tier = LoyaltyService.get_next_tier(db, tier)
|
||||
if next_tier:
|
||||
user_loyalty.next_tier_points_needed = next_tier.min_points - user_loyalty.lifetime_points
|
||||
else:
|
||||
user_loyalty.next_tier_points_needed = None
|
||||
|
||||
logger.info(f"User {user_loyalty.user_id} upgraded to {tier.name} tier")
|
||||
break
|
||||
|
||||
@staticmethod
|
||||
def get_next_tier(db: Session, current_tier: LoyaltyTier) -> Optional[LoyaltyTier]:
|
||||
"""Get next tier above current tier"""
|
||||
all_tiers = db.query(LoyaltyTier).filter(
|
||||
LoyaltyTier.is_active == True,
|
||||
LoyaltyTier.min_points > current_tier.min_points
|
||||
).order_by(LoyaltyTier.min_points.asc()).first()
|
||||
|
||||
return all_tiers
|
||||
|
||||
@staticmethod
|
||||
def redeem_points(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
reward_id: int,
|
||||
booking_id: Optional[int] = None
|
||||
) -> Optional[RewardRedemption]:
|
||||
"""Redeem points for a reward"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id)
|
||||
reward = db.query(LoyaltyReward).filter(LoyaltyReward.id == reward_id).first()
|
||||
|
||||
if not reward:
|
||||
raise ValueError("Reward not found")
|
||||
|
||||
# Get user's tier info for better availability checking
|
||||
user_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == user_loyalty.tier_id).first()
|
||||
user_tier_min_points = user_tier.min_points if user_tier else None
|
||||
|
||||
# If reward requires a specific tier, pre-load tier info for comparison
|
||||
if reward.applicable_tier_id:
|
||||
required_tier = db.query(LoyaltyTier).filter(LoyaltyTier.id == reward.applicable_tier_id).first()
|
||||
if required_tier:
|
||||
reward._required_tier_min_points = required_tier.min_points
|
||||
|
||||
if not reward.is_available(user_loyalty.tier_id, user_tier_min_points):
|
||||
# Log detailed reason for debugging
|
||||
logger.warning(
|
||||
f"Reward {reward.id} not available for user {user_id}: "
|
||||
f"reward_tier={reward.applicable_tier_id}, user_tier={user_loyalty.tier_id}, "
|
||||
f"user_tier_points={user_tier_min_points}, reward_active={reward.is_active}, "
|
||||
f"stock={reward.redeemed_count}/{reward.stock_quantity if reward.stock_quantity else 'unlimited'}"
|
||||
)
|
||||
if not reward.is_active:
|
||||
raise ValueError("Reward is not active")
|
||||
if reward.applicable_tier_id and user_loyalty.tier_id != reward.applicable_tier_id:
|
||||
raise ValueError(f"Reward is only available for specific tier")
|
||||
if reward.valid_until and datetime.utcnow() > reward.valid_until:
|
||||
raise ValueError("Reward has expired")
|
||||
if reward.stock_quantity is not None and reward.redeemed_count >= reward.stock_quantity:
|
||||
raise ValueError("Reward is out of stock")
|
||||
raise ValueError("Reward is not available for your tier or has expired")
|
||||
|
||||
if user_loyalty.available_points < reward.points_cost:
|
||||
raise ValueError(
|
||||
f"Insufficient points. Required: {reward.points_cost}, Available: {user_loyalty.available_points}"
|
||||
)
|
||||
|
||||
# Generate redemption code
|
||||
redemption_code = LoyaltyService.generate_redemption_code(db)
|
||||
|
||||
# Create redemption
|
||||
redemption = RewardRedemption(
|
||||
user_loyalty_id=user_loyalty.id,
|
||||
reward_id=reward.id,
|
||||
booking_id=booking_id,
|
||||
points_used=reward.points_cost,
|
||||
status=RedemptionStatus.active,
|
||||
code=redemption_code,
|
||||
expires_at=datetime.utcnow() + timedelta(days=365) if reward.reward_type == 'voucher' else None
|
||||
)
|
||||
db.add(redemption)
|
||||
|
||||
# Deduct points
|
||||
user_loyalty.available_points -= reward.points_cost
|
||||
user_loyalty.total_points -= reward.points_cost
|
||||
|
||||
# Create transaction
|
||||
transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=user_loyalty.id,
|
||||
booking_id=booking_id,
|
||||
transaction_type=TransactionType.redeemed,
|
||||
source=TransactionSource.redemption,
|
||||
points=-reward.points_cost,
|
||||
description=f"Points redeemed for {reward.name}",
|
||||
reference_number=redemption_code
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
# Update reward redemption count
|
||||
reward.redeemed_count += 1
|
||||
|
||||
db.commit()
|
||||
db.refresh(redemption)
|
||||
|
||||
logger.info(f"User {user_id} redeemed {reward.points_cost} points for reward {reward.name}")
|
||||
return redemption
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error redeeming points: {str(e)}")
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def generate_redemption_code(db: Session, length: int = 12) -> str:
|
||||
"""Generate unique redemption code"""
|
||||
max_attempts = 10
|
||||
for _ in range(max_attempts):
|
||||
code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=length))
|
||||
existing = db.query(RewardRedemption).filter(RewardRedemption.code == code).first()
|
||||
if not existing:
|
||||
return code
|
||||
return f"RED{int(datetime.utcnow().timestamp())}"
|
||||
|
||||
@staticmethod
|
||||
def process_referral(
|
||||
db: Session,
|
||||
referred_user_id: int,
|
||||
referral_code: str,
|
||||
booking_id: Optional[int] = None
|
||||
):
|
||||
"""Process referral when new user books with referral code"""
|
||||
try:
|
||||
# Find referrer
|
||||
referrer_loyalty = db.query(UserLoyalty).filter(
|
||||
UserLoyalty.referral_code == referral_code
|
||||
).first()
|
||||
|
||||
if not referrer_loyalty:
|
||||
logger.warning(f"Invalid referral code: {referral_code}")
|
||||
return
|
||||
|
||||
if referrer_loyalty.user_id == referred_user_id:
|
||||
logger.warning(f"User cannot refer themselves")
|
||||
return
|
||||
|
||||
# Check if referral already exists
|
||||
existing_referral = db.query(Referral).filter(
|
||||
Referral.referrer_id == referrer_loyalty.id,
|
||||
Referral.referred_user_id == referred_user_id
|
||||
).first()
|
||||
|
||||
if existing_referral and existing_referral.status == ReferralStatus.rewarded:
|
||||
logger.info(f"Referral already processed for user {referred_user_id}")
|
||||
return
|
||||
|
||||
# Create or update referral
|
||||
if not existing_referral:
|
||||
referral = Referral(
|
||||
referrer_id=referrer_loyalty.id,
|
||||
referred_user_id=referred_user_id,
|
||||
referral_code=referral_code,
|
||||
booking_id=booking_id,
|
||||
status=ReferralStatus.pending
|
||||
)
|
||||
db.add(referral)
|
||||
else:
|
||||
referral = existing_referral
|
||||
referral.booking_id = booking_id
|
||||
|
||||
# Award points when booking is completed
|
||||
if booking_id:
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if booking and booking.status == BookingStatus.confirmed:
|
||||
# Award referrer points
|
||||
expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS)
|
||||
|
||||
referrer_transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=referrer_loyalty.id,
|
||||
transaction_type=TransactionType.earned,
|
||||
source=TransactionSource.referral,
|
||||
points=LoyaltyService.REFERRER_POINTS,
|
||||
description=f"Referral bonus for user {referred_user_id}",
|
||||
expires_at=expires_at,
|
||||
reference_number=referral_code
|
||||
)
|
||||
db.add(referrer_transaction)
|
||||
|
||||
referrer_loyalty.total_points += LoyaltyService.REFERRER_POINTS
|
||||
referrer_loyalty.lifetime_points += LoyaltyService.REFERRER_POINTS
|
||||
referrer_loyalty.available_points += LoyaltyService.REFERRER_POINTS
|
||||
referrer_loyalty.referral_count += 1
|
||||
referral.referrer_points_earned = LoyaltyService.REFERRER_POINTS
|
||||
|
||||
# Award referred user bonus points
|
||||
referred_loyalty = LoyaltyService.get_or_create_user_loyalty(db, referred_user_id)
|
||||
|
||||
referred_transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=referred_loyalty.id,
|
||||
transaction_type=TransactionType.bonus,
|
||||
source=TransactionSource.referral,
|
||||
points=LoyaltyService.REFERRED_BONUS_POINTS,
|
||||
description=f"Welcome bonus for using referral code",
|
||||
expires_at=expires_at,
|
||||
reference_number=referral_code
|
||||
)
|
||||
db.add(referred_transaction)
|
||||
|
||||
referred_loyalty.total_points += LoyaltyService.REFERRED_BONUS_POINTS
|
||||
referred_loyalty.lifetime_points += LoyaltyService.REFERRED_BONUS_POINTS
|
||||
referred_loyalty.available_points += LoyaltyService.REFERRED_BONUS_POINTS
|
||||
referral.referred_points_earned = LoyaltyService.REFERRED_BONUS_POINTS
|
||||
|
||||
referral.status = ReferralStatus.rewarded
|
||||
referral.completed_at = datetime.utcnow()
|
||||
referral.rewarded_at = datetime.utcnow()
|
||||
else:
|
||||
referral.status = ReferralStatus.completed
|
||||
referral.completed_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Referral processed: referrer {referrer_loyalty.user_id} referred user {referred_user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing referral: {str(e)}")
|
||||
db.rollback()
|
||||
|
||||
@staticmethod
|
||||
def check_birthday_reward(db: Session, user_id: int, prevent_recent_update: bool = False):
|
||||
"""Check and award birthday bonus points
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID to check
|
||||
prevent_recent_update: If True, prevents reward if birthday was updated recently (within 365 days)
|
||||
"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
return
|
||||
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id)
|
||||
|
||||
if not user_loyalty.birthday:
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
birthday = user_loyalty.birthday
|
||||
|
||||
# Prevent abuse: Don't award if birthday was updated within last 365 days (once per year only)
|
||||
if prevent_recent_update and user_loyalty.updated_at:
|
||||
days_since_update = (datetime.utcnow() - user_loyalty.updated_at).days
|
||||
if days_since_update < 365:
|
||||
logger.warning(f"Birthday reward blocked for user {user_id}: birthday updated {days_since_update} days ago (minimum 365 days required - once per year only)")
|
||||
return
|
||||
|
||||
# Check if it's user's birthday
|
||||
if today.month == birthday.month and today.day == birthday.day:
|
||||
# Check if already awarded THIS YEAR (fix: check from Jan 1 of current year to now)
|
||||
year_start = datetime(today.year, 1, 1)
|
||||
now = datetime.utcnow()
|
||||
|
||||
existing_birthday = db.query(LoyaltyPointTransaction).filter(
|
||||
LoyaltyPointTransaction.user_loyalty_id == user_loyalty.id,
|
||||
LoyaltyPointTransaction.source == TransactionSource.birthday,
|
||||
LoyaltyPointTransaction.created_at >= year_start,
|
||||
LoyaltyPointTransaction.created_at <= now
|
||||
).first()
|
||||
|
||||
if not existing_birthday:
|
||||
# Award birthday bonus
|
||||
expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS)
|
||||
|
||||
transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=user_loyalty.id,
|
||||
transaction_type=TransactionType.bonus,
|
||||
source=TransactionSource.birthday,
|
||||
points=LoyaltyService.BIRTHDAY_BONUS_POINTS,
|
||||
description=f"Birthday bonus - Happy Birthday!",
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user_loyalty.total_points += LoyaltyService.BIRTHDAY_BONUS_POINTS
|
||||
user_loyalty.lifetime_points += LoyaltyService.BIRTHDAY_BONUS_POINTS
|
||||
user_loyalty.available_points += LoyaltyService.BIRTHDAY_BONUS_POINTS
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Birthday bonus awarded to user {user_id}")
|
||||
else:
|
||||
logger.info(f"Birthday reward already awarded this year for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking birthday reward: {str(e)}")
|
||||
db.rollback()
|
||||
|
||||
@staticmethod
|
||||
def check_anniversary_reward(db: Session, user_id: int, prevent_recent_update: bool = False):
|
||||
"""Check and award anniversary bonus points
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
user_id: User ID to check
|
||||
prevent_recent_update: If True, prevents reward if anniversary was updated recently (within 365 days)
|
||||
"""
|
||||
try:
|
||||
user_loyalty = LoyaltyService.get_or_create_user_loyalty(db, user_id)
|
||||
|
||||
if not user_loyalty.anniversary_date:
|
||||
return
|
||||
|
||||
today = date.today()
|
||||
anniversary = user_loyalty.anniversary_date
|
||||
|
||||
# Prevent abuse: Don't award if anniversary was updated within last 365 days (once per year only)
|
||||
if prevent_recent_update and user_loyalty.updated_at:
|
||||
days_since_update = (datetime.utcnow() - user_loyalty.updated_at).days
|
||||
if days_since_update < 365:
|
||||
logger.warning(f"Anniversary reward blocked for user {user_id}: anniversary updated {days_since_update} days ago (minimum 365 days required - once per year only)")
|
||||
return
|
||||
|
||||
# Check if it's anniversary
|
||||
if today.month == anniversary.month and today.day == anniversary.day:
|
||||
# Check if already awarded THIS YEAR (fix: check from Jan 1 of current year to now)
|
||||
year_start = datetime(today.year, 1, 1)
|
||||
now = datetime.utcnow()
|
||||
|
||||
existing_anniversary = db.query(LoyaltyPointTransaction).filter(
|
||||
LoyaltyPointTransaction.user_loyalty_id == user_loyalty.id,
|
||||
LoyaltyPointTransaction.source == TransactionSource.anniversary,
|
||||
LoyaltyPointTransaction.created_at >= year_start,
|
||||
LoyaltyPointTransaction.created_at <= now
|
||||
).first()
|
||||
|
||||
if not existing_anniversary:
|
||||
# Award anniversary bonus
|
||||
expires_at = datetime.utcnow() + timedelta(days=LoyaltyService.POINTS_EXPIRATION_DAYS)
|
||||
|
||||
transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=user_loyalty.id,
|
||||
transaction_type=TransactionType.bonus,
|
||||
source=TransactionSource.anniversary,
|
||||
points=LoyaltyService.ANNIVERSARY_BONUS_POINTS,
|
||||
description=f"Anniversary bonus - Thank you for your loyalty!",
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.add(transaction)
|
||||
|
||||
user_loyalty.total_points += LoyaltyService.ANNIVERSARY_BONUS_POINTS
|
||||
user_loyalty.lifetime_points += LoyaltyService.ANNIVERSARY_BONUS_POINTS
|
||||
user_loyalty.available_points += LoyaltyService.ANNIVERSARY_BONUS_POINTS
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Anniversary bonus awarded to user {user_id}")
|
||||
else:
|
||||
logger.info(f"Anniversary reward already awarded this year for user {user_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking anniversary reward: {str(e)}")
|
||||
db.rollback()
|
||||
|
||||
@staticmethod
|
||||
def expire_points(db: Session):
|
||||
"""Expire points that have passed expiration date"""
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Find all expired point transactions
|
||||
expired_transactions = db.query(LoyaltyPointTransaction).filter(
|
||||
LoyaltyPointTransaction.transaction_type == TransactionType.earned,
|
||||
LoyaltyPointTransaction.points > 0,
|
||||
LoyaltyPointTransaction.expires_at.isnot(None),
|
||||
LoyaltyPointTransaction.expires_at < now
|
||||
).all()
|
||||
|
||||
for transaction in expired_transactions:
|
||||
# Check if already expired
|
||||
if transaction.description and 'expired' in transaction.description.lower():
|
||||
continue
|
||||
|
||||
user_loyalty = transaction.user_loyalty
|
||||
|
||||
# Create expiration transaction
|
||||
expiration_transaction = LoyaltyPointTransaction(
|
||||
user_loyalty_id=user_loyalty.id,
|
||||
transaction_type=TransactionType.expired,
|
||||
source=transaction.source,
|
||||
points=-transaction.points,
|
||||
description=f"Points expired from {transaction.description or 'earned points'}",
|
||||
reference_number=transaction.reference_number
|
||||
)
|
||||
db.add(expiration_transaction)
|
||||
|
||||
# Update user loyalty
|
||||
user_loyalty.total_points -= transaction.points
|
||||
user_loyalty.available_points -= transaction.points
|
||||
user_loyalty.expired_points += transaction.points
|
||||
|
||||
# Mark transaction as expired
|
||||
transaction.description = f"{transaction.description or ''} [EXPIRED]"
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Expired {len(expired_transactions)} point transactions")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error expiring points: {str(e)}")
|
||||
db.rollback()
|
||||
|
||||
Reference in New Issue
Block a user