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