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

@@ -0,0 +1,9 @@
{
"filename": "backup_hotel_booking_dev_20251202_232932.sql",
"path": "backups/backup_hotel_booking_dev_20251202_232932.sql",
"size_bytes": 526399,
"size_mb": 0.5,
"created_at": "2025-12-02T23:29:32.937870",
"database": "hotel_booking_dev",
"status": "success"
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Master script to seed all test data for the hotel booking system.
This script runs all necessary seed scripts in the correct order.
"""
import sys
import os
from pathlib import Path
# Add the Backend directory to the path
backend_dir = Path(__file__).parent.parent
sys.path.insert(0, str(backend_dir))
# Also add the seeds_data directory like other seed scripts
sys.path.insert(0, str(Path(__file__).parent))
import bcrypt
def ensure_housekeeping_role(db):
"""Ensure housekeeping role exists"""
from src.models.role import Role
housekeeping_role = db.query(Role).filter(Role.name == 'housekeeping').first()
if not housekeeping_role:
print('Creating housekeeping role...')
housekeeping_role = Role(name='housekeeping', description='Housekeeping staff role')
db.add(housekeeping_role)
db.commit()
db.refresh(housekeeping_role)
print('✓ Housekeeping role created')
return housekeeping_role
def ensure_housekeeping_users(db):
"""Ensure housekeeping users exist"""
from src.models.role import Role
from src.models.user import User
housekeeping_role = db.query(Role).filter(Role.name == 'housekeeping').first()
if not housekeeping_role:
print('❌ Housekeeping role not found!')
return
housekeeping_users = [
{
'email': 'housekeeping@gnxsoft.com',
'password': 'housekeeping123',
'full_name': 'Housekeeping Staff',
'phone': '+1 (555) 999-0000'
},
{
'email': 'housekeeping2@gnxsoft.com',
'password': 'housekeeping123',
'full_name': 'Housekeeping Staff 2',
'phone': '+1 (555) 999-0001'
}
]
for user_data in housekeeping_users:
existing = db.query(User).filter(User.email == user_data['email']).first()
if not existing:
password_bytes = user_data['password'].encode('utf-8')
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
user = User(
email=user_data['email'],
password=hashed_password,
full_name=user_data['full_name'],
phone=user_data['phone'],
role_id=housekeeping_role.id,
currency='EUR',
is_active=True
)
db.add(user)
print(f' ✓ Created housekeeping user: {user_data["email"]} - Password: {user_data["password"]}')
else:
print(f' ⚠️ Housekeeping user "{user_data["email"]}" already exists')
db.commit()
def main():
print('=' * 80)
print('SEEDING ALL TEST DATA FOR HOTEL BOOKING SYSTEM')
print('=' * 80)
print()
from src.shared.config.database import SessionLocal
db = SessionLocal()
try:
# Step 1: Ensure housekeeping role exists
print('Step 1: Ensuring housekeeping role exists...')
ensure_housekeeping_role(db)
print()
# Step 2: Ensure housekeeping users exist
print('Step 2: Ensuring housekeeping users exist...')
ensure_housekeeping_users(db)
print()
# Step 3: Import and run seed scripts
print('Step 3: Running seed scripts...')
print()
# Import seed modules
from seeds_data.seed_initial_data import seed_roles, seed_room_types, seed_admin_user
from seeds_data.seed_users import seed_users
from seeds_data.seed_rooms import seed_rooms
from seeds_data.seed_bookings import seed_bookings
# Run seed scripts
print('Running seed_initial_data...')
seed_initial_data.seed_roles(db)
seed_initial_data.seed_room_types(db)
seed_initial_data.seed_admin_user(db)
print()
print('Running seed_users...')
seed_users_module.seed_users(db)
print()
print('Running seed_rooms...')
seed_rooms_module.seed_rooms(db)
print()
print('Running seed_bookings...')
seed_bookings_module.seed_bookings(db)
print()
print('=' * 80)
print('✅ ALL TEST DATA SEEDED SUCCESSFULLY!')
print('=' * 80)
print()
print('📋 Test Accounts:')
print(' Staff: staff@gnxsoft.com / staff123')
print(' Housekeeping: housekeeping@gnxsoft.com / housekeeping123')
print(' Housekeeping 2: housekeeping2@gnxsoft.com / housekeeping123')
print(' Customer: customer@gnxsoft.com / customer123')
print()
print('🧪 To test notifications:')
print(' 1. Log in as staff (staff@gnxsoft.com)')
print(' 2. Go to Bookings and mark a checked_in booking as checked_out')
print(' 3. Log in as housekeeping user in another browser/tab')
print(' 4. You should receive a real-time notification about the room needing cleaning')
print('=' * 80)
except Exception as e:
print(f'\n❌ Error: {e}')
import traceback
traceback.print_exc()
db.rollback()
finally:
db.close()
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""
Script to seed sample bookings for testing check-in/check-out and housekeeping notifications.
"""
import sys
import os
from pathlib import Path
# Add both the seeds_data directory and the Backend directory to the path
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from sqlalchemy import and_
from src.shared.config.database import SessionLocal
# Import all models needed for SQLAlchemy relationship setup
# Import base models first
from src.auth.models.role import Role
from src.auth.models.user import User
from src.rooms.models.room import Room, RoomStatus
from src.rooms.models.room_type import RoomType # For Room relationship
from src.rooms.models.rate_plan import RatePlan # For RoomType relationship
from src.rooms.models.room_maintenance import RoomMaintenance # For Room relationship
from src.rooms.models.room_inspection import RoomInspection # For Room relationship
from src.rooms.models.room_attribute import RoomAttribute # For Room relationship
from src.hotel_services.models.housekeeping_task import HousekeepingTask # For Room relationship
from src.bookings.models.booking import Booking, BookingStatus
from src.bookings.models.group_booking import GroupBooking # For Booking relationship
# Import all related models to satisfy SQLAlchemy relationships
# This is necessary because SQLAlchemy needs all related models loaded
try:
from src.auth.models.refresh_token import RefreshToken
from src.reviews.models.review import Review
from src.reviews.models.favorite import Favorite
from src.hotel_services.models.service import Service # For ServiceBooking relationship
from src.hotel_services.models.service_booking import ServiceBooking
from src.hotel_services.models.service_usage import ServiceUsage # For Booking relationship
from src.bookings.models.checkin_checkout import CheckInCheckOut
from src.payments.models.payment import Payment
from src.payments.models.invoice import Invoice
from src.ai.models.chat import Chat
from src.loyalty.models.user_loyalty import UserLoyalty
from src.loyalty.models.loyalty_tier import LoyaltyTier # For UserLoyalty relationship
from src.loyalty.models.loyalty_point_transaction import LoyaltyPointTransaction # For UserLoyalty relationship
from src.loyalty.models.referral import Referral
from src.loyalty.models.package import Package # For RoomType relationship
from src.guest_management.models.guest_preference import GuestPreference
from src.guest_management.models.guest_note import GuestNote
from src.guest_management.models.guest_communication import GuestCommunication
from src.guest_management.models.guest_tag import GuestTag, guest_tag_association
from src.guest_management.models.guest_segment import GuestSegment, guest_segment_association
# Import any other models that might be related
except ImportError:
pass # Some models might not exist, that's okay
from datetime import datetime, timedelta
import random
import secrets
def get_db():
db = SessionLocal()
try:
return db
finally:
pass
def generate_booking_number() -> str:
"""Generate a unique booking number"""
prefix = 'BK'
ts = int(datetime.utcnow().timestamp() * 1000)
rand = secrets.randbelow(9000) + 1000
return f'{prefix}-{ts}-{rand}'
def seed_bookings(db: Session):
print('=' * 80)
print('SEEDING SAMPLE BOOKINGS FOR TESTING')
print('=' * 80)
# Get roles
customer_role = db.query(Role).filter(Role.name == 'customer').first()
staff_role = db.query(Role).filter(Role.name == 'staff').first()
admin_role = db.query(Role).filter(Role.name == 'admin').first()
if not customer_role:
print(' ❌ Customer role not found! Please seed roles first.')
return
# Get customers
customers = db.query(User).filter(User.role_id == customer_role.id).all()
if not customers:
print(' ❌ No customer users found! Please seed users first.')
return
# Get available rooms
rooms = db.query(Room).all()
if not rooms:
print(' ❌ No rooms found! Please seed rooms first.')
return
print(f'\n✓ Found {len(customers)} customer(s) and {len(rooms)} room(s)')
# Delete existing bookings (optional - comment out if you want to keep existing bookings)
existing_bookings = db.query(Booking).all()
if existing_bookings:
print(f'\n🗑️ Deleting {len(existing_bookings)} existing booking(s)...')
booking_ids = [b.id for b in existing_bookings]
# Delete related records first to avoid foreign key constraints
try:
from src.guest_management.models.guest_complaint import GuestComplaint, ComplaintUpdate
complaints = db.query(GuestComplaint).filter(GuestComplaint.booking_id.in_(booking_ids)).all()
if complaints:
complaint_ids = [c.id for c in complaints]
# Delete complaint updates first
updates = db.query(ComplaintUpdate).filter(ComplaintUpdate.complaint_id.in_(complaint_ids)).all()
if updates:
for update in updates:
db.delete(update)
print(f' ✓ Deleted {len(updates)} complaint update(s)')
# Then delete complaints
for complaint in complaints:
db.delete(complaint)
print(f' ✓ Deleted {len(complaints)} guest complaint(s)')
except ImportError:
pass
for booking in existing_bookings:
db.delete(booking)
db.commit()
print(f'✓ Deleted {len(existing_bookings)} booking(s)')
# Create sample bookings with different statuses
now = datetime.utcnow()
bookings_data = []
# 1. Past bookings (checked out) - These should trigger cleaning notifications
print('\n📅 Creating past bookings (checked out)...')
for i in range(3):
room = random.choice(rooms)
customer = random.choice(customers)
# Check-out was 1-3 days ago
days_ago = random.randint(1, 3)
check_out_date = now - timedelta(days=days_ago)
check_in_date = check_out_date - timedelta(days=random.randint(1, 5))
# Calculate price
base_price = float(room.price) if room.price else 100.0
num_nights = (check_out_date - check_in_date).days
total_price = base_price * num_nights
booking = Booking(
booking_number=generate_booking_number(),
user_id=customer.id,
room_id=room.id,
check_in_date=check_in_date,
check_out_date=check_out_date,
num_guests=random.randint(1, min(room.capacity, 4)),
total_price=total_price,
status=BookingStatus.checked_out,
deposit_paid=True,
requires_deposit=False,
special_requests=f'Sample booking for testing - Checked out {days_ago} day(s) ago'
)
db.add(booking)
db.flush() # Flush to get booking ID
# Set room status to cleaning for checked out bookings
# Check if there's active maintenance first
try:
from src.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 not active_maintenance:
room.status = RoomStatus.cleaning
# Create housekeeping task for checkout cleaning
try:
from src.hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
checkout_checklist = [
{'item': 'Bathroom cleaned', 'completed': False, 'notes': ''},
{'item': 'Beds made with fresh linens', 'completed': False, 'notes': ''},
{'item': 'Trash emptied', 'completed': False, 'notes': ''},
{'item': 'Towels replaced', 'completed': False, 'notes': ''},
{'item': 'Amenities restocked', 'completed': False, 'notes': ''},
{'item': 'Floor vacuumed and mopped', 'completed': False, 'notes': ''},
{'item': 'Surfaces dusted', 'completed': False, 'notes': ''},
{'item': 'Windows and mirrors cleaned', 'completed': False, 'notes': ''},
]
housekeeping_task = HousekeepingTask(
room_id=room.id,
booking_id=booking.id,
task_type=HousekeepingType.checkout,
status=HousekeepingStatus.pending,
scheduled_time=datetime.utcnow(),
created_by=admin_role.id if admin_role else (staff_role.id if staff_role else None),
checklist_items=checkout_checklist,
notes=f'Auto-created on checkout for booking {booking.booking_number}',
estimated_duration_minutes=45
)
db.add(housekeeping_task)
except ImportError:
pass # If housekeeping models not available, skip task creation
except ImportError:
# If maintenance model not available, just set to cleaning
room.status = RoomStatus.cleaning
bookings_data.append({
'number': booking.booking_number,
'room': room.room_number,
'status': 'checked_out',
'check_out': check_out_date.strftime('%Y-%m-%d')
})
print(f' ✓ Created booking {booking.booking_number} - Room {room.room_number}, Checked out {days_ago} day(s) ago')
# 2. Current bookings (checked in) - Guests currently staying
print('\n📅 Creating current bookings (checked in)...')
for i in range(2):
room = random.choice(rooms)
customer = random.choice(customers)
# Checked in 1-3 days ago, checking out in 1-3 days
days_ago = random.randint(1, 3)
check_in_date = now - timedelta(days=days_ago)
check_out_date = now + timedelta(days=random.randint(1, 3))
base_price = float(room.price) if room.price else 100.0
num_nights = (check_out_date - check_in_date).days
total_price = base_price * num_nights
booking = Booking(
booking_number=generate_booking_number(),
user_id=customer.id,
room_id=room.id,
check_in_date=check_in_date,
check_out_date=check_out_date,
num_guests=random.randint(1, min(room.capacity, 4)),
total_price=total_price,
status=BookingStatus.checked_in,
deposit_paid=True,
requires_deposit=False,
special_requests=f'Sample booking for testing - Currently checked in'
)
db.add(booking)
db.flush() # Flush to get booking ID
# Set room status to occupied for checked in bookings
room.status = RoomStatus.occupied
bookings_data.append({
'number': booking.booking_number,
'room': room.room_number,
'status': 'checked_in',
'check_out': check_out_date.strftime('%Y-%m-%d')
})
print(f' ✓ Created booking {booking.booking_number} - Room {room.room_number}, Checked in {days_ago} day(s) ago')
# 3. Future bookings (confirmed) - Upcoming reservations
print('\n📅 Creating future bookings (confirmed)...')
for i in range(3):
room = random.choice(rooms)
customer = random.choice(customers)
# Check-in in 1-7 days, staying for 2-5 days
days_ahead = random.randint(1, 7)
check_in_date = now + timedelta(days=days_ahead)
check_out_date = check_in_date + timedelta(days=random.randint(2, 5))
base_price = float(room.price) if room.price else 100.0
num_nights = (check_out_date - check_in_date).days
total_price = base_price * num_nights
booking = Booking(
booking_number=generate_booking_number(),
user_id=customer.id,
room_id=room.id,
check_in_date=check_in_date,
check_out_date=check_out_date,
num_guests=random.randint(1, min(room.capacity, 4)),
total_price=total_price,
status=BookingStatus.confirmed,
deposit_paid=random.choice([True, False]),
requires_deposit=random.choice([True, False]),
special_requests=f'Sample booking for testing - Future reservation'
)
db.add(booking)
bookings_data.append({
'number': booking.booking_number,
'room': room.room_number,
'status': 'confirmed',
'check_in': check_in_date.strftime('%Y-%m-%d')
})
print(f' ✓ Created booking {booking.booking_number} - Room {room.room_number}, Check-in in {days_ahead} day(s)')
db.commit()
print(f'\n✅ Successfully created {len(bookings_data)} sample bookings!')
print(f'\n📊 Summary:')
checked_out = sum(1 for b in bookings_data if b['status'] == 'checked_out')
checked_in = sum(1 for b in bookings_data if b['status'] == 'checked_in')
confirmed = sum(1 for b in bookings_data if b['status'] == 'confirmed')
print(f' - Checked out: {checked_out} (rooms should be in cleaning status)')
print(f' - Checked in: {checked_in} (guests currently staying)')
print(f' - Confirmed: {confirmed} (upcoming reservations)')
print('\n💡 To test notifications:')
print(' 1. Log in as staff user (staff@gnxsoft.com / staff123)')
print(' 2. Go to Bookings and mark a checked_in booking as checked_out')
print(' 3. Log in as housekeeping user (housekeeping@gnxsoft.com / P4eli240453.)')
print(' 4. You should receive a notification about the room needing cleaning')
print('=' * 80)
def main():
db = get_db()
try:
seed_bookings(db)
except Exception as e:
print(f'\n❌ Error: {e}')
import traceback
traceback.print_exc()
db.rollback()
finally:
db.close()
if __name__ == '__main__':
main()

View File

@@ -1,12 +1,18 @@
import sys
import os
from pathlib import Path
# Add both the seeds_data directory and the Backend directory to the path
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from src.shared.config.database import SessionLocal
from src.models.role import Role
from src.models.room_type import RoomType
from src.models.user import User
# Import all models needed for SQLAlchemy relationship setup
from src.auth.models.role import Role
from src.auth.models.user import User
from src.rooms.models.room_type import RoomType
from src.rooms.models.room import Room
from src.rooms.models.rate_plan import RatePlan # For RoomType relationship
from src.bookings.models.booking import Booking # For Room relationship
import bcrypt
from datetime import datetime

View File

@@ -1,11 +1,13 @@
import sys
import os
from pathlib import Path
# Add both the seeds_data directory and the Backend directory to the path
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from src.shared.config.database import SessionLocal, engine
from src.models.room import Room, RoomStatus
from src.models.room_type import RoomType
from src.rooms.models.room import Room, RoomStatus
from src.rooms.models.room_type import RoomType
from datetime import datetime
import json
import random
@@ -28,9 +30,9 @@ def seed_rooms(db: Session):
print(f'\n✓ Found {len(room_types)} room type(s)')
for rt in room_types:
print(f' - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})')
from src.models.booking import Booking
from src.models.review import Review
from src.models.favorite import Favorite
from src.bookings.models.booking import Booking
from src.reviews.models.review import Review
from src.reviews.models.favorite import Favorite
existing_rooms = db.query(Room).all()
if existing_rooms:
print(f'\n🗑️ Deleting {len(existing_rooms)} existing room(s)...')

View File

@@ -1,11 +1,13 @@
import sys
import os
from pathlib import Path
# Add both the seeds_data directory and the Backend directory to the path
sys.path.insert(0, str(Path(__file__).parent))
sys.path.insert(0, str(Path(__file__).parent.parent))
from sqlalchemy.orm import Session
from src.shared.config.database import SessionLocal
from src.models.role import Role
from src.models.user import User
from src.auth.models.role import Role
from src.auth.models.user import User
import bcrypt
from datetime import datetime
@@ -25,6 +27,7 @@ def seed_users(db: Session):
admin_role = db.query(Role).filter(Role.name == 'admin').first()
staff_role = db.query(Role).filter(Role.name == 'staff').first()
customer_role = db.query(Role).filter(Role.name == 'customer').first()
housekeeping_role = db.query(Role).filter(Role.name == 'housekeeping').first()
if not admin_role or not staff_role or not customer_role:
print(' ❌ Roles not found! Please seed roles first.')
@@ -105,12 +108,38 @@ def seed_users(db: Session):
}
]
# Add housekeeping users if role exists
if housekeeping_role:
users_data.extend([
{
'email': 'housekeeping@gnxsoft.com',
'password': 'housekeeping123',
'full_name': 'Housekeeping Staff',
'phone': '+1 (555) 999-0000',
'role': 'housekeeping',
'currency': 'EUR',
'is_active': True
},
{
'email': 'housekeeping2@gnxsoft.com',
'password': 'housekeeping123',
'full_name': 'Housekeeping Staff 2',
'phone': '+1 (555) 999-0001',
'role': 'housekeeping',
'currency': 'EUR',
'is_active': True
}
])
role_map = {
'admin': admin_role.id,
'staff': staff_role.id,
'customer': customer_role.id
}
if housekeeping_role:
role_map['housekeeping'] = housekeeping_role.id
created_count = 0
skipped_count = 0

View File

@@ -504,6 +504,11 @@ async def upload_avatar(request: Request, image: UploadFile=File(...), current_u
# Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_avatar_size)
# Optimize image before saving
from ...shared.utils.image_optimization import optimize_image_async, ImageType
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.AVATAR)
# Use same path calculation as main.py: go from Backend/src/auth/routes/auth_routes.py
# to Backend/uploads/avatars
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'avatars'
@@ -519,12 +524,17 @@ async def upload_avatar(request: Request, image: UploadFile=File(...), current_u
from ...shared.utils.sanitization import sanitize_filename
original_filename = image.filename or 'avatar.png'
sanitized_filename = sanitize_filename(original_filename)
ext = Path(sanitized_filename).suffix or '.png'
ext = Path(sanitized_filename).suffix or optimized_ext or '.webp'
# Update extension if format changed
if optimized_ext:
ext = optimized_ext
# Generate secure filename with user ID and UUID to prevent collisions
filename = f'avatar-{current_user.id}-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
await f.write(optimized_content)
image_url = f'/uploads/avatars/{filename}'
current_user.avatar = image_url
db.commit()

View File

@@ -770,6 +770,31 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
)
db.add(housekeeping_task)
db.flush() # Flush to get the task ID if needed for notifications
# Send notification to all housekeeping users via dedicated notification system
try:
from ...notifications.routes.notification_routes import notification_manager
task_data_notification = {
'id': housekeeping_task.id,
'room_id': housekeeping_task.room_id,
'room_number': room.room_number,
'task_type': housekeeping_task.task_type.value,
'status': housekeeping_task.status.value,
'scheduled_time': housekeeping_task.scheduled_time.isoformat() if housekeeping_task.scheduled_time else None,
'assigned_to': housekeeping_task.assigned_to,
'booking_number': booking.booking_number,
'created_at': housekeeping_task.created_at.isoformat() if housekeeping_task.created_at else None
}
notification_data = {
'type': 'housekeeping_task_available',
'data': task_data_notification
}
# Send notification to all housekeeping users
await notification_manager.send_to_role('housekeeping', notification_data)
except Exception as e:
logger.error(f'Error sending housekeeping notification: {str(e)}', exc_info=True)
elif new_status == BookingStatus.cancelled:
# Update room status when booking is cancelled
if booking.payments:

View File

@@ -109,7 +109,7 @@ async def delete_banner(id: int, current_user: User=Depends(authorize_roles('adm
if not banner:
raise HTTPException(status_code=404, detail='Banner not found')
if banner.image_url and banner.image_url.startswith('/uploads/banners/'):
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'banners' / Path(banner.image_url).name
file_path = Path(__file__).parent.parent.parent.parent / 'uploads' / 'banners' / Path(banner.image_url).name
if file_path.exists():
file_path.unlink()
db.delete(banner)
@@ -130,7 +130,7 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'File must be an image. Received: {image.content_type}')
if not image.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'banners'
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'banners'
upload_dir.mkdir(parents=True, exist_ok=True)
ext = Path(image.filename).suffix or '.jpg'
filename = f'banner-{uuid.uuid4()}{ext}'
@@ -143,8 +143,17 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur
# Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_size)
# Optimize image before saving
from ...shared.utils.image_optimization import optimize_image_async, ImageType
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.BANNER)
# Update filename extension if format changed
if optimized_ext and optimized_ext != ext:
filename = f'banner-{uuid.uuid4()}{optimized_ext}'
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
await f.write(optimized_content)
image_url = f'/uploads/banners/{filename}'
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)

View File

@@ -532,7 +532,7 @@ async def upload_blog_image(
if not image.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'blog'
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'blog'
upload_dir.mkdir(parents=True, exist_ok=True)
ext = Path(image.filename).suffix or '.jpg'
@@ -546,8 +546,17 @@ async def upload_blog_image(
content = await validate_uploaded_image(image, max_size)
# Optimize image before saving
from ...shared.utils.image_optimization import optimize_image_async, ImageType
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.BLOG)
# Update filename extension if format changed
if optimized_ext and optimized_ext != ext:
filename = f'blog-{uuid.uuid4()}{optimized_ext}'
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
await f.write(optimized_content)
image_url = f'/uploads/blog/{filename}'
base_url = get_base_url(request)

View File

@@ -56,7 +56,7 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(...
if not image.filename:
logger.error('No filename provided in upload request')
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'page-content'
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'page-content'
upload_dir.mkdir(parents=True, exist_ok=True)
logger.info(f'Upload directory: {upload_dir}')
ext = Path(image.filename).suffix or '.jpg'
@@ -70,9 +70,18 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(...
# Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_size)
# Optimize image before saving
from ...shared.utils.image_optimization import optimize_image_async, ImageType
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.PAGE_CONTENT)
# Update filename extension if format changed
if optimized_ext and optimized_ext != ext:
filename = f'page-content-{uuid.uuid4()}{optimized_ext}'
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes')
await f.write(optimized_content)
logger.info(f'File saved successfully: {file_path}, size: {len(optimized_content)} bytes')
image_url = f'/uploads/page-content/{filename}'
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)

View File

@@ -246,8 +246,22 @@ async def serve_upload_file(file_path: str, request: Request):
# Get origin from request
origin = request.headers.get('origin')
# Prepare response
response = FileResponse(str(file_location))
# Determine media type based on file extension
media_type = None
file_ext = file_location.suffix.lower()
if file_ext == '.webp':
media_type = 'image/webp'
elif file_ext in ['.jpg', '.jpeg']:
media_type = 'image/jpeg'
elif file_ext == '.png':
media_type = 'image/png'
elif file_ext == '.gif':
media_type = 'image/gif'
elif file_ext == '.ico':
media_type = 'image/x-icon'
# Prepare response with appropriate media type
response = FileResponse(str(file_location), media_type=media_type)
# Add CORS headers if origin matches
if origin:

View File

@@ -338,7 +338,7 @@ async def websocket_staff_notifications(websocket: WebSocket):
await websocket.close(code=1008, reason='User not found')
return
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role or role.name not in ['staff', 'admin']:
if not role or role.name not in ['staff', 'admin', 'housekeeping']:
await websocket.close(code=1008, reason='Unauthorized role')
return
finally:

View File

@@ -1,16 +1,115 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from fastapi import APIRouter, Depends, HTTPException, Query, Body, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from typing import Optional, List, Dict, Any
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import authorize_roles, get_current_user
from ...auth.models.user import User
from ...auth.models.role import Role
from ..models.notification import NotificationChannel, NotificationStatus, NotificationType
from ..services.notification_service import NotificationService
from pydantic import BaseModel
from datetime import datetime
import json
logger = get_logger(__name__)
router = APIRouter(prefix='/notifications', tags=['notifications'])
# Dedicated Notification Connection Manager (separate from chat)
class NotificationConnectionManager:
"""Manages WebSocket connections for real-time notifications"""
def __init__(self):
self.user_connections: dict[int, WebSocket] = {}
self.role_connections: dict[str, List[WebSocket]] = {
'admin': [],
'staff': [],
'housekeeping': [],
'accountant': []
}
def connect_user(self, user_id: int, websocket: WebSocket, role_name: str):
"""Connect a user to the notification system"""
self.user_connections[user_id] = websocket
if role_name in self.role_connections:
if websocket not in self.role_connections[role_name]:
self.role_connections[role_name].append(websocket)
logger.debug(f'User {user_id} ({role_name}) connected to notification system')
def disconnect_user(self, user_id: int, role_name: str):
"""Disconnect a user from the notification system"""
websocket = None
if user_id in self.user_connections:
websocket = self.user_connections[user_id]
del self.user_connections[user_id]
if role_name in self.role_connections and websocket:
self.role_connections[role_name] = [
ws for ws in self.role_connections[role_name]
if ws != websocket
]
logger.debug(f'User {user_id} ({role_name}) disconnected from notification system')
async def send_to_user(self, user_id: int, message: dict):
"""Send notification to a specific user"""
if user_id in self.user_connections:
try:
websocket = self.user_connections[user_id]
await websocket.send_json(message)
return True
except Exception as e:
logger.error(f'Error sending notification to user {user_id}: {str(e)}', exc_info=True)
# Remove broken connection
if user_id in self.user_connections:
del self.user_connections[user_id]
return False
return False
async def send_to_role(self, role_name: str, message: dict):
"""Send notification to all users with a specific role"""
if role_name not in self.role_connections:
return 0
disconnected = []
sent_count = 0
for websocket in self.role_connections[role_name]:
try:
await websocket.send_json(message)
sent_count += 1
except Exception as e:
logger.error(f'Error sending notification to {role_name} user: {str(e)}', exc_info=True)
disconnected.append(websocket)
# Clean up disconnected websockets
for ws in disconnected:
if ws in self.role_connections[role_name]:
self.role_connections[role_name].remove(ws)
return sent_count
async def broadcast(self, message: dict):
"""Broadcast notification to all connected users"""
disconnected = []
sent_count = 0
for user_id, websocket in self.user_connections.items():
try:
await websocket.send_json(message)
sent_count += 1
except Exception as e:
logger.error(f'Error broadcasting to user {user_id}: {str(e)}', exc_info=True)
disconnected.append(user_id)
# Clean up disconnected websockets
for user_id in disconnected:
if user_id in self.user_connections:
del self.user_connections[user_id]
return sent_count
# Global notification manager instance
notification_manager = NotificationConnectionManager()
# Request/Response Models
class NotificationSendRequest(BaseModel):
user_id: Optional[int] = None
@@ -304,3 +403,98 @@ async def update_preferences(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# WebSocket endpoint for real-time notifications
@router.websocket('/ws')
async def websocket_notifications(websocket: WebSocket):
"""WebSocket endpoint for real-time notifications (separate from chat)"""
current_user = None
role_name = None
try:
await websocket.accept()
# Security: Read token from cookies
token = None
if websocket.cookies:
token = websocket.cookies.get('accessToken')
# Fallback to query parameter for backward compatibility
if not token:
query_params = dict(websocket.query_params)
token = query_params.get('token')
if not token:
await websocket.close(code=1008, reason='Token required')
return
try:
from ...security.middleware.auth import verify_token
from ...shared.config.database import get_db
payload = verify_token(token)
user_id = payload.get('userId')
if not user_id:
await websocket.close(code=1008, reason='Invalid token payload')
return
db_gen = get_db()
db = next(db_gen)
try:
current_user = db.query(User).filter(User.id == user_id).first()
if not current_user:
await websocket.close(code=1008, reason='User not found')
return
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role:
await websocket.close(code=1008, reason='User role not found')
return
role_name = role.name
finally:
db.close()
except Exception as e:
logger.error(f'WebSocket token verification error: {str(e)}', exc_info=True)
await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}')
return
# Connect user to notification system
notification_manager.connect_user(current_user.id, websocket, role_name)
try:
await websocket.send_json({
'type': 'connected',
'data': {
'message': 'Connected to notification system',
'user_id': current_user.id,
'role': role_name
}
})
except Exception as e:
logger.error(f'Error sending initial notification message: {str(e)}', exc_info=True, extra={'user_id': current_user.id})
# Keep connection alive and handle ping/pong
while True:
try:
data = await websocket.receive_text()
try:
message_data = json.loads(data)
if message_data.get('type') == 'ping':
await websocket.send_json({'type': 'pong', 'data': 'pong'})
except json.JSONDecodeError:
await websocket.send_json({'type': 'pong', 'data': 'pong'})
except WebSocketDisconnect:
logger.info('Notification WebSocket disconnected normally', extra={'user_id': current_user.id})
break
except Exception as e:
logger.error(f'Notification WebSocket receive error: {str(e)}', exc_info=True, extra={'user_id': current_user.id})
break
except WebSocketDisconnect:
logger.info('Notification WebSocket disconnected')
except Exception as e:
logger.error(f'Notification WebSocket error: {str(e)}', exc_info=True)
finally:
if current_user and role_name:
try:
notification_manager.disconnect_user(current_user.id, role_name)
except:
pass

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

View File

@@ -0,0 +1,252 @@
"""
Image optimization utility for compressing and resizing images before serving to frontend.
Reduces file size while maintaining visual quality.
"""
from PIL import Image, ImageOps
import io
from typing import Optional, Tuple
from enum import Enum
from ...shared.config.logging_config import get_logger
logger = get_logger(__name__)
class ImageType(Enum):
"""Image types with different optimization settings"""
AVATAR = "avatar" # Small profile images
BANNER = "banner" # Large banner images
ROOM = "room" # Room gallery images
BLOG = "blog" # Blog post images
PAGE_CONTENT = "page_content" # General page content images
COMPANY = "company" # Company logos/favicons
GALLERY = "gallery" # General gallery images
# Optimization settings for different image types
IMAGE_OPTIMIZATION_CONFIG = {
ImageType.AVATAR: {
"max_width": 400,
"max_height": 400,
"quality": 85,
"convert_to_webp": True,
"webp_quality": 80,
},
ImageType.BANNER: {
"max_width": 1920,
"max_height": 1080,
"quality": 85,
"convert_to_webp": True,
"webp_quality": 82,
},
ImageType.ROOM: {
"max_width": 1920,
"max_height": 1920,
"quality": 85,
"convert_to_webp": True,
"webp_quality": 85,
},
ImageType.BLOG: {
"max_width": 1920,
"max_height": 1920,
"quality": 85,
"convert_to_webp": True,
"webp_quality": 85,
},
ImageType.PAGE_CONTENT: {
"max_width": 1920,
"max_height": 1920,
"quality": 85,
"convert_to_webp": True,
"webp_quality": 85,
},
ImageType.COMPANY: {
"max_width": 512,
"max_height": 512,
"quality": 90,
"convert_to_webp": True,
"webp_quality": 88,
},
ImageType.GALLERY: {
"max_width": 1920,
"max_height": 1920,
"quality": 85,
"convert_to_webp": True,
"webp_quality": 85,
},
}
def optimize_image(
image_bytes: bytes,
image_type: ImageType,
preserve_original_format: bool = False
) -> Tuple[bytes, str]:
"""
Optimize an image by resizing, compressing, and optionally converting to WebP.
Args:
image_bytes: Original image as bytes
image_type: Type of image (determines optimization settings)
preserve_original_format: If True, keep original format. If False, convert to WebP when beneficial.
Returns:
Tuple of (optimized_image_bytes, file_extension)
"""
try:
# Get optimization config for this image type
config = IMAGE_OPTIMIZATION_CONFIG.get(image_type, IMAGE_OPTIMIZATION_CONFIG[ImageType.GALLERY])
# Open and process image
img = Image.open(io.BytesIO(image_bytes))
# Convert to RGB if necessary (removes transparency, necessary for JPEG)
# For WebP, we can preserve transparency if original format supports it
original_format = img.format
has_transparency = img.mode in ('RGBA', 'LA', 'P') and 'transparency' in img.info
# Auto-orient image based on EXIF data
try:
img = ImageOps.exif_transpose(img)
except Exception:
# If EXIF orientation fails, continue with original image
pass
# Calculate new dimensions while maintaining aspect ratio
width, height = img.size
max_width = config["max_width"]
max_height = config["max_height"]
if width > max_width or height > max_height:
# Calculate scaling factor
scale = min(max_width / width, max_height / height)
new_width = int(width * scale)
new_height = int(height * scale)
# Use high-quality resampling for downscaling
# Fallback for older Pillow versions
try:
resampling = Image.Resampling.LANCZOS
except AttributeError:
resampling = Image.LANCZOS
img = img.resize((new_width, new_height), resampling)
logger.debug(
f"Resized image from {width}x{height} to {new_width}x{new_height} "
f"(scale: {scale:.2f})"
)
# Decide on output format
output_format = original_format or 'JPEG'
output_extension = '.jpg'
# Convert to WebP if configured and beneficial
if config.get("convert_to_webp", True) and not preserve_original_format:
# WebP typically provides 25-35% better compression
output_format = 'WEBP'
output_extension = '.webp'
# Preserve transparency for WebP if original had it
if has_transparency:
img = img.convert('RGBA')
else:
img = img.convert('RGB')
else:
# For JPEG/other formats, convert to RGB (removes transparency)
if output_format in ('JPEG', 'JPG'):
if img.mode != 'RGB':
# Create white background for images with transparency
if img.mode in ('RGBA', 'LA'):
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'RGBA':
background.paste(img, mask=img.split()[3]) # Use alpha channel as mask
else:
background.paste(img)
img = background
else:
img = img.convert('RGB')
# Keep original format extension
if original_format:
output_extension = f'.{original_format.lower()}'
# Compress and save to bytes
output = io.BytesIO()
if output_format == 'WEBP':
quality = config.get("webp_quality", 85)
img.save(
output,
format='WEBP',
quality=quality,
method=6 # Method 6 is slower but produces better compression
)
elif output_format in ('JPEG', 'JPG'):
quality = config.get("quality", 85)
img.save(
output,
format='JPEG',
quality=quality,
optimize=True, # Enable JPEG optimization
progressive=True # Progressive JPEG for better perceived loading
)
elif output_format == 'PNG':
# PNG compression
img.save(
output,
format='PNG',
optimize=True,
compress_level=9 # Maximum compression (0-9)
)
else:
# For other formats (GIF, etc.), save as-is with optimization
img.save(output, format=output_format, optimize=True)
optimized_bytes = output.getvalue()
# Log optimization results
original_size = len(image_bytes)
optimized_size = len(optimized_bytes)
reduction_percent = ((original_size - optimized_size) / original_size * 100) if original_size > 0 else 0
logger.info(
f"Image optimized: {original_size} bytes -> {optimized_size} bytes "
f"({reduction_percent:.1f}% reduction, type: {image_type.value})"
)
return optimized_bytes, output_extension
except Exception as e:
logger.error(f"Error optimizing image: {str(e)}", exc_info=True)
# Return original image if optimization fails
logger.warning("Returning original image due to optimization error")
original_format = Image.open(io.BytesIO(image_bytes)).format or 'JPEG'
extension = f'.{original_format.lower()}' if original_format else '.jpg'
return image_bytes, extension
async def optimize_image_async(
image_bytes: bytes,
image_type: ImageType,
preserve_original_format: bool = False
) -> Tuple[bytes, str]:
"""
Async wrapper for optimize_image (for use in async endpoints).
Args:
image_bytes: Original image as bytes
image_type: Type of image (determines optimization settings)
preserve_original_format: If True, keep original format. If False, convert to WebP when beneficial.
Returns:
Tuple of (optimized_image_bytes, file_extension)
"""
import asyncio
# Run the CPU-intensive optimization in a thread pool to avoid blocking
loop = asyncio.get_event_loop()
return await loop.run_in_executor(
None,
optimize_image,
image_bytes,
image_type,
preserve_original_format
)

View File

@@ -740,7 +740,7 @@ async def upload_borica_certificate(
)
# Create upload directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "certificates" / "borica"
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "certificates" / "borica"
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
@@ -1370,22 +1370,18 @@ async def upload_company_logo(
):
try:
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Validate image using comprehensive validation
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
max_size = 2 * 1024 * 1024 # 2MB for logos
content = await validate_uploaded_image(image, max_size)
content = await image.read()
if len(content) > 2 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Logo file size must be less than 2MB"
)
# Optimize image before saving
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.COMPANY, preserve_original_format=False)
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
@@ -1394,7 +1390,7 @@ async def upload_company_logo(
).first()
if old_logo_setting and old_logo_setting.value:
old_logo_path = Path(__file__).parent.parent.parent / old_logo_setting.value.lstrip('/')
old_logo_path = Path(__file__).parent.parent.parent.parent / old_logo_setting.value.lstrip('/')
if old_logo_path.exists() and old_logo_path.is_file():
try:
old_logo_path.unlink()
@@ -1402,14 +1398,14 @@ async def upload_company_logo(
logger.warning(f"Could not delete old logo: {e}")
ext = Path(image.filename).suffix or '.png'
filename = "logo.png"
# Use optimized extension, default to webp or png
ext = optimized_ext or '.webp'
filename = f"logo{ext}"
file_path = upload_dir / filename
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
await f.write(optimized_content)
image_url = f"/uploads/company/{filename}"
@@ -1462,32 +1458,54 @@ async def upload_company_favicon(
):
try:
if not image.content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type could not be determined"
)
# Validate image
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
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico']
if image.content_type not in allowed_types:
max_size = 500 * 1024 # 500KB for favicons
filename_lower = (image.filename or '').lower()
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']):
# For favicons, we need to check if it's SVG (text format) or image format
filename_lower = (image.filename or '').lower()
is_svg = filename_lower.endswith('.svg')
if is_svg:
# For SVG, just validate size (can't optimize SVG with PIL)
content = await image.read()
if len(content) > max_size:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon must be .ico, .png, or .svg file"
detail="Favicon file size must be less than 500KB"
)
# Keep SVG as-is
optimized_content = content
optimized_ext = '.svg'
else:
# For ICO/PNG, validate and optimize
if not image.content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type could not be determined"
)
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/ico']
if image.content_type not in allowed_types:
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png']):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon must be .ico or .png file"
)
content = await image.read()
if len(content) > 500 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon file size must be less than 500KB"
content = await validate_uploaded_image(image, max_size)
# Optimize favicon but preserve format (ICO/PNG)
optimized_content, optimized_ext = await optimize_image_async(
content,
ImageType.COMPANY,
preserve_original_format=True # Keep original format for favicons
)
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
@@ -1496,7 +1514,7 @@ async def upload_company_favicon(
).first()
if old_favicon_setting and old_favicon_setting.value:
old_favicon_path = Path(__file__).parent.parent.parent / old_favicon_setting.value.lstrip('/')
old_favicon_path = Path(__file__).parent.parent.parent.parent / old_favicon_setting.value.lstrip('/')
if old_favicon_path.exists() and old_favicon_path.is_file():
try:
old_favicon_path.unlink()
@@ -1504,11 +1522,11 @@ async def upload_company_favicon(
logger.warning(f"Could not delete old favicon: {e}")
filename_lower = (image.filename or '').lower()
if filename_lower.endswith('.ico'):
filename = "favicon.ico"
elif filename_lower.endswith('.svg'):
# Determine filename based on original or optimized extension
if is_svg or filename_lower.endswith('.svg'):
filename = "favicon.svg"
elif optimized_ext == '.ico' or filename_lower.endswith('.ico'):
filename = "favicon.ico"
else:
filename = "favicon.png"
@@ -1516,7 +1534,7 @@ async def upload_company_favicon(
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
await f.write(optimized_content)
image_url = f"/uploads/company/{filename}"

View File

@@ -7,6 +7,8 @@ import {
X,
CheckCircle,
Clock,
RefreshCw,
Play,
} from 'lucide-react';
import { toast } from 'react-toastify';
import Loading from '../../../shared/components/Loading';
@@ -66,10 +68,23 @@ const HousekeepingManagement: React.FC = () => {
fetchTasks();
}, [currentPage, filters]);
// Auto-refresh every 30 seconds for real-time updates
useEffect(() => {
const interval = setInterval(() => {
fetchTasks();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [currentPage, filters]);
const fetchTasks = async () => {
try {
setLoading(true);
const params: any = { page: currentPage, limit: 10 };
const params: any = {
page: currentPage,
limit: 10,
include_cleaning_rooms: true // Include rooms in cleaning status
};
if (filters.room_id) params.room_id = parseInt(filters.room_id);
if (filters.status) params.status = filters.status;
if (filters.task_type) params.task_type = filters.task_type;
@@ -176,7 +191,30 @@ const HousekeepingManagement: React.FC = () => {
setShowModal(true);
};
const handleStartTask = async (task: HousekeepingTask) => {
if (!task.id) {
toast.error('Cannot start task: Invalid task ID');
return;
}
try {
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'in_progress',
assigned_to: userInfo?.id, // Assign to current user when starting
});
toast.success('Task started successfully');
fetchTasks();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to start task');
}
};
const handleMarkAsDone = async (task: HousekeepingTask) => {
if (!task.id) {
toast.error('Cannot complete task: Invalid task ID');
return;
}
// Double check that the task is assigned to the current user
if (!task.assigned_to) {
toast.error('Task must be assigned before it can be marked as done');
@@ -192,7 +230,7 @@ const HousekeepingManagement: React.FC = () => {
status: 'completed',
checklist_items: task.checklist_items?.map(item => ({ ...item, completed: true })) || [],
});
toast.success('Task marked as completed successfully');
toast.success('Task marked as completed successfully. Room is now ready for check-in.');
fetchTasks();
} catch (error: any) {
toast.error(error.response?.data?.detail || 'Failed to mark task as done');
@@ -322,15 +360,26 @@ const HousekeepingManagement: React.FC = () => {
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{(isAdmin || userInfo?.role === 'staff') && (
<div className="flex items-center space-x-2">
<button
onClick={handleCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
onClick={fetchTasks}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 transition-colors disabled:opacity-50"
title="Refresh tasks"
>
<Plus className="w-4 h-4" />
<span>New Task</span>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Refresh</span>
</button>
)}
{(isAdmin || userInfo?.role === 'staff') && (
<button
onClick={handleCreate}
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<Plus className="w-4 h-4" />
<span>New Task</span>
</button>
)}
</div>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
@@ -347,18 +396,34 @@ const HousekeepingManagement: React.FC = () => {
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tasks.map((task) => {
{tasks.map((task, index) => {
const completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
const totalItems = task.checklist_items?.length || 0;
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
const isRoomStatusOnly = task.is_room_status_only || task.id === null;
const isCleaningRoom = task.room_status === 'cleaning' || isRoomStatusOnly;
return (
<tr key={task.id} className="hover:bg-gray-50">
<tr
key={task.id || `room-${task.room_id}-${index}`}
className={`hover:bg-gray-50 ${isCleaningRoom ? 'bg-amber-50/50' : ''}`}
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{task.room_number || `Room ${task.room_id}`}</div>
<div className="flex items-center space-x-2">
<div className="text-sm font-medium text-gray-900">
{task.room_number || `Room ${task.room_id}`}
</div>
{isCleaningRoom && (
<span className="px-2 py-0.5 text-xs font-semibold rounded-full bg-amber-100 text-amber-800 border border-amber-200">
Cleaning
</span>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-500 capitalize">{task.task_type}</div>
<div className="text-sm text-gray-500 capitalize">
{isRoomStatusOnly ? 'Room Status' : task.task_type}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(task.status)}`}>
@@ -366,60 +431,107 @@ const HousekeepingManagement: React.FC = () => {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(task.scheduled_time).toLocaleString()}
{task.scheduled_time ? new Date(task.scheduled_time).toLocaleString() : 'N/A'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{task.assigned_staff_name || 'Unassigned'}
{task.assigned_staff_name || (isRoomStatusOnly ? 'Not Assigned' : 'Unassigned')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="w-16 bg-gray-200 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${progress}%` }}
/>
{totalItems > 0 ? (
<div className="flex items-center">
<div className="w-16 bg-gray-200 rounded-full h-2 mr-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-gray-600">{progress}%</span>
</div>
<span className="text-xs text-gray-600">{progress}%</span>
</div>
) : (
<span className="text-xs text-gray-400">No checklist</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => setViewingTask(task)}
className="text-blue-600 hover:text-blue-900"
title="View task"
>
<Eye className="w-4 h-4" />
</button>
{isAdmin ? (
{task.id && (
<button
onClick={() => handleEdit(task)}
className="text-indigo-600 hover:text-indigo-900"
title="Edit task"
onClick={() => setViewingTask(task)}
className="text-blue-600 hover:text-blue-900"
title="View task"
>
<Edit className="w-4 h-4" />
<Eye className="w-4 h-4" />
</button>
)}
{isRoomStatusOnly ? (
// For room status entries, allow creating a task
(isAdmin || userInfo?.role === 'staff') && (
<button
onClick={() => {
setFormData({
room_id: task.room_id.toString(),
booking_id: '',
task_type: 'vacant',
scheduled_time: new Date(),
assigned_to: '',
checklist_items: [],
notes: '',
estimated_duration_minutes: '',
});
setEditingTask(null);
setShowModal(true);
}}
className="text-indigo-600 hover:text-indigo-900"
title="Create task for this room"
>
<Plus className="w-4 h-4" />
</button>
)
) : (
// Housekeeping and staff can only edit their own assigned tasks
(isHousekeeping || userInfo?.role === 'staff') &&
task.assigned_to === userInfo?.id &&
task.status !== 'completed' && (
<>
<button
onClick={() => handleEdit(task)}
className="text-indigo-600 hover:text-indigo-900"
title="Update task"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={() => handleMarkAsDone(task)}
className="text-green-600 hover:text-green-900"
title="Mark as done"
>
<CheckCircle className="w-4 h-4" />
</button>
</>
// For actual tasks
isAdmin ? (
<button
onClick={() => handleEdit(task)}
className="text-indigo-600 hover:text-indigo-900"
title="Edit task"
>
<Edit className="w-4 h-4" />
</button>
) : (
// Housekeeping and staff actions
(isHousekeeping || userInfo?.role === 'staff') && (
<>
{task.status === 'pending' && !task.assigned_to && (
// Show Start button for unassigned pending tasks
<button
onClick={() => handleStartTask(task)}
className="text-blue-600 hover:text-blue-900"
title="Start cleaning this room"
>
<Play className="w-4 h-4" />
</button>
)}
{task.assigned_to === userInfo?.id && task.status !== 'completed' && (
<>
<button
onClick={() => handleEdit(task)}
className="text-indigo-600 hover:text-indigo-900"
title="Update task"
>
<Edit className="w-4 h-4" />
</button>
{task.status === 'in_progress' && (
<button
onClick={() => handleMarkAsDone(task)}
className="text-green-600 hover:text-green-900"
title="Mark as done - Room ready for check-in"
>
<CheckCircle className="w-4 h-4" />
</button>
)}
</>
)}
</>
)
)
)}
</div>

View File

@@ -26,7 +26,7 @@ export interface MaintenanceRecord {
}
export interface HousekeepingTask {
id: number;
id: number | null; // null for room status entries without tasks
room_id: number;
room_number?: string;
booking_id?: number;
@@ -42,6 +42,8 @@ export interface HousekeepingTask {
quality_score?: number;
estimated_duration_minutes?: number;
actual_duration_minutes?: number;
room_status?: 'available' | 'occupied' | 'maintenance' | 'cleaning';
is_room_status_only?: boolean; // Flag to indicate this is from room status, not a task
}
export interface ChecklistItem {

View File

@@ -526,6 +526,7 @@ const IPWhitelistTab: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Desktop Table */}
@@ -701,6 +702,7 @@ const IPBlacklistTab: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Desktop Table */}
@@ -1012,6 +1014,7 @@ const OAuthProvidersTab: React.FC = () => {
</div>
</div>
</div>
</div>
)}
{/* Desktop Table */}

View File

@@ -356,23 +356,29 @@ const HousekeepingDashboardPage: React.FC = () => {
date: today,
page: 1,
limit: 50,
include_cleaning_rooms: true, // Include rooms in cleaning status
});
if (response.status === 'success' && response.data?.tasks) {
const userTasks = response.data.tasks.filter(
(task: HousekeepingTask) => task.assigned_to === userInfo?.id
// Backend already filters to show assigned tasks and unassigned tasks
// Just filter out completed tasks that aren't assigned to current user
const allTasks = response.data.tasks.filter(
(task: HousekeepingTask) =>
task.assigned_to === userInfo?.id || // Assigned to current user
!task.assigned_to || // Unassigned tasks (can be picked up)
task.is_room_status_only // Room status entries
);
setTasks(userTasks);
setTasks(allTasks);
const pending = userTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
const pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
const in_progress = allTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
const completed = allTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
setStats({
pending,
in_progress,
completed,
total: userTasks.length,
total: allTasks.length,
});
} else {
setTasks([]);
@@ -406,6 +412,8 @@ const HousekeepingDashboardPage: React.FC = () => {
};
useEffect(() => {
if (!userInfo?.id) return;
fetchTasks();
const interval = setInterval(() => {
@@ -414,11 +422,168 @@ const HousekeepingDashboardPage: React.FC = () => {
}
}, 30000);
// WebSocket connection for real-time notifications
let websocket: WebSocket | null = null;
let reconnectTimeout: NodeJS.Timeout | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let isIntentionallyClosing = false;
const connectWebSocket = () => {
// Don't connect if already connected or intentionally closing
if (websocket && (websocket.readyState === WebSocket.CONNECTING || websocket.readyState === WebSocket.OPEN)) {
return;
}
try {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const normalizedBase = baseUrl.replace(/\/$/, '');
const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws';
const wsBase = normalizedBase.replace(/^https?/, wsProtocol);
const wsUrl = `${wsBase}/api/notifications/ws`;
logger.debug('Attempting to connect WebSocket...', { url: wsUrl });
websocket = new WebSocket(wsUrl);
websocket.onopen = () => {
logger.debug('Housekeeping notification WebSocket connected');
isIntentionallyClosing = false;
// Clear any pending reconnect
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
// Set up ping interval to keep connection alive (every 30 seconds)
pingInterval = setInterval(() => {
if (websocket && websocket.readyState === WebSocket.OPEN) {
try {
websocket.send(JSON.stringify({ type: 'ping' }));
} catch (error) {
logger.error('Error sending ping', error);
}
}
}, 30000);
};
websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Handle pong responses
if (data.type === 'pong' || data.type === 'connected') {
return;
}
if (data.type === 'housekeeping_task_available') {
const taskData = data.data;
toast.info(
`New cleaning task available: Room ${taskData.room_number || taskData.room_id}`,
{
onClick: () => {
fetchTasks();
},
autoClose: 10000,
}
);
// Refresh tasks to show the new one
fetchTasks();
} else if (data.type === 'housekeeping_task_assigned') {
const taskData = data.data;
if (taskData.assigned_to === userInfo?.id) {
toast.success(
`You've been assigned to clean Room ${taskData.room_number || taskData.room_id}`,
{
onClick: () => {
fetchTasks();
},
autoClose: 8000,
}
);
fetchTasks();
}
}
} catch (error) {
logger.error('Error parsing WebSocket message', error);
}
};
websocket.onerror = (error) => {
logger.error('Housekeeping notification WebSocket error', error);
};
websocket.onclose = (event) => {
logger.debug('Housekeeping notification WebSocket disconnected', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean
});
// Clear ping interval
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
// Only attempt to reconnect if:
// 1. It wasn't an intentional close (code 1000)
// 2. It wasn't a normal navigation away (code 1001)
// 3. We're not already trying to reconnect
if (!isIntentionallyClosing && event.code !== 1000 && event.code !== 1001 && !reconnectTimeout) {
logger.debug('Scheduling WebSocket reconnection...');
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
logger.debug('Attempting to reconnect WebSocket...');
connectWebSocket();
}, 5000);
}
};
} catch (error) {
logger.error('Error creating WebSocket connection', error);
// Schedule retry on error
if (!reconnectTimeout) {
reconnectTimeout = setTimeout(() => {
reconnectTimeout = null;
connectWebSocket();
}, 5000);
}
}
};
// Delay connection slightly to ensure cookies are available
const connectionTimeout = setTimeout(() => {
connectWebSocket();
}, 500);
return () => {
if (tasksAbortRef.current) {
tasksAbortRef.current.abort();
}
clearInterval(interval);
clearTimeout(connectionTimeout);
isIntentionallyClosing = true;
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
reconnectTimeout = null;
}
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (websocket) {
try {
if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) {
websocket.close(1000, 'Component unmounting');
}
} catch (error) {
logger.error('Error closing WebSocket', error);
}
websocket = null;
}
};
}, [userInfo?.id]);
@@ -513,17 +678,69 @@ const HousekeepingDashboardPage: React.FC = () => {
}, [currentFloorTasks]);
const handleStartTask = async (task: HousekeepingTask) => {
// If task has no ID, it's a room status entry - create a task first
if (!task.id) {
if (updatingTasks.has(task.room_id)) return;
setUpdatingTasks(prev => new Set(prev).add(task.room_id));
try {
// Create a new task for this room
const defaultChecklist = [
{ 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: '' }
];
const createResponse = await advancedRoomService.createHousekeepingTask({
room_id: task.room_id,
task_type: task.task_type || 'vacant',
scheduled_time: new Date().toISOString(),
assigned_to: userInfo?.id,
checklist_items: defaultChecklist,
notes: 'Task created by housekeeping staff',
estimated_duration_minutes: 45
});
if (createResponse.status === 'success' && createResponse.data?.task_id) {
// Now update the newly created task to in_progress
await advancedRoomService.updateHousekeepingTask(createResponse.data.task_id, {
status: 'in_progress',
assigned_to: userInfo?.id,
});
toast.success('Task created and started successfully! Room cleaning in progress.');
await fetchTasks();
} else {
toast.error('Failed to create task');
}
} catch (error: any) {
logger.error('Error creating and starting task', error);
toast.error(error.response?.data?.detail || 'Failed to create and start task');
} finally {
setUpdatingTasks(prev => {
const newSet = new Set(prev);
newSet.delete(task.room_id);
return newSet;
});
}
return;
}
// Handle existing task
if (updatingTasks.has(task.id)) return;
setUpdatingTasks(prev => new Set(prev).add(task.id));
try {
await advancedRoomService.updateHousekeepingTask(task.id, {
status: 'in_progress',
assigned_to: userInfo?.id, // Assign to current user when starting
});
toast.success('Task started successfully!');
toast.success('Task started successfully! Room cleaning in progress.');
await fetchTasks();
if (selectedTask?.id === task.id) {
setSelectedTask({ ...task, status: 'in_progress' });
setSelectedTask({ ...task, status: 'in_progress', assigned_to: userInfo?.id });
}
} catch (error: any) {
logger.error('Error starting task', error);
@@ -538,6 +755,10 @@ const HousekeepingDashboardPage: React.FC = () => {
};
const handleUpdateChecklist = async (task: HousekeepingTask, itemIndex: number, checked: boolean) => {
if (!task.id) {
toast.error('Cannot update checklist: Invalid task ID');
return;
}
if (updatingTasks.has(task.id)) return;
if (!task.checklist_items) return;
@@ -586,6 +807,10 @@ const HousekeepingDashboardPage: React.FC = () => {
};
const handleCompleteTask = async (task: HousekeepingTask) => {
if (!task.id) {
toast.error('Cannot complete task: Invalid task ID');
return;
}
if (updatingTasks.has(task.id)) return;
const allCompleted = task.checklist_items?.every(item => item.completed) ?? true;
@@ -608,7 +833,7 @@ const HousekeepingDashboardPage: React.FC = () => {
status: 'completed',
checklist_items: updatedChecklist,
});
toast.success('Task completed successfully! 🎉');
toast.success('Task completed successfully! Room is now ready for check-in. 🎉');
await fetchTasks();
closeTaskModal();
} catch (error: any) {
@@ -854,17 +1079,17 @@ const HousekeepingDashboardPage: React.FC = () => {
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
{currentFloorTasks.map((task) => {
{currentFloorTasks.map((task, index) => {
const completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
const totalItems = task.checklist_items?.length || 0;
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
const isUpdating = updatingTasks.has(task.id);
const canStart = task.status === 'pending';
const canComplete = task.status === 'in_progress' || task.status === 'pending';
const isUpdating = task.id ? updatingTasks.has(task.id) : updatingTasks.has(task.room_id);
const canStart = task.status === 'pending' && (!task.assigned_to || task.assigned_to === userInfo?.id);
const canComplete = task.id && (task.status === 'in_progress' || task.status === 'pending') && task.assigned_to === userInfo?.id;
return (
<div
key={task.id}
key={task.id || `room-${task.room_id}-${index}`}
className="enterprise-card p-4 sm:p-5 hover:shadow-2xl transition-all duration-300 cursor-pointer group border border-gray-200/50"
onClick={() => openTaskModal(task)}
>