This commit is contained in:
Iliyan Angelov
2025-11-21 22:40:44 +02:00
parent 9842cc3a4a
commit be07802066
60 changed files with 8189 additions and 9 deletions

View 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')

View 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')

View 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()

View File

@@ -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')

View File

@@ -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']

View 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')

View 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])

View 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')

View 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')

View 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')

View 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])

View 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

View 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')

View 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])

View 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])

View File

@@ -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')

View 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')

View File

@@ -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

View 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))

View 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))

View File

@@ -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

View 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
}

View 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()