from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File from sqlalchemy.orm import Session, joinedload, load_only from sqlalchemy import and_, or_, func, desc from typing import List, Optional from datetime import datetime, timedelta from pathlib import Path import uuid import hashlib 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 ...shared.utils.sanitization import sanitize_text from ...auth.models.user import User from ...auth.models.role import Role from ..models.room import Room, RoomStatus from ...bookings.models.booking import Booking, BookingStatus from ..models.room_maintenance import RoomMaintenance, MaintenanceType, MaintenanceStatus from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType from ..models.room_inspection import RoomInspection, InspectionType, InspectionStatus from ..models.room_attribute import RoomAttribute from ..services.room_assignment_service import RoomAssignmentService from pydantic import BaseModel from typing import Dict, Any logger = get_logger(__name__) router = APIRouter(prefix='/advanced-rooms', tags=['advanced-room-management']) # ==================== Room Assignment Optimization ==================== @router.post('/assign-optimal-room') async def assign_optimal_room( request_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff')), db: Session = Depends(get_db) ): """Find the best available room for a booking based on preferences""" try: room_type_id = request_data.get('room_type_id') check_in_str = request_data.get('check_in') check_out_str = request_data.get('check_out') num_guests = request_data.get('num_guests', 1) guest_preferences = request_data.get('guest_preferences', {}) exclude_room_ids = request_data.get('exclude_room_ids', []) if not room_type_id or not check_in_str or not check_out_str: raise HTTPException(status_code=400, detail='Missing required fields') check_in = datetime.fromisoformat(check_in_str.replace('Z', '+00:00')) check_out = datetime.fromisoformat(check_out_str.replace('Z', '+00:00')) best_room = RoomAssignmentService.find_best_room( db=db, room_type_id=room_type_id, check_in=check_in, check_out=check_out, num_guests=num_guests, guest_preferences=guest_preferences, exclude_room_ids=exclude_room_ids ) if not best_room: return { 'status': 'success', 'data': {'room': None, 'message': 'No suitable room available'} } return { 'status': 'success', 'data': { 'room': { 'id': best_room.id, 'room_number': best_room.room_number, 'floor': best_room.floor, 'view': best_room.view, 'status': best_room.status.value } } } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get('/{room_id}/availability-calendar') async def get_room_availability_calendar( room_id: int, start_date: str = Query(...), end_date: str = Query(...), current_user: User = Depends(authorize_roles('admin', 'staff')), db: Session = Depends(get_db) ): """Get detailed availability calendar for a room""" try: start = datetime.fromisoformat(start_date.replace('Z', '+00:00')) end = datetime.fromisoformat(end_date.replace('Z', '+00:00')) calendar = RoomAssignmentService.get_room_availability_calendar( db=db, room_id=room_id, start_date=start, end_date=end ) if not calendar: raise HTTPException(status_code=404, detail='Room not found') return {'status': 'success', 'data': calendar} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) # ==================== Room Maintenance ==================== @router.get('/maintenance') async def get_maintenance_records( room_id: Optional[int] = Query(None), status: Optional[str] = Query(None), maintenance_type: Optional[str] = Query(None), page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), current_user: User = Depends(authorize_roles('admin', 'staff')), db: Session = Depends(get_db) ): """Get maintenance records with filtering""" try: # Check if user is staff (not admin) - staff should see their assigned records AND unassigned records role = db.query(Role).filter(Role.id == current_user.role_id).first() is_staff = role and role.name == 'staff' query = db.query(RoomMaintenance).options( joinedload(RoomMaintenance.room), joinedload(RoomMaintenance.assigned_staff) ) # Filter by assigned_to for staff users - include unassigned records so they can pick them up if is_staff: query = query.filter( or_( RoomMaintenance.assigned_to == current_user.id, RoomMaintenance.assigned_to.is_(None) ) ) if room_id: query = query.filter(RoomMaintenance.room_id == room_id) if status: query = query.filter(RoomMaintenance.status == MaintenanceStatus(status)) if maintenance_type: query = query.filter(RoomMaintenance.maintenance_type == MaintenanceType(maintenance_type)) total = query.count() query = query.order_by(desc(RoomMaintenance.scheduled_start)) offset = (page - 1) * limit records = query.offset(offset).limit(limit).all() result = [] for record in records: # Get reported by user info reported_by_name = None if record.reported_by: reported_by_user = db.query(User).filter(User.id == record.reported_by).first() if reported_by_user: reported_by_name = reported_by_user.full_name result.append({ 'id': record.id, 'room_id': record.room_id, 'room_number': record.room.room_number if record.room else None, 'maintenance_type': record.maintenance_type.value, 'status': record.status.value, 'title': record.title, 'description': record.description, 'scheduled_start': record.scheduled_start.isoformat() if record.scheduled_start else None, 'scheduled_end': record.scheduled_end.isoformat() if record.scheduled_end else None, 'actual_start': record.actual_start.isoformat() if record.actual_start else None, 'actual_end': record.actual_end.isoformat() if record.actual_end else None, 'assigned_to': record.assigned_to, 'assigned_staff_name': record.assigned_staff.full_name if record.assigned_staff else None, 'reported_by': record.reported_by, 'reported_by_name': reported_by_name, 'priority': record.priority, 'blocks_room': record.blocks_room, 'estimated_cost': float(record.estimated_cost) if record.estimated_cost else None, 'actual_cost': float(record.actual_cost) if record.actual_cost else None, 'notes': record.notes, 'completion_notes': record.completion_notes if hasattr(record, 'completion_notes') else None, 'created_at': record.created_at.isoformat() if record.created_at else None, 'updated_at': record.updated_at.isoformat() if record.updated_at else None, }) return { 'status': 'success', 'data': { 'maintenance_records': result, 'pagination': { 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit } } } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post('/maintenance') async def create_maintenance_record( maintenance_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Create a new maintenance record""" try: # Check user role - housekeeping users can only report issues, not create full maintenance records role = db.query(Role).filter(Role.id == current_user.role_id).first() is_housekeeping = role and role.name == 'housekeeping' room = db.query(Room).filter(Room.id == maintenance_data.get('room_id')).first() if not room: raise HTTPException(status_code=404, detail='Room not found') # For housekeeping users, set defaults for quick issue reporting if is_housekeeping: # Housekeeping users can only create corrective/emergency maintenance maintenance_type = maintenance_data.get('maintenance_type', 'corrective') if maintenance_type not in ['corrective', 'emergency']: maintenance_type = 'corrective' maintenance_data['maintenance_type'] = maintenance_type # Default to high priority for housekeeping-reported issues if 'priority' not in maintenance_data: maintenance_data['priority'] = 'high' # Default to blocking room if 'blocks_room' not in maintenance_data: maintenance_data['blocks_room'] = True scheduled_start = datetime.fromisoformat(maintenance_data.get('scheduled_start', datetime.utcnow().isoformat()).replace('Z', '+00:00')) scheduled_end = None if maintenance_data.get('scheduled_end'): scheduled_end = datetime.fromisoformat(maintenance_data['scheduled_end'].replace('Z', '+00:00')) block_start = None block_end = None if maintenance_data.get('block_start'): block_start = datetime.fromisoformat(maintenance_data['block_start'].replace('Z', '+00:00')) if maintenance_data.get('block_end'): block_end = datetime.fromisoformat(maintenance_data['block_end'].replace('Z', '+00:00')) # Sanitize user input sanitized_title = sanitize_text(maintenance_data.get('title', 'Maintenance')) sanitized_description = sanitize_text(maintenance_data.get('description')) if maintenance_data.get('description') else None sanitized_notes = sanitize_text(maintenance_data.get('notes')) if maintenance_data.get('notes') else None maintenance = RoomMaintenance( room_id=maintenance_data['room_id'], maintenance_type=MaintenanceType(maintenance_data.get('maintenance_type', 'preventive')), status=MaintenanceStatus(maintenance_data.get('status', 'scheduled')), title=sanitized_title, description=sanitized_description, scheduled_start=scheduled_start, scheduled_end=scheduled_end, assigned_to=maintenance_data.get('assigned_to'), reported_by=current_user.id, estimated_cost=maintenance_data.get('estimated_cost'), blocks_room=maintenance_data.get('blocks_room', True), block_start=block_start, block_end=block_end, priority=maintenance_data.get('priority', 'medium'), notes=sanitized_notes ) # Update room status if blocking and maintenance is active if maintenance.blocks_room and maintenance.status in [MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]: # Only update if room is currently available if room.status == RoomStatus.available: room.status = RoomStatus.maintenance db.add(maintenance) db.commit() db.refresh(maintenance) return { 'status': 'success', 'message': 'Maintenance record created successfully', 'data': {'maintenance_id': maintenance.id} } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.put('/maintenance/{maintenance_id}') async def update_maintenance_record( maintenance_id: int, maintenance_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff')), db: Session = Depends(get_db) ): """Update a maintenance record""" try: maintenance = db.query(RoomMaintenance).filter(RoomMaintenance.id == maintenance_id).first() if not maintenance: raise HTTPException(status_code=404, detail='Maintenance record not found') # Check if user is staff (not admin) - staff can only update their own assigned records role = db.query(Role).filter(Role.id == current_user.role_id).first() is_staff = role and role.name == 'staff' if is_staff: # Staff can only update records assigned to them if maintenance.assigned_to != current_user.id: raise HTTPException(status_code=403, detail='You can only update maintenance assigned to you') # Staff can only update status and completion fields allowed_fields = {'status', 'actual_start', 'actual_end', 'completion_notes', 'actual_cost'} if any(key not in allowed_fields for key in maintenance_data.keys()): raise HTTPException(status_code=403, detail='You can only update status and completion information') # Update fields if 'status' in maintenance_data: new_status = MaintenanceStatus(maintenance_data['status']) # Only the assigned user can mark the maintenance as completed if new_status == MaintenanceStatus.completed: if not maintenance.assigned_to: raise HTTPException(status_code=400, detail='Maintenance must be assigned before it can be marked as completed') if maintenance.assigned_to != current_user.id: raise HTTPException(status_code=403, detail='Only the assigned staff member can mark this maintenance as completed') old_status = maintenance.status maintenance.status = new_status # Update room status based on maintenance status if maintenance.status == MaintenanceStatus.completed and maintenance.blocks_room: # Check if room has other active maintenance other_maintenance = db.query(RoomMaintenance).filter( and_( RoomMaintenance.room_id == maintenance.room_id, RoomMaintenance.id != maintenance_id, RoomMaintenance.blocks_room == True, RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]) ) ).first() if not other_maintenance: # Check if room has active bookings from datetime import datetime active_booking = db.query(Booking).filter( and_( Booking.room_id == maintenance.room_id, Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]), Booking.check_in_date <= datetime.utcnow(), Booking.check_out_date > datetime.utcnow() ) ).first() if active_booking: maintenance.room.status = RoomStatus.occupied else: maintenance.room.status = RoomStatus.available elif maintenance.status in [MaintenanceStatus.scheduled, MaintenanceStatus.in_progress] and maintenance.blocks_room: # Set room to maintenance if it's not occupied if maintenance.room.status == RoomStatus.available: maintenance.room.status = RoomStatus.maintenance if 'actual_start' in maintenance_data: maintenance.actual_start = datetime.fromisoformat(maintenance_data['actual_start'].replace('Z', '+00:00')) if 'actual_end' in maintenance_data: maintenance.actual_end = datetime.fromisoformat(maintenance_data['actual_end'].replace('Z', '+00:00')) if 'completion_notes' in maintenance_data: maintenance.completion_notes = sanitize_text(maintenance_data['completion_notes']) if maintenance_data['completion_notes'] else None if 'actual_cost' in maintenance_data: maintenance.actual_cost = maintenance_data['actual_cost'] db.commit() return { 'status': 'success', 'message': 'Maintenance record updated successfully' } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) # ==================== Housekeeping Tasks ==================== @router.get('/housekeeping') async def get_housekeeping_tasks( room_id: Optional[int] = Query(None), status: Optional[str] = Query(None), task_type: Optional[str] = Query(None), date: Optional[str] = Query(None), page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), include_cleaning_rooms: bool = Query(True, description='Include rooms in cleaning status even without tasks'), current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Get housekeeping tasks with filtering. Also includes rooms in cleaning status.""" try: # Check user role - housekeeping and staff users should only see their assigned tasks role = db.query(Role).filter(Role.id == current_user.role_id).first() is_admin = role and role.name == 'admin' is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff') # Build base query for filtering base_query = db.query(HousekeepingTask) # Filter by assigned_to for housekeeping and staff users (not admin) # But also include unassigned tasks so they can pick them up if is_housekeeping_or_staff: base_query = base_query.filter( or_( HousekeepingTask.assigned_to == current_user.id, HousekeepingTask.assigned_to.is_(None) ) ) if room_id: base_query = base_query.filter(HousekeepingTask.room_id == room_id) if status: base_query = base_query.filter(HousekeepingTask.status == HousekeepingStatus(status)) if task_type: base_query = base_query.filter(HousekeepingTask.task_type == HousekeepingType(task_type)) if date: try: # Handle different date formats if 'T' in date: date_obj = datetime.fromisoformat(date.replace('Z', '+00:00')).date() else: date_obj = datetime.strptime(date, '%Y-%m-%d').date() base_query = base_query.filter(func.date(HousekeepingTask.scheduled_time) == date_obj) except (ValueError, AttributeError) as date_error: logger.error(f'Error parsing date {date}: {str(date_error)}') raise HTTPException(status_code=400, detail=f'Invalid date format: {date}') # Get count before adding joins (to avoid duplicate counting) total = base_query.count() # Add eager loading and ordering for the actual data query query = base_query.options( joinedload(HousekeepingTask.room), joinedload(HousekeepingTask.assigned_staff) ).order_by(HousekeepingTask.scheduled_time) offset = (page - 1) * limit tasks = query.offset(offset).limit(limit).all() result = [] task_room_ids = set() # Process existing tasks for task in tasks: try: task_room_ids.add(task.room_id) # Safely get room status room_status = None if task.room and hasattr(task.room, 'status') and task.room.status: room_status = task.room.status.value if hasattr(task.room.status, 'value') else str(task.room.status) # Safely get assigned staff name assigned_staff_name = None if task.assigned_staff and hasattr(task.assigned_staff, 'full_name'): assigned_staff_name = task.assigned_staff.full_name result.append({ 'id': task.id, 'room_id': task.room_id, 'room_number': task.room.room_number if task.room and hasattr(task.room, 'room_number') else None, 'booking_id': task.booking_id, 'task_type': task.task_type.value if hasattr(task.task_type, 'value') else str(task.task_type), 'status': task.status.value if hasattr(task.status, 'value') else str(task.status), 'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None, 'started_at': task.started_at.isoformat() if task.started_at else None, 'completed_at': task.completed_at.isoformat() if task.completed_at else None, 'assigned_to': task.assigned_to, 'assigned_staff_name': assigned_staff_name, 'checklist_items': task.checklist_items if task.checklist_items else [], 'notes': task.notes, 'quality_score': task.quality_score, 'estimated_duration_minutes': task.estimated_duration_minutes, 'actual_duration_minutes': task.actual_duration_minutes, 'room_status': room_status, 'photos': task.photos if task.photos else [] }) except Exception as task_error: logger.error(f'Error processing task {task.id if task else "unknown"}: {str(task_error)}', exc_info=True) # Continue with next task instead of failing completely continue # Include rooms in cleaning status that don't have tasks (or have unassigned tasks for housekeeping users) if include_cleaning_rooms: rooms_query = db.query(Room).filter(Room.status == RoomStatus.cleaning) if room_id: rooms_query = rooms_query.filter(Room.id == room_id) # For housekeeping/staff users, also include rooms with unassigned tasks if is_housekeeping_or_staff: # Get room IDs with unassigned tasks unassigned_task_rooms = db.query(HousekeepingTask.room_id).filter( and_( HousekeepingTask.assigned_to.is_(None), HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]) ) ).distinct().all() unassigned_room_ids = [r[0] for r in unassigned_task_rooms] # Include rooms in cleaning status OR rooms with unassigned tasks if unassigned_room_ids: rooms_query = db.query(Room).filter( or_( Room.status == RoomStatus.cleaning, Room.id.in_(unassigned_room_ids) ) ) if room_id: rooms_query = rooms_query.filter(Room.id == room_id) cleaning_rooms = rooms_query.all() # Add rooms in cleaning status that don't have tasks in current page results for room in cleaning_rooms: if room.id not in task_room_ids: # Check if there are any pending tasks for this room pending_tasks = db.query(HousekeepingTask).filter( and_( HousekeepingTask.room_id == room.id, HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]) ) ).all() # For housekeeping/staff, only show if there are unassigned tasks or if room is in cleaning if is_housekeeping_or_staff: has_unassigned = any(t.assigned_to is None for t in pending_tasks) if not has_unassigned and room.status != RoomStatus.cleaning: continue # Create a virtual task entry for rooms in cleaning status result.append({ 'id': None, # No task ID since this is a room status entry 'room_id': room.id, 'room_number': room.room_number, 'booking_id': None, 'task_type': 'vacant', # Default task type 'status': 'pending', 'scheduled_time': datetime.utcnow().isoformat(), 'started_at': None, 'completed_at': None, 'assigned_to': None, 'assigned_staff_name': None, 'checklist_items': [], 'notes': 'Room is in cleaning mode', 'quality_score': None, 'estimated_duration_minutes': None, 'actual_duration_minutes': None, 'room_status': room.status.value, 'is_room_status_only': True, # Flag to indicate this is from room status, not a task 'photos': [] }) # Update total count to include cleaning rooms if include_cleaning_rooms: total = len(result) return { 'status': 'success', 'data': { 'tasks': result, 'pagination': { 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit } } } except Exception as e: logger.error(f'Error fetching housekeeping tasks: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail='Failed to fetch housekeeping tasks') @router.post('/housekeeping') async def create_housekeeping_task( task_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Create a new housekeeping task""" try: # Check user role - housekeeping users can only assign tasks to themselves role = db.query(Role).filter(Role.id == current_user.role_id).first() is_admin = role and role.name == 'admin' is_housekeeping = role and role.name == 'housekeeping' room = db.query(Room).filter(Room.id == task_data.get('room_id')).first() if not room: raise HTTPException(status_code=404, detail='Room not found') scheduled_time = datetime.fromisoformat(task_data['scheduled_time'].replace('Z', '+00:00')) assigned_to = task_data.get('assigned_to') # Housekeeping users can only assign tasks to themselves if is_housekeeping and assigned_to and assigned_to != current_user.id: raise HTTPException(status_code=403, detail='Housekeeping users can only assign tasks to themselves') # If housekeeping user doesn't specify assigned_to, assign to themselves if is_housekeeping and not assigned_to: assigned_to = current_user.id # Sanitize user input sanitized_notes = sanitize_text(task_data.get('notes')) if task_data.get('notes') else None task = HousekeepingTask( room_id=task_data['room_id'], booking_id=task_data.get('booking_id'), task_type=HousekeepingType(task_data.get('task_type', 'vacant')), status=HousekeepingStatus(task_data.get('status', 'pending')), scheduled_time=scheduled_time, assigned_to=assigned_to, created_by=current_user.id, checklist_items=task_data.get('checklist_items', []), notes=sanitized_notes, estimated_duration_minutes=task_data.get('estimated_duration_minutes') ) db.add(task) db.commit() db.refresh(task) # Send notification to assigned staff member if task is assigned if assigned_to: try: from ...notifications.routes.notification_routes import notification_manager task_data_notification = { 'id': task.id, 'room_id': task.room_id, 'room_number': room.room_number, 'task_type': task.task_type.value, 'status': task.status.value, 'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None, 'assigned_to': task.assigned_to, 'created_at': task.created_at.isoformat() if task.created_at else None } notification_data = { 'type': 'housekeeping_task_assigned', 'data': task_data_notification } # Send notification to the specific staff member await notification_manager.send_to_user(assigned_to, notification_data) except Exception as e: logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True) return { 'status': 'success', 'message': 'Housekeeping task created successfully', 'data': {'task_id': task.id} } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.put('/housekeeping/{task_id}') async def update_housekeeping_task( task_id: int, task_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Update a housekeeping task""" try: task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail='Housekeeping task not found') # Check user role - housekeeping and staff users can only update their own assigned tasks role = db.query(Role).filter(Role.id == current_user.role_id).first() is_admin = role and role.name == 'admin' is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff') if is_housekeeping_or_staff: # Housekeeping and staff can start unassigned tasks (assign to themselves) if task.assigned_to is None: # Allow housekeeping users to assign unassigned tasks to themselves if 'status' in task_data and task_data['status'] == 'in_progress': task.assigned_to = current_user.id task_data['assigned_to'] = current_user.id elif task.assigned_to != current_user.id: # If task is assigned, only the assigned user can update it raise HTTPException(status_code=403, detail='You can only update tasks assigned to you') # Housekeeping and staff cannot change assignment of already assigned tasks if 'assigned_to' in task_data and task.assigned_to is not None and task_data.get('assigned_to') != task.assigned_to: raise HTTPException(status_code=403, detail='You cannot change task assignment') old_assigned_to = task.assigned_to assigned_to_changed = False # Handle assignment - admin can assign, housekeeping can self-assign unassigned tasks if 'assigned_to' in task_data: if is_admin: new_assigned_to = task_data.get('assigned_to') if new_assigned_to != old_assigned_to: task.assigned_to = new_assigned_to assigned_to_changed = True elif is_housekeeping_or_staff and task.assigned_to is None: # Housekeeping can assign unassigned tasks to themselves when starting if task_data.get('assigned_to') == current_user.id: task.assigned_to = current_user.id assigned_to_changed = True if 'status' in task_data: new_status = HousekeepingStatus(task_data['status']) # Only the assigned user can mark the task as completed if new_status == HousekeepingStatus.completed: if not task.assigned_to: raise HTTPException(status_code=400, detail='Task must be assigned before it can be marked as completed') if task.assigned_to != current_user.id: raise HTTPException(status_code=403, detail='Only the assigned staff member can mark this task as completed') task.status = new_status if new_status == HousekeepingStatus.in_progress and not task.started_at: task.started_at = datetime.utcnow() # If task was unassigned, assign it to the current user if task.assigned_to is None and is_housekeeping_or_staff: task.assigned_to = current_user.id elif new_status == HousekeepingStatus.completed and not task.completed_at: task.completed_at = datetime.utcnow() if task.started_at: duration = (task.completed_at - task.started_at).total_seconds() / 60 task.actual_duration_minutes = int(duration) # Update room status when housekeeping task is completed room = db.query(Room).filter(Room.id == task.room_id).first() if room: # Check if there are other pending housekeeping tasks for this room pending_tasks = db.query(HousekeepingTask).filter( and_( HousekeepingTask.room_id == room.id, HousekeepingTask.id != task.id, HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress]) ) ).count() # Check if there's active maintenance from ...rooms.models.room_maintenance import RoomMaintenance, MaintenanceStatus active_maintenance = db.query(RoomMaintenance).filter( and_( RoomMaintenance.room_id == room.id, RoomMaintenance.blocks_room == True, RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]) ) ).first() if active_maintenance: room.status = RoomStatus.maintenance elif pending_tasks > 0: # Keep room as cleaning if there are other pending tasks room.status = RoomStatus.cleaning else: # No pending tasks and no maintenance - room is ready for check-in # Check if there are any upcoming bookings for this room from ...bookings.models.booking import Booking, BookingStatus upcoming_booking = db.query(Booking).filter( and_( Booking.room_id == room.id, Booking.status == BookingStatus.confirmed, Booking.check_in_date <= datetime.utcnow() + timedelta(days=1) ) ).first() if upcoming_booking: # Room has upcoming booking, keep as available (ready for check-in) room.status = RoomStatus.available else: # No upcoming bookings, room is available room.status = RoomStatus.available if 'checklist_items' in task_data: task.checklist_items = task_data['checklist_items'] if 'notes' in task_data: task.notes = sanitize_text(task_data['notes']) if task_data['notes'] else None if 'issues_found' in task_data: task.issues_found = sanitize_text(task_data['issues_found']) if task_data['issues_found'] else None if 'quality_score' in task_data: task.quality_score = task_data['quality_score'] if 'inspected_by' in task_data: task.inspected_by = task_data['inspected_by'] task.inspected_at = datetime.utcnow() if 'inspection_notes' in task_data: task.inspection_notes = sanitize_text(task_data['inspection_notes']) if task_data['inspection_notes'] else None if 'photos' in task_data: task.photos = task_data['photos'] db.commit() db.refresh(task) # Send notification if assignment changed if assigned_to_changed and task.assigned_to: try: from ...notifications.routes.notification_routes import notification_manager room = db.query(Room).filter(Room.id == task.room_id).first() task_data_notification = { 'id': task.id, 'room_id': task.room_id, 'room_number': room.room_number if room else None, 'task_type': task.task_type.value, 'status': task.status.value, 'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None, 'assigned_to': task.assigned_to, 'updated_at': task.updated_at.isoformat() if task.updated_at else None } notification_data = { 'type': 'housekeeping_task_assigned', 'data': task_data_notification } # Send notification to the newly assigned staff member await notification_manager.send_to_user(task.assigned_to, notification_data) except Exception as e: logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True) return { 'status': 'success', 'message': 'Housekeeping task updated successfully' } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.post('/housekeeping/{task_id}/upload-photo') async def upload_housekeeping_task_photo( task_id: int, request: Request, image: UploadFile = File(...), current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Upload a photo for a housekeeping task""" try: task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail='Housekeeping task not found') # Check permissions - housekeeping users can only upload photos to their assigned tasks role = db.query(Role).filter(Role.id == current_user.role_id).first() is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff') if is_housekeeping_or_staff: if task.assigned_to != current_user.id: raise HTTPException(status_code=403, detail='You can only upload photos to tasks assigned to you') # Validate and process image from ...shared.utils.file_validation import validate_uploaded_image from ...shared.config.settings import settings from PIL import Image import io max_size = 5 * 1024 * 1024 # 5MB content = await validate_uploaded_image(image, max_size) # Optimize image img = Image.open(io.BytesIO(content)) if img.mode in ('RGBA', 'LA', 'P'): img = img.convert('RGB') # Generate unique filename file_ext = Path(image.filename).suffix.lower() if image.filename else '.jpg' if file_ext not in ['.jpg', '.jpeg', '.png', '.webp']: file_ext = '.jpg' filename = f"housekeeping_task_{task_id}_{uuid.uuid4().hex[:8]}{file_ext}" upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'housekeeping' upload_dir.mkdir(parents=True, exist_ok=True) file_path = upload_dir / filename # Save optimized image img.save(file_path, 'JPEG', quality=85, optimize=True) image_url = f'/uploads/housekeeping/{filename}' # Update task photos if task.photos is None: task.photos = [] if not isinstance(task.photos, list): task.photos = [] task.photos.append(image_url) db.commit() # Get full URL base_url = str(request.base_url).rstrip('/') full_url = f"{base_url}{image_url}" return { 'status': 'success', 'message': 'Photo uploaded successfully', 'data': { 'photo_url': image_url, 'full_url': full_url, 'photos': task.photos } } except HTTPException: raise except Exception as e: db.rollback() logger.error(f'Error uploading housekeeping task photo: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=f'Error uploading photo: {str(e)}') @router.post('/housekeeping/{task_id}/report-maintenance-issue') async def report_maintenance_issue_from_task( task_id: int, issue_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Quick maintenance issue reporting from housekeeping task""" try: task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first() if not task: raise HTTPException(status_code=404, detail='Housekeeping task not found') # Check permissions - housekeeping users can only report issues for their assigned tasks role = db.query(Role).filter(Role.id == current_user.role_id).first() is_housekeeping = role and role.name == 'housekeeping' if is_housekeeping: if task.assigned_to != current_user.id: raise HTTPException(status_code=403, detail='You can only report issues for tasks assigned to you') room = db.query(Room).filter(Room.id == task.room_id).first() if not room: raise HTTPException(status_code=404, detail='Room not found') # Create maintenance record - sanitize user input title = sanitize_text(issue_data.get('title', f'Issue reported from Room {room.room_number}')) description = sanitize_text(issue_data.get('description', '')) if task.notes: description = f"Reported from housekeeping task.\n\nTask Notes: {sanitize_text(task.notes)}\n\nIssue Description: {description}".strip() else: description = f"Reported from housekeeping task.\n\nIssue Description: {description}".strip() maintenance = RoomMaintenance( room_id=task.room_id, maintenance_type=MaintenanceType(issue_data.get('maintenance_type', 'corrective')), status=MaintenanceStatus('scheduled'), title=title, description=description, scheduled_start=datetime.utcnow(), assigned_to=None, # Will be assigned by admin/staff reported_by=current_user.id, priority=issue_data.get('priority', 'high'), blocks_room=issue_data.get('blocks_room', True), notes=sanitize_text(issue_data.get('notes', f'Reported from housekeeping task #{task_id}')) ) # Update room status if blocking if maintenance.blocks_room and room.status == RoomStatus.available: room.status = RoomStatus.maintenance db.add(maintenance) db.commit() db.refresh(maintenance) # Send notification to admin/staff try: from ...notifications.routes.notification_routes import notification_manager notification_data = { 'type': 'maintenance_request_created', 'data': { 'maintenance_id': maintenance.id, 'room_id': room.id, 'room_number': room.room_number, 'title': maintenance.title, 'priority': maintenance.priority, 'reported_by': current_user.full_name, 'reported_at': maintenance.created_at.isoformat() if maintenance.created_at else None } } # Send to admin and staff roles await notification_manager.send_to_role('admin', notification_data) await notification_manager.send_to_role('staff', notification_data) except Exception as e: logger.error(f'Error sending maintenance notification: {str(e)}', exc_info=True) return { 'status': 'success', 'message': 'Maintenance issue reported successfully', 'data': {'maintenance_id': maintenance.id} } except HTTPException: raise except Exception as e: db.rollback() logger.error(f'Error reporting maintenance issue: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=f'Error reporting issue: {str(e)}') # ==================== Room Inspections ==================== @router.get('/inspections') async def get_room_inspections( room_id: Optional[int] = Query(None), inspection_type: Optional[str] = Query(None), status: Optional[str] = Query(None), page: int = Query(1, ge=1), limit: int = Query(20, ge=1, le=100), current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Get room inspections with filtering""" try: # Check if user is staff or housekeeping (not admin) - they should only see their assigned inspections role = db.query(Role).filter(Role.id == current_user.role_id).first() is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping') query = db.query(RoomInspection) # Filter by inspected_by for staff and housekeeping users if is_staff_or_housekeeping: query = query.filter(RoomInspection.inspected_by == current_user.id) if room_id: query = query.filter(RoomInspection.room_id == room_id) if inspection_type: query = query.filter(RoomInspection.inspection_type == InspectionType(inspection_type)) if status: query = query.filter(RoomInspection.status == InspectionStatus(status)) total = query.count() query = query.order_by(desc(RoomInspection.scheduled_at)) offset = (page - 1) * limit inspections = query.offset(offset).limit(limit).all() result = [] for inspection in inspections: result.append({ 'id': inspection.id, 'room_id': inspection.room_id, 'room_number': inspection.room.room_number if inspection.room else None, 'booking_id': inspection.booking_id, 'inspection_type': inspection.inspection_type.value, 'status': inspection.status.value, 'scheduled_at': inspection.scheduled_at.isoformat() if inspection.scheduled_at else None, 'started_at': inspection.started_at.isoformat() if inspection.started_at else None, 'completed_at': inspection.completed_at.isoformat() if inspection.completed_at else None, 'inspected_by': inspection.inspected_by, 'inspector_name': inspection.inspector.full_name if inspection.inspector else None, 'checklist_items': inspection.checklist_items, 'overall_score': float(inspection.overall_score) if inspection.overall_score else None, 'overall_notes': inspection.overall_notes, 'issues_found': inspection.issues_found, 'requires_followup': inspection.requires_followup, 'created_at': inspection.created_at.isoformat() if inspection.created_at else None }) return { 'status': 'success', 'data': { 'inspections': result, 'pagination': { 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit } } } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post('/inspections') async def create_room_inspection( inspection_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Create a new room inspection""" try: # Check user role - housekeeping users can only create inspections for themselves role = db.query(Role).filter(Role.id == current_user.role_id).first() is_housekeeping = role and role.name == 'housekeeping' room = db.query(Room).filter(Room.id == inspection_data.get('room_id')).first() if not room: raise HTTPException(status_code=404, detail='Room not found') scheduled_at = datetime.fromisoformat(inspection_data['scheduled_at'].replace('Z', '+00:00')) inspected_by = inspection_data.get('inspected_by') # Housekeeping users can only assign inspections to themselves if is_housekeeping: if inspected_by and inspected_by != current_user.id: raise HTTPException(status_code=403, detail='Housekeeping users can only create inspections for themselves') inspected_by = current_user.id inspection = RoomInspection( room_id=inspection_data['room_id'], booking_id=inspection_data.get('booking_id'), inspection_type=InspectionType(inspection_data.get('inspection_type', 'routine')), status=InspectionStatus(inspection_data.get('status', 'pending')), scheduled_at=scheduled_at, inspected_by=inspected_by, created_by=current_user.id, checklist_items=inspection_data.get('checklist_items', []), checklist_template_id=inspection_data.get('checklist_template_id') ) db.add(inspection) db.commit() db.refresh(inspection) return { 'status': 'success', 'message': 'Room inspection created successfully', 'data': {'inspection_id': inspection.id} } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) @router.put('/inspections/{inspection_id}') async def update_room_inspection( inspection_id: int, inspection_data: dict, current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')), db: Session = Depends(get_db) ): """Update a room inspection""" try: inspection = db.query(RoomInspection).filter(RoomInspection.id == inspection_id).first() if not inspection: raise HTTPException(status_code=404, detail='Room inspection not found') # Check if user is staff or housekeeping (not admin) - they can only update their own assigned inspections role = db.query(Role).filter(Role.id == current_user.role_id).first() is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping') if is_staff_or_housekeeping: # Staff and housekeeping can only update inspections assigned to them if inspection.inspected_by != current_user.id: raise HTTPException(status_code=403, detail='You can only update inspections assigned to you') # Staff and housekeeping can only update status and inspection results allowed_fields = {'status', 'checklist_items', 'overall_score', 'overall_notes', 'issues_found', 'requires_followup', 'followup_notes', 'photos'} if any(key not in allowed_fields for key in inspection_data.keys()): raise HTTPException(status_code=403, detail='You can only update status and inspection results') if 'status' in inspection_data: new_status = InspectionStatus(inspection_data['status']) # Only the assigned user can mark the inspection as completed if new_status == InspectionStatus.completed: if not inspection.inspected_by: raise HTTPException(status_code=400, detail='Inspection must be assigned before it can be marked as completed') if inspection.inspected_by != current_user.id: raise HTTPException(status_code=403, detail='Only the assigned inspector can mark this inspection as completed') inspection.status = new_status if new_status == InspectionStatus.in_progress and not inspection.started_at: inspection.started_at = datetime.utcnow() elif new_status == InspectionStatus.completed and not inspection.completed_at: inspection.completed_at = datetime.utcnow() if 'checklist_items' in inspection_data: inspection.checklist_items = inspection_data['checklist_items'] if 'overall_score' in inspection_data: inspection.overall_score = inspection_data['overall_score'] if 'overall_notes' in inspection_data: inspection.overall_notes = sanitize_text(inspection_data['overall_notes']) if inspection_data['overall_notes'] else None if 'issues_found' in inspection_data: inspection.issues_found = sanitize_text(inspection_data['issues_found']) if inspection_data['issues_found'] else None if 'photos' in inspection_data: inspection.photos = inspection_data['photos'] if 'requires_followup' in inspection_data: inspection.requires_followup = inspection_data['requires_followup'] if 'followup_notes' in inspection_data: inspection.followup_notes = sanitize_text(inspection_data['followup_notes']) if inspection_data['followup_notes'] else None if 'maintenance_request_id' in inspection_data: inspection.maintenance_request_id = inspection_data['maintenance_request_id'] db.commit() return { 'status': 'success', 'message': 'Room inspection updated successfully' } except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) # ==================== Room Status Board ==================== @router.get('/status-board') async def get_room_status_board( floor: Optional[int] = Query(None), current_user: User = Depends(authorize_roles('admin', 'staff')), db: Session = Depends(get_db) ): """Get visual room status board with all rooms and their current status""" try: query = db.query(Room).options(joinedload(Room.room_type)) if floor: query = query.filter(Room.floor == floor) rooms = query.order_by(Room.floor, Room.room_number).all() result = [] for room in rooms: # Get current booking if any # Use load_only to avoid querying columns that don't exist in the database (rate_plan_id, group_booking_id) current_booking = db.query(Booking).options( joinedload(Booking.user), load_only(Booking.id, Booking.user_id, Booking.room_id, Booking.check_in_date, Booking.check_out_date, Booking.status) ).filter( and_( Booking.room_id == room.id, Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]), Booking.check_in_date <= datetime.utcnow(), Booking.check_out_date > datetime.utcnow() ) ).first() # Get active maintenance active_maintenance = db.query(RoomMaintenance).filter( and_( RoomMaintenance.room_id == room.id, RoomMaintenance.blocks_room == True, RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]) ) ).first() # Get pending housekeeping tasks pending_housekeeping = db.query(HousekeepingTask).filter( and_( HousekeepingTask.room_id == room.id, HousekeepingTask.status == HousekeepingStatus.pending, func.date(HousekeepingTask.scheduled_time) == datetime.utcnow().date() ) ).count() result.append({ 'id': room.id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value, 'room_type': room.room_type.name if room.room_type else None, 'current_booking': { 'id': current_booking.id, 'guest_name': current_booking.user.full_name if current_booking.user else 'Unknown', 'check_out': current_booking.check_out_date.isoformat() } if current_booking else None, 'active_maintenance': { 'id': active_maintenance.id, 'title': active_maintenance.title, 'type': active_maintenance.maintenance_type.value } if active_maintenance else None, 'pending_housekeeping_count': pending_housekeeping }) return { 'status': 'success', 'data': {'rooms': result} } except Exception as e: import logging import traceback logger = logging.getLogger(__name__) logger.error(f'Error in get_room_status_board: {str(e)}') logger.error(f'Traceback: {traceback.format_exc()}') raise HTTPException(status_code=500, detail=str(e))