update
This commit is contained in:
0
Backend/src/guest_management/__init__.py
Normal file
0
Backend/src/guest_management/__init__.py
Normal file
0
Backend/src/guest_management/models/__init__.py
Normal file
0
Backend/src/guest_management/models/__init__.py
Normal file
36
Backend/src/guest_management/models/guest_communication.py
Normal file
36
Backend/src/guest_management/models/guest_communication.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...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')
|
||||
|
||||
19
Backend/src/guest_management/models/guest_note.py
Normal file
19
Backend/src/guest_management/models/guest_note.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...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])
|
||||
|
||||
40
Backend/src/guest_management/models/guest_preference.py
Normal file
40
Backend/src/guest_management/models/guest_preference.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, JSON, ForeignKey, DateTime, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...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')
|
||||
|
||||
26
Backend/src/guest_management/models/guest_segment.py
Normal file
26
Backend/src/guest_management/models/guest_segment.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean, JSON, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...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')
|
||||
|
||||
24
Backend/src/guest_management/models/guest_tag.py
Normal file
24
Backend/src/guest_management/models/guest_tag.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, Table
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ...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')
|
||||
|
||||
0
Backend/src/guest_management/routes/__init__.py
Normal file
0
Backend/src/guest_management/routes/__init__.py
Normal file
566
Backend/src/guest_management/routes/guest_profile_routes.py
Normal file
566
Backend/src/guest_management/routes/guest_profile_routes.py
Normal 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))
|
||||
|
||||
0
Backend/src/guest_management/schemas/__init__.py
Normal file
0
Backend/src/guest_management/schemas/__init__.py
Normal file
0
Backend/src/guest_management/services/__init__.py
Normal file
0
Backend/src/guest_management/services/__init__.py
Normal file
295
Backend/src/guest_management/services/guest_profile_service.py
Normal file
295
Backend/src/guest_management/services/guest_profile_service.py
Normal 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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user