This commit is contained in:
Iliyan Angelov
2025-11-30 22:43:09 +02:00
parent 24b40450dd
commit 39fcfff811
1610 changed files with 5442 additions and 1383 deletions

View File

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 ...shared.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 ...shared.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 ...shared.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 ...shared.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 ...shared.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,566 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from ...shared.config.database import get_db
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.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
from ...shared.utils.role_helpers import is_customer
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
from ...shared.utils.role_helpers import is_customer
if not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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).first()
if not user or not is_customer(user, db):
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,295 @@
from sqlalchemy.orm import Session, load_only
from sqlalchemy import func, and_, or_, desc
from typing import List, Dict, Optional
from datetime import datetime, timedelta
from decimal import Decimal
from ...auth.models.user import User
from ...bookings.models.booking import Booking, BookingStatus
from ...payments.models.payment import Payment
from ...reviews.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 ...payments.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 ...hotel_services.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"""
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
query = db.query(Booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
)
).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"""
# Use func.count with load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
# This avoids SQLAlchemy generating subqueries with all columns
total_bookings = db.query(func.count(Booking.id)).filter(Booking.user_id == user_id).scalar() or 0
completed_bookings = db.query(func.count(Booking.id)).filter(
Booking.user_id == user_id,
Booking.status == BookingStatus.checked_out
).scalar() or 0
cancelled_bookings = db.query(func.count(Booking.id)).filter(
Booking.user_id == user_id,
Booking.status == BookingStatus.cancelled
).scalar() or 0
# Get last visit date
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
last_booking = db.query(Booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
)
).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
# Aggregate queries don't need load_only as they don't load full objects
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
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
bookings = db.query(Booking).options(
load_only(
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
Booking.total_price, Booking.original_price, Booking.discount_amount,
Booking.promotion_code, Booking.status, Booking.deposit_paid,
Booking.requires_deposit, Booking.special_requests,
Booking.created_at, Booking.updated_at
)
).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
}