601 lines
24 KiB
Python
601 lines
24 KiB
Python
"""
|
|
Routes for guest complaint management.
|
|
"""
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import and_, or_, func
|
|
from typing import Optional
|
|
from datetime import datetime
|
|
from ...shared.config.database import get_db
|
|
from ...shared.config.logging_config import get_logger
|
|
from ...security.middleware.auth import get_current_user, authorize_roles
|
|
from ...auth.models.user import User
|
|
from ..models.guest_complaint import (
|
|
GuestComplaint, ComplaintStatus, ComplaintPriority, ComplaintCategory, ComplaintUpdate
|
|
)
|
|
from ..schemas.complaint import (
|
|
CreateComplaintRequest, UpdateComplaintRequest,
|
|
AddComplaintUpdateRequest, ResolveComplaintRequest
|
|
)
|
|
from ...shared.utils.response_helpers import success_response
|
|
from ...analytics.services.audit_service import audit_service
|
|
|
|
logger = get_logger(__name__)
|
|
router = APIRouter(prefix='/complaints', tags=['complaints'])
|
|
|
|
|
|
@router.post('/')
|
|
async def create_complaint(
|
|
complaint_data: CreateComplaintRequest,
|
|
request: Request,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Create a new guest complaint."""
|
|
client_ip = request.client.host if request.client else None
|
|
user_agent = request.headers.get('User-Agent')
|
|
request_id = getattr(request.state, 'request_id', None)
|
|
|
|
try:
|
|
# Verify booking ownership if booking_id provided
|
|
if complaint_data.booking_id:
|
|
from ...bookings.models.booking import Booking
|
|
booking = db.query(Booking).filter(Booking.id == complaint_data.booking_id).first()
|
|
if not booking:
|
|
db.rollback()
|
|
raise HTTPException(status_code=404, detail='Booking not found')
|
|
|
|
# Check if user owns the booking (unless admin/staff)
|
|
from ...shared.utils.role_helpers import is_admin, is_staff
|
|
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
|
if booking.user_id != current_user.id:
|
|
db.rollback()
|
|
raise HTTPException(status_code=403, detail='Access denied')
|
|
|
|
complaint = GuestComplaint(
|
|
guest_id=current_user.id,
|
|
booking_id=complaint_data.booking_id,
|
|
room_id=complaint_data.room_id,
|
|
category=ComplaintCategory(complaint_data.category),
|
|
priority=ComplaintPriority(complaint_data.priority),
|
|
status=ComplaintStatus.open,
|
|
title=complaint_data.title,
|
|
description=complaint_data.description,
|
|
attachments=complaint_data.attachments or []
|
|
)
|
|
|
|
db.add(complaint)
|
|
db.flush()
|
|
|
|
# Create initial update
|
|
update = ComplaintUpdate(
|
|
complaint_id=complaint.id,
|
|
update_type='status_change',
|
|
description=f'Complaint created: {complaint_data.title}',
|
|
updated_by=current_user.id,
|
|
update_metadata={'status': 'open', 'priority': complaint_data.priority}
|
|
)
|
|
db.add(update)
|
|
|
|
db.commit()
|
|
db.refresh(complaint)
|
|
|
|
# SECURITY: Log complaint creation for audit trail
|
|
try:
|
|
await audit_service.log_action(
|
|
db=db,
|
|
action='complaint_created',
|
|
resource_type='complaint',
|
|
user_id=current_user.id,
|
|
resource_id=complaint.id,
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
request_id=request_id,
|
|
details={
|
|
'complaint_id': complaint.id,
|
|
'title': complaint.title,
|
|
'category': complaint.category.value,
|
|
'priority': complaint.priority.value,
|
|
'status': complaint.status.value,
|
|
'booking_id': complaint.booking_id,
|
|
'room_id': complaint.room_id
|
|
},
|
|
status='success'
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f'Failed to log complaint creation audit: {e}')
|
|
|
|
return success_response(
|
|
data={'complaint': {
|
|
'id': complaint.id,
|
|
'title': complaint.title,
|
|
'status': complaint.status.value,
|
|
'priority': complaint.priority.value,
|
|
'category': complaint.category.value,
|
|
'created_at': complaint.created_at.isoformat()
|
|
}},
|
|
message='Complaint created successfully'
|
|
)
|
|
except HTTPException:
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f'Error creating complaint: {str(e)}', exc_info=True)
|
|
raise HTTPException(status_code=500, detail='An error occurred while creating the complaint')
|
|
|
|
|
|
@router.get('/')
|
|
async def get_complaints(
|
|
status: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
category: Optional[str] = Query(None),
|
|
assigned_to: Optional[int] = Query(None),
|
|
page: int = Query(1, ge=1),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get complaints with filtering."""
|
|
try:
|
|
from ...shared.utils.role_helpers import is_admin, is_staff
|
|
|
|
query = db.query(GuestComplaint)
|
|
|
|
# Filter by user role
|
|
try:
|
|
is_admin_user = is_admin(current_user, db)
|
|
is_staff_user = is_staff(current_user, db)
|
|
except Exception as role_error:
|
|
logger.warning(f'Error checking user role: {str(role_error)}')
|
|
is_admin_user = False
|
|
is_staff_user = False
|
|
|
|
if not (is_admin_user or is_staff_user):
|
|
# Customers can only see their own complaints
|
|
query = query.filter(GuestComplaint.guest_id == current_user.id)
|
|
elif assigned_to:
|
|
# Staff/admin can filter by assignee
|
|
query = query.filter(GuestComplaint.assigned_to == assigned_to)
|
|
|
|
from sqlalchemy.orm import joinedload
|
|
from ...rooms.models.room import Room
|
|
|
|
# Eager load relationships
|
|
query = query.options(
|
|
joinedload(GuestComplaint.guest),
|
|
joinedload(GuestComplaint.assignee)
|
|
)
|
|
|
|
# Apply filters
|
|
if status:
|
|
try:
|
|
query = query.filter(GuestComplaint.status == ComplaintStatus(status))
|
|
except ValueError:
|
|
logger.warning(f'Invalid status filter: {status}')
|
|
if priority:
|
|
try:
|
|
query = query.filter(GuestComplaint.priority == ComplaintPriority(priority))
|
|
except ValueError:
|
|
logger.warning(f'Invalid priority filter: {priority}')
|
|
if category:
|
|
try:
|
|
query = query.filter(GuestComplaint.category == ComplaintCategory(category))
|
|
except ValueError:
|
|
logger.warning(f'Invalid category filter: {category}')
|
|
|
|
# Pagination
|
|
total = query.count()
|
|
offset = (page - 1) * limit
|
|
complaints = query.order_by(GuestComplaint.created_at.desc()).offset(offset).limit(limit).all()
|
|
|
|
complaints_data = []
|
|
for complaint in complaints:
|
|
# Get room number if room_id exists
|
|
room_number = None
|
|
if complaint.room_id:
|
|
room = db.query(Room).filter(Room.id == complaint.room_id).first()
|
|
if room:
|
|
room_number = room.room_number
|
|
|
|
complaints_data.append({
|
|
'id': complaint.id,
|
|
'title': complaint.title,
|
|
'description': complaint.description,
|
|
'category': complaint.category.value,
|
|
'priority': complaint.priority.value,
|
|
'status': complaint.status.value,
|
|
'guest_id': complaint.guest_id,
|
|
'guest_name': complaint.guest.full_name if complaint.guest else None,
|
|
'booking_id': complaint.booking_id,
|
|
'room_id': complaint.room_id,
|
|
'room_number': room_number,
|
|
'assigned_to': complaint.assigned_to,
|
|
'assigned_staff_name': complaint.assignee.full_name if complaint.assignee else None,
|
|
'resolution_notes': complaint.resolution,
|
|
'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
|
|
'created_at': complaint.created_at.isoformat(),
|
|
'updated_at': complaint.updated_at.isoformat()
|
|
})
|
|
|
|
return success_response(
|
|
data={
|
|
'complaints': complaints_data,
|
|
'pagination': {
|
|
'page': page,
|
|
'limit': limit,
|
|
'total': total,
|
|
'total_pages': (total + limit - 1) // limit
|
|
}
|
|
},
|
|
message='Complaints retrieved successfully'
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f'Error retrieving complaints: {str(e)}', exc_info=True)
|
|
raise HTTPException(status_code=500, detail=f'An error occurred while retrieving complaints: {str(e)}')
|
|
|
|
|
|
@router.get('/{complaint_id}')
|
|
async def get_complaint(
|
|
complaint_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Get a specific complaint with details."""
|
|
try:
|
|
from sqlalchemy.orm import joinedload
|
|
from ...rooms.models.room import Room
|
|
|
|
complaint = db.query(GuestComplaint).options(
|
|
joinedload(GuestComplaint.guest),
|
|
joinedload(GuestComplaint.assignee),
|
|
joinedload(GuestComplaint.room)
|
|
).filter(GuestComplaint.id == complaint_id).first()
|
|
if not complaint:
|
|
raise HTTPException(status_code=404, detail='Complaint not found')
|
|
|
|
# Check access
|
|
from ...shared.utils.role_helpers import is_admin, is_staff
|
|
is_admin_user = is_admin(current_user, db)
|
|
is_staff_user = is_staff(current_user, db)
|
|
if not (is_admin_user or is_staff_user):
|
|
if complaint.guest_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail='Access denied')
|
|
|
|
# Get updates
|
|
updates = db.query(ComplaintUpdate).options(
|
|
joinedload(ComplaintUpdate.updater)
|
|
).filter(
|
|
ComplaintUpdate.complaint_id == complaint_id
|
|
).order_by(ComplaintUpdate.created_at.asc()).all()
|
|
|
|
# Get room number
|
|
room_number = None
|
|
if complaint.room:
|
|
room_number = complaint.room.room_number
|
|
|
|
complaint_data = {
|
|
'id': complaint.id,
|
|
'title': complaint.title,
|
|
'description': complaint.description,
|
|
'category': complaint.category.value,
|
|
'priority': complaint.priority.value,
|
|
'status': complaint.status.value,
|
|
'guest_id': complaint.guest_id,
|
|
'guest_name': complaint.guest.full_name if complaint.guest else None,
|
|
'booking_id': complaint.booking_id,
|
|
'room_id': complaint.room_id,
|
|
'room_number': room_number,
|
|
'assigned_to': complaint.assigned_to,
|
|
'assigned_staff_name': complaint.assignee.full_name if complaint.assignee else None,
|
|
'resolution_notes': complaint.resolution,
|
|
'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
|
|
'resolved_by': complaint.resolved_by,
|
|
'guest_satisfaction_rating': complaint.guest_satisfaction_rating,
|
|
'guest_feedback': complaint.guest_feedback,
|
|
'internal_notes': complaint.internal_notes if (is_admin_user or is_staff_user) else None,
|
|
'attachments': complaint.attachments,
|
|
'requires_follow_up': complaint.requires_follow_up,
|
|
'follow_up_date': complaint.follow_up_date.isoformat() if complaint.follow_up_date else None,
|
|
'created_at': complaint.created_at.isoformat(),
|
|
'updated_at': complaint.updated_at.isoformat(),
|
|
'updates': [{
|
|
'id': u.id,
|
|
'update_type': u.update_type,
|
|
'description': u.description,
|
|
'updated_by': u.updated_by,
|
|
'updated_by_name': u.updater.full_name if u.updater else None,
|
|
'created_at': u.created_at.isoformat()
|
|
} for u in updates]
|
|
}
|
|
|
|
return success_response(
|
|
data={'complaint': complaint_data},
|
|
message='Complaint retrieved successfully'
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f'Error retrieving complaint: {str(e)}', exc_info=True)
|
|
raise HTTPException(status_code=500, detail='An error occurred while retrieving complaint')
|
|
|
|
|
|
@router.put('/{complaint_id}')
|
|
async def update_complaint(
|
|
complaint_id: int,
|
|
update_data: UpdateComplaintRequest,
|
|
request: Request,
|
|
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Update a complaint (admin/staff only)."""
|
|
client_ip = request.client.host if request.client else None
|
|
user_agent = request.headers.get('User-Agent')
|
|
request_id = getattr(request.state, 'request_id', None)
|
|
|
|
try:
|
|
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
|
if not complaint:
|
|
db.rollback()
|
|
raise HTTPException(status_code=404, detail='Complaint not found')
|
|
|
|
# Track changes for audit
|
|
old_values = {
|
|
'status': complaint.status.value,
|
|
'priority': complaint.priority.value,
|
|
'assigned_to': complaint.assigned_to
|
|
}
|
|
changes = []
|
|
|
|
if update_data.status:
|
|
old_status = complaint.status.value
|
|
complaint.status = ComplaintStatus(update_data.status)
|
|
changes.append(f'Status changed from {old_status} to {update_data.status}')
|
|
|
|
if update_data.status == 'resolved' and not complaint.resolved_at:
|
|
complaint.resolved_at = datetime.utcnow()
|
|
complaint.resolved_by = current_user.id
|
|
|
|
if update_data.priority:
|
|
old_priority = complaint.priority.value
|
|
complaint.priority = ComplaintPriority(update_data.priority)
|
|
changes.append(f'Priority changed from {old_priority} to {update_data.priority}')
|
|
|
|
if update_data.assigned_to is not None:
|
|
old_assignee = complaint.assigned_to
|
|
complaint.assigned_to = update_data.assigned_to
|
|
changes.append(f'Assigned to user {update_data.assigned_to}')
|
|
|
|
if update_data.resolution:
|
|
complaint.resolution = update_data.resolution
|
|
|
|
if update_data.internal_notes is not None:
|
|
complaint.internal_notes = update_data.internal_notes
|
|
|
|
if update_data.requires_follow_up is not None:
|
|
complaint.requires_follow_up = update_data.requires_follow_up
|
|
|
|
if update_data.follow_up_date:
|
|
complaint.follow_up_date = datetime.fromisoformat(update_data.follow_up_date.replace('Z', '+00:00'))
|
|
|
|
# Create update record
|
|
if changes:
|
|
update = ComplaintUpdate(
|
|
complaint_id=complaint.id,
|
|
update_type='status_change' if update_data.status else 'note',
|
|
description='; '.join(changes),
|
|
updated_by=current_user.id
|
|
)
|
|
db.add(update)
|
|
|
|
db.commit()
|
|
|
|
# SECURITY: Log complaint status change for audit trail
|
|
if update_data.status and old_values['status'] != update_data.status:
|
|
try:
|
|
await audit_service.log_action(
|
|
db=db,
|
|
action='complaint_status_changed',
|
|
resource_type='complaint',
|
|
user_id=current_user.id,
|
|
resource_id=complaint.id,
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
request_id=request_id,
|
|
details={
|
|
'complaint_id': complaint.id,
|
|
'old_status': old_values['status'],
|
|
'new_status': update_data.status,
|
|
'guest_id': complaint.guest_id,
|
|
'resolved_by': current_user.id if update_data.status == 'resolved' else None
|
|
},
|
|
status='success'
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f'Failed to log complaint status change audit: {e}')
|
|
|
|
# SECURITY: Log complaint assignment change for audit trail
|
|
if update_data.assigned_to is not None and old_values['assigned_to'] != update_data.assigned_to:
|
|
try:
|
|
await audit_service.log_action(
|
|
db=db,
|
|
action='complaint_assigned',
|
|
resource_type='complaint',
|
|
user_id=current_user.id,
|
|
resource_id=complaint.id,
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
request_id=request_id,
|
|
details={
|
|
'complaint_id': complaint.id,
|
|
'old_assigned_to': old_values['assigned_to'],
|
|
'new_assigned_to': update_data.assigned_to,
|
|
'guest_id': complaint.guest_id
|
|
},
|
|
status='success'
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f'Failed to log complaint assignment audit: {e}')
|
|
db.refresh(complaint)
|
|
|
|
return success_response(
|
|
data={'complaint': {
|
|
'id': complaint.id,
|
|
'status': complaint.status.value,
|
|
'priority': complaint.priority.value,
|
|
'assigned_to': complaint.assigned_to
|
|
}},
|
|
message='Complaint updated successfully'
|
|
)
|
|
except HTTPException:
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f'Error updating complaint: {str(e)}', exc_info=True)
|
|
raise HTTPException(status_code=500, detail='An error occurred while updating complaint')
|
|
|
|
|
|
@router.post('/{complaint_id}/resolve')
|
|
async def resolve_complaint(
|
|
complaint_id: int,
|
|
resolve_data: ResolveComplaintRequest,
|
|
request: Request,
|
|
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Resolve a complaint."""
|
|
client_ip = request.client.host if request.client else None
|
|
user_agent = request.headers.get('User-Agent')
|
|
request_id = getattr(request.state, 'request_id', None)
|
|
|
|
try:
|
|
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
|
if not complaint:
|
|
db.rollback()
|
|
raise HTTPException(status_code=404, detail='Complaint not found')
|
|
|
|
old_status = complaint.status.value
|
|
|
|
complaint.status = ComplaintStatus.resolved
|
|
complaint.resolution = resolve_data.resolution
|
|
complaint.resolved_at = datetime.utcnow()
|
|
complaint.resolved_by = current_user.id
|
|
|
|
if resolve_data.guest_satisfaction_rating:
|
|
complaint.guest_satisfaction_rating = resolve_data.guest_satisfaction_rating
|
|
if resolve_data.guest_feedback:
|
|
complaint.guest_feedback = resolve_data.guest_feedback
|
|
|
|
# Create update record
|
|
update = ComplaintUpdate(
|
|
complaint_id=complaint.id,
|
|
update_type='resolution',
|
|
description=f'Complaint resolved: {resolve_data.resolution}',
|
|
updated_by=current_user.id,
|
|
update_metadata={
|
|
'satisfaction_rating': resolve_data.guest_satisfaction_rating,
|
|
'guest_feedback': resolve_data.guest_feedback
|
|
}
|
|
)
|
|
db.add(update)
|
|
|
|
db.commit()
|
|
|
|
# SECURITY: Log complaint resolution for audit trail
|
|
try:
|
|
await audit_service.log_action(
|
|
db=db,
|
|
action='complaint_resolved',
|
|
resource_type='complaint',
|
|
user_id=current_user.id,
|
|
resource_id=complaint.id,
|
|
ip_address=client_ip,
|
|
user_agent=user_agent,
|
|
request_id=request_id,
|
|
details={
|
|
'complaint_id': complaint.id,
|
|
'old_status': old_status,
|
|
'new_status': 'resolved',
|
|
'guest_id': complaint.guest_id,
|
|
'resolved_by': current_user.id,
|
|
'satisfaction_rating': resolve_data.guest_satisfaction_rating,
|
|
'has_feedback': bool(resolve_data.guest_feedback)
|
|
},
|
|
status='success'
|
|
)
|
|
except Exception as e:
|
|
logger.warning(f'Failed to log complaint resolution audit: {e}')
|
|
|
|
db.refresh(complaint)
|
|
|
|
return success_response(
|
|
data={'complaint': {
|
|
'id': complaint.id,
|
|
'status': complaint.status.value,
|
|
'resolved_at': complaint.resolved_at.isoformat()
|
|
}},
|
|
message='Complaint resolved successfully'
|
|
)
|
|
except HTTPException:
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f'Error resolving complaint: {str(e)}', exc_info=True)
|
|
raise HTTPException(status_code=500, detail='An error occurred while resolving complaint')
|
|
|
|
|
|
@router.post('/{complaint_id}/updates')
|
|
async def add_complaint_update(
|
|
complaint_id: int,
|
|
update_data: AddComplaintUpdateRequest,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db)
|
|
):
|
|
"""Add an update to a complaint."""
|
|
try:
|
|
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
|
if not complaint:
|
|
db.rollback()
|
|
raise HTTPException(status_code=404, detail='Complaint not found')
|
|
|
|
# Check access
|
|
from ...shared.utils.role_helpers import is_admin, is_staff
|
|
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
|
if complaint.guest_id != current_user.id:
|
|
db.rollback()
|
|
raise HTTPException(status_code=403, detail='Access denied')
|
|
|
|
update = ComplaintUpdate(
|
|
complaint_id=complaint_id,
|
|
update_type=update_data.update_type,
|
|
description=update_data.description,
|
|
updated_by=current_user.id,
|
|
update_metadata=update_data.metadata or {}
|
|
)
|
|
|
|
db.add(update)
|
|
db.commit()
|
|
db.refresh(update)
|
|
|
|
return success_response(
|
|
data={'update': {
|
|
'id': update.id,
|
|
'update_type': update.update_type,
|
|
'description': update.description,
|
|
'created_at': update.created_at.isoformat()
|
|
}},
|
|
message='Update added successfully'
|
|
)
|
|
except HTTPException:
|
|
db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
db.rollback()
|
|
logger.error(f'Error adding complaint update: {str(e)}', exc_info=True)
|
|
raise HTTPException(status_code=500, detail='An error occurred while adding update')
|
|
|