This commit is contained in:
Iliyan Angelov
2025-12-03 01:31:34 +02:00
parent e32527ae8c
commit 5fb50983a9
37 changed files with 5844 additions and 201 deletions

View File

@@ -346,10 +346,11 @@ async def get_housekeeping_tasks(
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"""
"""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()
@@ -359,8 +360,14 @@ async def get_housekeeping_tasks(
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:
query = query.filter(HousekeepingTask.assigned_to == current_user.id)
query = query.filter(
or_(
HousekeepingTask.assigned_to == current_user.id,
HousekeepingTask.assigned_to.is_(None)
)
)
if room_id:
query = query.filter(HousekeepingTask.room_id == room_id)
@@ -379,7 +386,11 @@ async def get_housekeeping_tasks(
tasks = query.offset(offset).limit(limit).all()
result = []
task_room_ids = set()
# Process existing tasks
for task in tasks:
task_room_ids.add(task.room_id)
result.append({
'id': task.id,
'room_id': task.room_id,
@@ -396,9 +407,84 @@ async def get_housekeeping_tasks(
'notes': task.notes,
'quality_score': task.quality_score,
'estimated_duration_minutes': task.estimated_duration_minutes,
'actual_duration_minutes': task.actual_duration_minutes
'actual_duration_minutes': task.actual_duration_minutes,
'room_status': task.room.status.value if task.room else None
})
# 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
})
# Update total count to include cleaning rooms
if include_cleaning_rooms:
total = len(result)
return {
'status': 'success',
'data': {
@@ -418,11 +504,16 @@ async def get_housekeeping_tasks(
@router.post('/housekeeping')
async def create_housekeeping_task(
task_data: dict,
current_user: User = Depends(authorize_roles('admin', 'staff')),
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')
@@ -430,6 +521,14 @@ async def create_housekeeping_task(
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
task = HousekeepingTask(
room_id=task_data['room_id'],
booking_id=task_data.get('booking_id'),
@@ -450,8 +549,7 @@ async def create_housekeeping_task(
# Send notification to assigned staff member if task is assigned
if assigned_to:
try:
from ..routes.chat_routes import manager
assigned_staff = db.query(User).filter(User.id == assigned_to).first()
from ...notifications.routes.notification_routes import notification_manager
task_data_notification = {
'id': task.id,
'room_id': task.room_id,
@@ -467,13 +565,9 @@ async def create_housekeeping_task(
'data': task_data_notification
}
# Send notification to the specific staff member
if assigned_to in manager.staff_connections:
try:
await manager.staff_connections[assigned_to].send_json(notification_data)
except Exception as e:
logger.error(f'Error sending housekeeping task notification to staff {assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': assigned_to})
await notification_manager.send_to_user(assigned_to, notification_data)
except Exception as e:
logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True)
return {
'status': 'success',
@@ -504,21 +598,34 @@ async def update_housekeeping_task(
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
if is_housekeeping_or_staff:
# Housekeeping and staff can only update tasks assigned to them
if task.assigned_to != current_user.id:
# 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
if 'assigned_to' in task_data and task_data.get('assigned_to') != task.assigned_to:
# 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
if 'assigned_to' in task_data and 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
# 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'])
@@ -534,6 +641,9 @@ async def update_housekeeping_task(
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:
@@ -568,8 +678,23 @@ async def update_housekeeping_task(
# Keep room as cleaning if there are other pending tasks
room.status = RoomStatus.cleaning
else:
# No pending tasks and no maintenance - room is ready
room.status = RoomStatus.available
# 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']
@@ -591,7 +716,7 @@ async def update_housekeeping_task(
# Send notification if assignment changed
if assigned_to_changed and task.assigned_to:
try:
from ..routes.chat_routes import manager
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,
@@ -608,13 +733,9 @@ async def update_housekeeping_task(
'data': task_data_notification
}
# Send notification to the newly assigned staff member
if task.assigned_to in manager.staff_connections:
try:
await manager.staff_connections[task.assigned_to].send_json(notification_data)
except Exception as e:
logger.error(f'Error sending housekeeping task notification to staff {task.assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': task.assigned_to})
await notification_manager.send_to_user(task.assigned_to, notification_data)
except Exception as e:
logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True)
return {
'status': 'success',

View File

@@ -14,6 +14,7 @@ from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRooms
from ...shared.utils.response_helpers import success_response
from ...reviews.models.review import Review, ReviewStatus
from ...bookings.models.booking import Booking, BookingStatus
from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
from ..services.room_service import get_rooms_with_ratings, get_amenities_list, normalize_images, get_base_url
import os
import aiofiles
@@ -424,8 +425,39 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
if room_data.floor is not None:
room.floor = room_data.floor
old_status = room.status
if room_data.status is not None:
room.status = RoomStatus(room_data.status)
new_status = RoomStatus(room_data.status)
room.status = new_status
# If room status is changed to cleaning, create a housekeeping task if one doesn't exist
if new_status == RoomStatus.cleaning and old_status != RoomStatus.cleaning:
# Check if there's already a pending housekeeping task for this room
existing_task = db.query(HousekeepingTask).filter(
and_(
HousekeepingTask.room_id == room.id,
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress])
)
).first()
if not existing_task:
# Create a new housekeeping task for the cleaning room
cleaning_task = HousekeepingTask(
room_id=room.id,
task_type=HousekeepingType.vacant,
status=HousekeepingStatus.pending,
scheduled_time=datetime.utcnow(),
created_by=current_user.id,
checklist_items=[
{'item': 'Deep clean bathroom', 'completed': False, 'notes': ''},
{'item': 'Change linens', 'completed': False, 'notes': ''},
{'item': 'Vacuum and mop', 'completed': False, 'notes': ''},
{'item': 'Dust surfaces', 'completed': False, 'notes': ''},
{'item': 'Check amenities', 'completed': False, 'notes': ''}
],
notes='Room set to cleaning mode'
)
db.add(cleaning_task)
if room_data.featured is not None:
room.featured = room_data.featured
if room_data.price is not None:
@@ -545,24 +577,55 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren
room = db.query(Room).filter(Room.id == id).first()
if not room:
raise HTTPException(status_code=404, detail='Room not found')
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'rooms'
# Calculate upload directory to match main.py (Backend/uploads/rooms)
# From Backend/src/rooms/routes/room_routes.py -> Backend/
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'rooms'
upload_dir.mkdir(parents=True, exist_ok=True)
# Import validation and optimization utilities
from ...shared.config.settings import settings
from ...shared.utils.file_validation import validate_uploaded_image
from ...shared.utils.image_optimization import optimize_image_async, ImageType
image_urls = []
for image in images:
if not image.content_type or not image.content_type.startswith('image/'):
continue
if not image.filename:
continue
import uuid
ext = Path(image.filename).suffix or '.jpg'
filename = f'room-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
try:
# Validate the image
content = await validate_uploaded_image(image, settings.MAX_UPLOAD_SIZE)
# Optimize image before saving
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.ROOM)
import uuid
ext = Path(image.filename).suffix or '.jpg'
# Update extension if format changed
if optimized_ext:
ext = optimized_ext
filename = f'room-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
await f.write(optimized_content)
# Verify file was saved
if not file_path.exists():
logger.error(f'File was not saved: {file_path}')
continue
await f.write(content)
image_urls.append(f'/uploads/rooms/{filename}')
logger.info(f'Successfully uploaded and optimized image: {filename} ({len(optimized_content)} bytes)')
image_urls.append(f'/uploads/rooms/{filename}')
except HTTPException:
# Skip invalid images and continue with others
logger.warning(f'Skipping invalid image: {image.filename}')
continue
except Exception as e:
logger.error(f'Error processing image {image.filename}: {str(e)}', exc_info=True)
continue
# Handle existing_images - it might be a list, a JSON string, or None
existing_images = room.images or []
@@ -595,20 +658,44 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
room = db.query(Room).filter(Room.id == id).first()
if not room:
raise HTTPException(status_code=404, detail='Room not found')
# For external URLs, keep the full URL for matching
# For local files, normalize to path
# Normalize the input URL to extract the path part
# The frontend may send a full URL like "http://localhost:8000/uploads/rooms/image.webp"
# but the database stores relative paths like "/uploads/rooms/image.webp"
is_external_url = image_url.startswith('http://') or image_url.startswith('https://')
normalized_url = image_url
normalized_path = image_url
filename = None
if is_external_url:
# For external URLs, use the full URL as-is for matching
normalized_url = image_url
# Extract the path from the full URL
try:
from urllib.parse import urlparse
parsed_url = urlparse(image_url)
normalized_path = parsed_url.path # Extract path like "/uploads/rooms/image.webp"
# Check if it's a local uploads path (not an external image service)
if normalized_path.startswith('/uploads/'):
is_external_url = False # It's a local file with full URL
filename = Path(normalized_path).name
else:
# Truly external URL (like Unsplash)
normalized_path = image_url
except Exception as e:
logger.warning(f'Error parsing URL {image_url}: {str(e)}')
# Fallback: try to extract path manually
if '/uploads/' in image_url:
match = image_url.split('/uploads/', 1)
if len(match) == 2:
normalized_path = f'/uploads/{match[1]}'
is_external_url = False
filename = Path(normalized_path).name
else:
# For local files, normalize the path
if not normalized_url.startswith('/'):
normalized_url = f'/{normalized_url}'
filename = Path(normalized_url).name
# Local file path - normalize it
if not normalized_path.startswith('/'):
normalized_path = f'/{normalized_path}'
filename = Path(normalized_path).name
logger.info(f'Deleting image: original={image_url}, normalized_path={normalized_path}, filename={filename}, is_external={is_external_url}')
# Handle existing_images - it might be a list, a JSON string, or None
existing_images = room.images or []
@@ -626,24 +713,52 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
updated_images = []
for img in existing_images:
# For external URLs, match by full URL (keep images that don't match)
if is_external_url:
# Keep the image if it doesn't match the URL we're deleting
if img != normalized_url:
# Convert stored image to string for comparison
img_str = str(img).strip()
if not img_str:
continue
# Normalize stored image path
stored_path = img_str
stored_is_external = stored_path.startswith('http://') or stored_path.startswith('https://')
if stored_is_external and is_external_url:
# Both are external URLs - match exactly
if img_str != image_url:
updated_images.append(img)
elif stored_is_external and not is_external_url:
# Stored is external, deleting is local - keep it
updated_images.append(img)
elif not stored_is_external and is_external_url:
# Stored is local, deleting is external - keep it
updated_images.append(img)
else:
# For local files, match by path or filename (keep images that don't match)
stored_path = img if img.startswith('/') else f'/{img}'
stored_filename = Path(stored_path).name if '/' in str(stored_path) else stored_path
# Keep the image if it doesn't match any of the comparison criteria
if img != normalized_url and stored_path != normalized_url and (not filename or stored_filename != filename):
# Both are local paths - normalize both for comparison
stored_normalized = stored_path if stored_path.startswith('/') else f'/{stored_path}'
stored_filename = Path(stored_normalized).name if '/' in stored_normalized else stored_path
# Match by full path or by filename
path_matches = (stored_normalized == normalized_path or stored_path == normalized_path)
filename_matches = (filename and stored_filename == filename)
if not (path_matches or filename_matches):
# Keep images that don't match
updated_images.append(img)
# Only try to delete the file if it's a local file (filename exists)
logger.info(f'Images before: {len(existing_images)}, after: {len(updated_images)}')
# Only try to delete the physical file if it's a local file (filename exists)
if filename:
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'rooms' / filename
file_path = Path(__file__).parent.parent.parent.parent / 'uploads' / 'rooms' / filename
if file_path.exists():
file_path.unlink()
try:
file_path.unlink()
logger.info(f'Deleted file: {file_path}')
except Exception as e:
logger.warning(f'Could not delete file {file_path}: {str(e)}')
else:
logger.warning(f'File does not exist: {file_path}')
room.images = updated_images
db.commit()
return {'status': 'success', 'message': 'Image deleted successfully', 'data': {'images': updated_images}}
@@ -651,6 +766,7 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
raise
except Exception as e:
db.rollback()
logger.error(f'Error deleting room image: {str(e)}', exc_info=True, extra={'room_id': id, 'image_url': image_url})
raise HTTPException(status_code=500, detail=str(e))
@router.get('/{id}/booked-dates')