This commit is contained in:
Iliyan Angelov
2025-12-04 01:07:34 +02:00
parent 5fb50983a9
commit 3d634b4fce
92 changed files with 9678 additions and 221 deletions

View File

@@ -1,8 +1,11 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
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
@@ -119,15 +122,23 @@ async def get_maintenance_records(
):
"""Get maintenance records with filtering"""
try:
# Check if user is staff (not admin) - staff should only see their assigned records
# 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)
query = db.query(RoomMaintenance).options(
joinedload(RoomMaintenance.room),
joinedload(RoomMaintenance.assigned_staff)
)
# Filter by assigned_to for staff users
# Filter by assigned_to for staff users - include unassigned records so they can pick them up
if is_staff:
query = query.filter(RoomMaintenance.assigned_to == current_user.id)
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)
@@ -144,6 +155,13 @@ async def get_maintenance_records(
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,
@@ -158,11 +176,16 @@ async def get_maintenance_records(
'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,
'created_at': record.created_at.isoformat() if record.created_at 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 {
@@ -184,16 +207,34 @@ async def get_maintenance_records(
@router.post('/maintenance')
async def create_maintenance_record(
maintenance_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 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')
scheduled_start = datetime.fromisoformat(maintenance_data['scheduled_start'].replace('Z', '+00:00'))
# 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'))
@@ -357,12 +398,13 @@ async def get_housekeeping_tasks(
is_admin = role and role.name == 'admin'
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
query = db.query(HousekeepingTask)
# 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:
query = query.filter(
base_query = base_query.filter(
or_(
HousekeepingTask.assigned_to == current_user.id,
HousekeepingTask.assigned_to.is_(None)
@@ -370,17 +412,31 @@ async def get_housekeeping_tasks(
)
if room_id:
query = query.filter(HousekeepingTask.room_id == room_id)
base_query = base_query.filter(HousekeepingTask.room_id == room_id)
if status:
query = query.filter(HousekeepingTask.status == HousekeepingStatus(status))
base_query = base_query.filter(HousekeepingTask.status == HousekeepingStatus(status))
if task_type:
query = query.filter(HousekeepingTask.task_type == HousekeepingType(task_type))
base_query = base_query.filter(HousekeepingTask.task_type == HousekeepingType(task_type))
if date:
date_obj = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
query = query.filter(func.date(HousekeepingTask.scheduled_time) == date_obj)
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}')
total = query.count()
query = query.order_by(HousekeepingTask.scheduled_time)
# 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()
@@ -390,26 +446,43 @@ async def get_housekeeping_tasks(
# Process existing tasks
for task in tasks:
task_room_ids.add(task.room_id)
result.append({
'id': task.id,
'room_id': task.room_id,
'room_number': task.room.room_number if task.room else None,
'booking_id': task.booking_id,
'task_type': task.task_type.value,
'status': task.status.value,
'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': task.assigned_staff.full_name if task.assigned_staff else None,
'checklist_items': task.checklist_items,
'notes': task.notes,
'quality_score': task.quality_score,
'estimated_duration_minutes': task.estimated_duration_minutes,
'actual_duration_minutes': task.actual_duration_minutes,
'room_status': task.room.status.value if task.room else None
})
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:
@@ -478,7 +551,8 @@ async def get_housekeeping_tasks(
'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
'is_room_status_only': True, # Flag to indicate this is from room status, not a task
'photos': []
})
# Update total count to include cleaning rooms
@@ -498,7 +572,8 @@ async def get_housekeeping_tasks(
}
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(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')
@@ -709,6 +784,8 @@ async def update_housekeeping_task(
task.inspected_at = datetime.utcnow()
if 'inspection_notes' in task_data:
task.inspection_notes = task_data['inspection_notes']
if 'photos' in task_data:
task.photos = task_data['photos']
db.commit()
db.refresh(task)
@@ -746,6 +823,177 @@ async def update_housekeeping_task(
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
title = issue_data.get('title', f'Issue reported from Room {room.room_number}')
description = issue_data.get('description', '')
if task.notes:
description = f"Reported from housekeeping task.\n\nTask Notes: {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=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')
@@ -755,19 +1003,19 @@ async def get_room_inspections(
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')),
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 (not admin) - staff should only see their assigned inspections
# 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 = role and role.name == 'staff'
is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping')
query = db.query(RoomInspection)
# Filter by inspected_by for staff users
if is_staff:
# 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:
@@ -824,16 +1072,27 @@ async def get_room_inspections(
@router.post('/inspections')
async def create_room_inspection(
inspection_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 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'],
@@ -841,7 +1100,7 @@ async def create_room_inspection(
inspection_type=InspectionType(inspection_data.get('inspection_type', 'routine')),
status=InspectionStatus(inspection_data.get('status', 'pending')),
scheduled_at=scheduled_at,
inspected_by=inspection_data.get('inspected_by'),
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')
@@ -865,7 +1124,7 @@ async def create_room_inspection(
async def update_room_inspection(
inspection_id: int,
inspection_data: dict,
current_user: User = Depends(authorize_roles('admin', 'staff')),
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Update a room inspection"""
@@ -874,16 +1133,16 @@ async def update_room_inspection(
if not inspection:
raise HTTPException(status_code=404, detail='Room inspection not found')
# Check if user is staff (not admin) - staff can only update their own assigned inspections
# 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 = role and role.name == 'staff'
is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping')
if is_staff:
# Staff can only update inspections assigned to them
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 can only update status and inspection results
allowed_fields = {'status', 'checklist_items', 'overall_score', 'overall_notes', 'issues_found', 'requires_followup', 'followup_notes'}
# 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')