updates
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
3995
Backend/backups/backup_hotel_booking_dev_20251202_232932.sql
Normal file
3995
Backend/backups/backup_hotel_booking_dev_20251202_232932.sql
Normal file
File diff suppressed because one or more lines are too long
BIN
Backend/seeds_data/__pycache__/seed_bookings.cpython-312.pyc
Normal file
BIN
Backend/seeds_data/__pycache__/seed_bookings.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/seeds_data/__pycache__/seed_initial_data.cpython-312.pyc
Normal file
BIN
Backend/seeds_data/__pycache__/seed_initial_data.cpython-312.pyc
Normal file
Binary file not shown.
154
Backend/seeds_data/seed_all_test_data.py
Executable file
154
Backend/seeds_data/seed_all_test_data.py
Executable 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()
|
||||||
|
|
||||||
329
Backend/seeds_data/seed_bookings.py
Executable file
329
Backend/seeds_data/seed_bookings.py
Executable 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()
|
||||||
|
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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))
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from src.shared.config.database import SessionLocal
|
from src.shared.config.database import SessionLocal
|
||||||
from src.models.role import Role
|
# Import all models needed for SQLAlchemy relationship setup
|
||||||
from src.models.room_type import RoomType
|
from src.auth.models.role import Role
|
||||||
from src.models.user import User
|
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
|
import bcrypt
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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))
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from src.shared.config.database import SessionLocal, engine
|
from src.shared.config.database import SessionLocal, engine
|
||||||
from src.models.room import Room, RoomStatus
|
from src.rooms.models.room import Room, RoomStatus
|
||||||
from src.models.room_type import RoomType
|
from src.rooms.models.room_type import RoomType
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
@@ -28,9 +30,9 @@ def seed_rooms(db: Session):
|
|||||||
print(f'\n✓ Found {len(room_types)} room type(s)')
|
print(f'\n✓ Found {len(room_types)} room type(s)')
|
||||||
for rt in room_types:
|
for rt in room_types:
|
||||||
print(f' - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})')
|
print(f' - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})')
|
||||||
from src.models.booking import Booking
|
from src.bookings.models.booking import Booking
|
||||||
from src.models.review import Review
|
from src.reviews.models.review import Review
|
||||||
from src.models.favorite import Favorite
|
from src.reviews.models.favorite import Favorite
|
||||||
existing_rooms = db.query(Room).all()
|
existing_rooms = db.query(Room).all()
|
||||||
if existing_rooms:
|
if existing_rooms:
|
||||||
print(f'\n🗑️ Deleting {len(existing_rooms)} existing room(s)...')
|
print(f'\n🗑️ Deleting {len(existing_rooms)} existing room(s)...')
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
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))
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from src.shared.config.database import SessionLocal
|
from src.shared.config.database import SessionLocal
|
||||||
from src.models.role import Role
|
from src.auth.models.role import Role
|
||||||
from src.models.user import User
|
from src.auth.models.user import User
|
||||||
import bcrypt
|
import bcrypt
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -25,6 +27,7 @@ def seed_users(db: Session):
|
|||||||
admin_role = db.query(Role).filter(Role.name == 'admin').first()
|
admin_role = db.query(Role).filter(Role.name == 'admin').first()
|
||||||
staff_role = db.query(Role).filter(Role.name == 'staff').first()
|
staff_role = db.query(Role).filter(Role.name == 'staff').first()
|
||||||
customer_role = db.query(Role).filter(Role.name == 'customer').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:
|
if not admin_role or not staff_role or not customer_role:
|
||||||
print(' ❌ Roles not found! Please seed roles first.')
|
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 = {
|
role_map = {
|
||||||
'admin': admin_role.id,
|
'admin': admin_role.id,
|
||||||
'staff': staff_role.id,
|
'staff': staff_role.id,
|
||||||
'customer': customer_role.id
|
'customer': customer_role.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if housekeeping_role:
|
||||||
|
role_map['housekeeping'] = housekeeping_role.id
|
||||||
|
|
||||||
created_count = 0
|
created_count = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -504,6 +504,11 @@ async def upload_avatar(request: Request, image: UploadFile=File(...), current_u
|
|||||||
|
|
||||||
# Validate file completely (MIME type, size, magic bytes, integrity)
|
# Validate file completely (MIME type, size, magic bytes, integrity)
|
||||||
content = await validate_uploaded_image(image, max_avatar_size)
|
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
|
# Use same path calculation as main.py: go from Backend/src/auth/routes/auth_routes.py
|
||||||
# to Backend/uploads/avatars
|
# to Backend/uploads/avatars
|
||||||
upload_dir = Path(__file__).parent.parent.parent.parent / '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
|
from ...shared.utils.sanitization import sanitize_filename
|
||||||
original_filename = image.filename or 'avatar.png'
|
original_filename = image.filename or 'avatar.png'
|
||||||
sanitized_filename = sanitize_filename(original_filename)
|
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
|
# Generate secure filename with user ID and UUID to prevent collisions
|
||||||
filename = f'avatar-{current_user.id}-{uuid.uuid4()}{ext}'
|
filename = f'avatar-{current_user.id}-{uuid.uuid4()}{ext}'
|
||||||
file_path = upload_dir / filename
|
file_path = upload_dir / filename
|
||||||
async with aiofiles.open(file_path, 'wb') as f:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
await f.write(content)
|
await f.write(optimized_content)
|
||||||
image_url = f'/uploads/avatars/{filename}'
|
image_url = f'/uploads/avatars/{filename}'
|
||||||
current_user.avatar = image_url
|
current_user.avatar = image_url
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
Binary file not shown.
@@ -770,6 +770,31 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
|
|||||||
)
|
)
|
||||||
db.add(housekeeping_task)
|
db.add(housekeeping_task)
|
||||||
db.flush() # Flush to get the task ID if needed for notifications
|
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:
|
elif new_status == BookingStatus.cancelled:
|
||||||
# Update room status when booking is cancelled
|
# Update room status when booking is cancelled
|
||||||
if booking.payments:
|
if booking.payments:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -109,7 +109,7 @@ async def delete_banner(id: int, current_user: User=Depends(authorize_roles('adm
|
|||||||
if not banner:
|
if not banner:
|
||||||
raise HTTPException(status_code=404, detail='Banner not found')
|
raise HTTPException(status_code=404, detail='Banner not found')
|
||||||
if banner.image_url and banner.image_url.startswith('/uploads/banners/'):
|
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():
|
if file_path.exists():
|
||||||
file_path.unlink()
|
file_path.unlink()
|
||||||
db.delete(banner)
|
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}')
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'File must be an image. Received: {image.content_type}')
|
||||||
if not image.filename:
|
if not image.filename:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
|
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)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
ext = Path(image.filename).suffix or '.jpg'
|
ext = Path(image.filename).suffix or '.jpg'
|
||||||
filename = f'banner-{uuid.uuid4()}{ext}'
|
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)
|
# Validate file completely (MIME type, size, magic bytes, integrity)
|
||||||
content = await validate_uploaded_image(image, max_size)
|
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:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
await f.write(content)
|
await f.write(optimized_content)
|
||||||
image_url = f'/uploads/banners/{filename}'
|
image_url = f'/uploads/banners/{filename}'
|
||||||
base_url = get_base_url(request)
|
base_url = get_base_url(request)
|
||||||
full_url = normalize_image_url(image_url, base_url)
|
full_url = normalize_image_url(image_url, base_url)
|
||||||
|
|||||||
@@ -532,7 +532,7 @@ async def upload_blog_image(
|
|||||||
if not image.filename:
|
if not image.filename:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
|
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)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
ext = Path(image.filename).suffix or '.jpg'
|
ext = Path(image.filename).suffix or '.jpg'
|
||||||
@@ -546,8 +546,17 @@ async def upload_blog_image(
|
|||||||
|
|
||||||
content = await validate_uploaded_image(image, max_size)
|
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:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
await f.write(content)
|
await f.write(optimized_content)
|
||||||
|
|
||||||
image_url = f'/uploads/blog/{filename}'
|
image_url = f'/uploads/blog/{filename}'
|
||||||
base_url = get_base_url(request)
|
base_url = get_base_url(request)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(...
|
|||||||
if not image.filename:
|
if not image.filename:
|
||||||
logger.error('No filename provided in upload request')
|
logger.error('No filename provided in upload request')
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
|
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)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
logger.info(f'Upload directory: {upload_dir}')
|
logger.info(f'Upload directory: {upload_dir}')
|
||||||
ext = Path(image.filename).suffix or '.jpg'
|
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)
|
# Validate file completely (MIME type, size, magic bytes, integrity)
|
||||||
content = await validate_uploaded_image(image, max_size)
|
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:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
await f.write(content)
|
await f.write(optimized_content)
|
||||||
logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes')
|
logger.info(f'File saved successfully: {file_path}, size: {len(optimized_content)} bytes')
|
||||||
image_url = f'/uploads/page-content/{filename}'
|
image_url = f'/uploads/page-content/{filename}'
|
||||||
base_url = get_base_url(request)
|
base_url = get_base_url(request)
|
||||||
full_url = normalize_image_url(image_url, base_url)
|
full_url = normalize_image_url(image_url, base_url)
|
||||||
|
|||||||
@@ -246,8 +246,22 @@ async def serve_upload_file(file_path: str, request: Request):
|
|||||||
# Get origin from request
|
# Get origin from request
|
||||||
origin = request.headers.get('origin')
|
origin = request.headers.get('origin')
|
||||||
|
|
||||||
# Prepare response
|
# Determine media type based on file extension
|
||||||
response = FileResponse(str(file_location))
|
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
|
# Add CORS headers if origin matches
|
||||||
if origin:
|
if origin:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -338,7 +338,7 @@ async def websocket_staff_notifications(websocket: WebSocket):
|
|||||||
await websocket.close(code=1008, reason='User not found')
|
await websocket.close(code=1008, reason='User not found')
|
||||||
return
|
return
|
||||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
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')
|
await websocket.close(code=1008, reason='Unauthorized role')
|
||||||
return
|
return
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -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 sqlalchemy.orm import Session
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from ...shared.config.database import get_db
|
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 ...security.middleware.auth import authorize_roles, get_current_user
|
||||||
from ...auth.models.user import User
|
from ...auth.models.user import User
|
||||||
|
from ...auth.models.role import Role
|
||||||
from ..models.notification import NotificationChannel, NotificationStatus, NotificationType
|
from ..models.notification import NotificationChannel, NotificationStatus, NotificationType
|
||||||
from ..services.notification_service import NotificationService
|
from ..services.notification_service import NotificationService
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
router = APIRouter(prefix='/notifications', tags=['notifications'])
|
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
|
# Request/Response Models
|
||||||
class NotificationSendRequest(BaseModel):
|
class NotificationSendRequest(BaseModel):
|
||||||
user_id: Optional[int] = None
|
user_id: Optional[int] = None
|
||||||
@@ -304,3 +403,98 @@ async def update_preferences(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -346,10 +346,11 @@ async def get_housekeeping_tasks(
|
|||||||
date: Optional[str] = Query(None),
|
date: Optional[str] = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
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')),
|
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Get housekeeping tasks with filtering"""
|
"""Get housekeeping tasks with filtering. Also includes rooms in cleaning status."""
|
||||||
try:
|
try:
|
||||||
# Check user role - housekeeping and staff users should only see their assigned tasks
|
# 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()
|
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)
|
query = db.query(HousekeepingTask)
|
||||||
|
|
||||||
# Filter by assigned_to for housekeeping and staff users (not admin)
|
# 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:
|
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:
|
if room_id:
|
||||||
query = query.filter(HousekeepingTask.room_id == 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()
|
tasks = query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
|
task_room_ids = set()
|
||||||
|
|
||||||
|
# Process existing tasks
|
||||||
for task in tasks:
|
for task in tasks:
|
||||||
|
task_room_ids.add(task.room_id)
|
||||||
result.append({
|
result.append({
|
||||||
'id': task.id,
|
'id': task.id,
|
||||||
'room_id': task.room_id,
|
'room_id': task.room_id,
|
||||||
@@ -396,9 +407,84 @@ async def get_housekeeping_tasks(
|
|||||||
'notes': task.notes,
|
'notes': task.notes,
|
||||||
'quality_score': task.quality_score,
|
'quality_score': task.quality_score,
|
||||||
'estimated_duration_minutes': task.estimated_duration_minutes,
|
'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 {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
@@ -418,11 +504,16 @@ async def get_housekeeping_tasks(
|
|||||||
@router.post('/housekeeping')
|
@router.post('/housekeeping')
|
||||||
async def create_housekeeping_task(
|
async def create_housekeeping_task(
|
||||||
task_data: dict,
|
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)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create a new housekeeping task"""
|
"""Create a new housekeeping task"""
|
||||||
try:
|
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()
|
room = db.query(Room).filter(Room.id == task_data.get('room_id')).first()
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail='Room not found')
|
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'))
|
scheduled_time = datetime.fromisoformat(task_data['scheduled_time'].replace('Z', '+00:00'))
|
||||||
assigned_to = task_data.get('assigned_to')
|
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(
|
task = HousekeepingTask(
|
||||||
room_id=task_data['room_id'],
|
room_id=task_data['room_id'],
|
||||||
booking_id=task_data.get('booking_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
|
# Send notification to assigned staff member if task is assigned
|
||||||
if assigned_to:
|
if assigned_to:
|
||||||
try:
|
try:
|
||||||
from ..routes.chat_routes import manager
|
from ...notifications.routes.notification_routes import notification_manager
|
||||||
assigned_staff = db.query(User).filter(User.id == assigned_to).first()
|
|
||||||
task_data_notification = {
|
task_data_notification = {
|
||||||
'id': task.id,
|
'id': task.id,
|
||||||
'room_id': task.room_id,
|
'room_id': task.room_id,
|
||||||
@@ -467,13 +565,9 @@ async def create_housekeeping_task(
|
|||||||
'data': task_data_notification
|
'data': task_data_notification
|
||||||
}
|
}
|
||||||
# Send notification to the specific staff member
|
# Send notification to the specific staff member
|
||||||
if assigned_to in manager.staff_connections:
|
await notification_manager.send_to_user(assigned_to, notification_data)
|
||||||
try:
|
|
||||||
await manager.staff_connections[assigned_to].send_json(notification_data)
|
|
||||||
except Exception as e:
|
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})
|
logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
@@ -504,21 +598,34 @@ async def update_housekeeping_task(
|
|||||||
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
|
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
|
||||||
|
|
||||||
if is_housekeeping_or_staff:
|
if is_housekeeping_or_staff:
|
||||||
# Housekeeping and staff can only update tasks assigned to them
|
# Housekeeping and staff can start unassigned tasks (assign to themselves)
|
||||||
if task.assigned_to != current_user.id:
|
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')
|
raise HTTPException(status_code=403, detail='You can only update tasks assigned to you')
|
||||||
# Housekeeping and staff cannot change assignment
|
# Housekeeping and staff cannot change assignment of already assigned tasks
|
||||||
if 'assigned_to' in task_data and task_data.get('assigned_to') != task.assigned_to:
|
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')
|
raise HTTPException(status_code=403, detail='You cannot change task assignment')
|
||||||
|
|
||||||
old_assigned_to = task.assigned_to
|
old_assigned_to = task.assigned_to
|
||||||
assigned_to_changed = False
|
assigned_to_changed = False
|
||||||
|
|
||||||
if 'assigned_to' in task_data and is_admin:
|
# 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')
|
new_assigned_to = task_data.get('assigned_to')
|
||||||
if new_assigned_to != old_assigned_to:
|
if new_assigned_to != old_assigned_to:
|
||||||
task.assigned_to = new_assigned_to
|
task.assigned_to = new_assigned_to
|
||||||
assigned_to_changed = True
|
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:
|
if 'status' in task_data:
|
||||||
new_status = HousekeepingStatus(task_data['status'])
|
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:
|
if new_status == HousekeepingStatus.in_progress and not task.started_at:
|
||||||
task.started_at = datetime.utcnow()
|
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:
|
elif new_status == HousekeepingStatus.completed and not task.completed_at:
|
||||||
task.completed_at = datetime.utcnow()
|
task.completed_at = datetime.utcnow()
|
||||||
if task.started_at:
|
if task.started_at:
|
||||||
@@ -568,7 +678,22 @@ async def update_housekeeping_task(
|
|||||||
# Keep room as cleaning if there are other pending tasks
|
# Keep room as cleaning if there are other pending tasks
|
||||||
room.status = RoomStatus.cleaning
|
room.status = RoomStatus.cleaning
|
||||||
else:
|
else:
|
||||||
# No pending tasks and no maintenance - room is ready
|
# 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
|
room.status = RoomStatus.available
|
||||||
|
|
||||||
if 'checklist_items' in task_data:
|
if 'checklist_items' in task_data:
|
||||||
@@ -591,7 +716,7 @@ async def update_housekeeping_task(
|
|||||||
# Send notification if assignment changed
|
# Send notification if assignment changed
|
||||||
if assigned_to_changed and task.assigned_to:
|
if assigned_to_changed and task.assigned_to:
|
||||||
try:
|
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()
|
room = db.query(Room).filter(Room.id == task.room_id).first()
|
||||||
task_data_notification = {
|
task_data_notification = {
|
||||||
'id': task.id,
|
'id': task.id,
|
||||||
@@ -608,13 +733,9 @@ async def update_housekeeping_task(
|
|||||||
'data': task_data_notification
|
'data': task_data_notification
|
||||||
}
|
}
|
||||||
# Send notification to the newly assigned staff member
|
# Send notification to the newly assigned staff member
|
||||||
if task.assigned_to in manager.staff_connections:
|
await notification_manager.send_to_user(task.assigned_to, notification_data)
|
||||||
try:
|
|
||||||
await manager.staff_connections[task.assigned_to].send_json(notification_data)
|
|
||||||
except Exception as e:
|
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})
|
logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True)
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRooms
|
|||||||
from ...shared.utils.response_helpers import success_response
|
from ...shared.utils.response_helpers import success_response
|
||||||
from ...reviews.models.review import Review, ReviewStatus
|
from ...reviews.models.review import Review, ReviewStatus
|
||||||
from ...bookings.models.booking import Booking, BookingStatus
|
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
|
from ..services.room_service import get_rooms_with_ratings, get_amenities_list, normalize_images, get_base_url
|
||||||
import os
|
import os
|
||||||
import aiofiles
|
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:
|
if room_data.floor is not None:
|
||||||
room.floor = room_data.floor
|
room.floor = room_data.floor
|
||||||
|
old_status = room.status
|
||||||
if room_data.status is not None:
|
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:
|
if room_data.featured is not None:
|
||||||
room.featured = room_data.featured
|
room.featured = room_data.featured
|
||||||
if room_data.price is not None:
|
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()
|
room = db.query(Room).filter(Room.id == id).first()
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail='Room not found')
|
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)
|
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 = []
|
image_urls = []
|
||||||
for image in images:
|
for image in images:
|
||||||
if not image.content_type or not image.content_type.startswith('image/'):
|
if not image.content_type or not image.content_type.startswith('image/'):
|
||||||
continue
|
continue
|
||||||
if not image.filename:
|
if not image.filename:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
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
|
import uuid
|
||||||
ext = Path(image.filename).suffix or '.jpg'
|
ext = Path(image.filename).suffix or '.jpg'
|
||||||
|
# Update extension if format changed
|
||||||
|
if optimized_ext:
|
||||||
|
ext = optimized_ext
|
||||||
filename = f'room-{uuid.uuid4()}{ext}'
|
filename = f'room-{uuid.uuid4()}{ext}'
|
||||||
file_path = upload_dir / filename
|
file_path = upload_dir / filename
|
||||||
|
|
||||||
async with aiofiles.open(file_path, 'wb') as f:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
content = await image.read()
|
await f.write(optimized_content)
|
||||||
if not content:
|
|
||||||
|
# Verify file was saved
|
||||||
|
if not file_path.exists():
|
||||||
|
logger.error(f'File was not saved: {file_path}')
|
||||||
continue
|
continue
|
||||||
await f.write(content)
|
|
||||||
|
logger.info(f'Successfully uploaded and optimized image: {filename} ({len(optimized_content)} bytes)')
|
||||||
image_urls.append(f'/uploads/rooms/{filename}')
|
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
|
# Handle existing_images - it might be a list, a JSON string, or None
|
||||||
existing_images = room.images or []
|
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()
|
room = db.query(Room).filter(Room.id == id).first()
|
||||||
if not room:
|
if not room:
|
||||||
raise HTTPException(status_code=404, detail='Room not found')
|
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://')
|
is_external_url = image_url.startswith('http://') or image_url.startswith('https://')
|
||||||
normalized_url = image_url
|
normalized_path = image_url
|
||||||
filename = None
|
filename = None
|
||||||
|
|
||||||
if is_external_url:
|
if is_external_url:
|
||||||
# For external URLs, use the full URL as-is for matching
|
# Extract the path from the full URL
|
||||||
normalized_url = image_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:
|
else:
|
||||||
# For local files, normalize the path
|
# Truly external URL (like Unsplash)
|
||||||
if not normalized_url.startswith('/'):
|
normalized_path = image_url
|
||||||
normalized_url = f'/{normalized_url}'
|
except Exception as e:
|
||||||
filename = Path(normalized_url).name
|
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:
|
||||||
|
# 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
|
# Handle existing_images - it might be a list, a JSON string, or None
|
||||||
existing_images = room.images or []
|
existing_images = room.images or []
|
||||||
@@ -626,24 +713,52 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
|
|||||||
|
|
||||||
updated_images = []
|
updated_images = []
|
||||||
for img in existing_images:
|
for img in existing_images:
|
||||||
# For external URLs, match by full URL (keep images that don't match)
|
# Convert stored image to string for comparison
|
||||||
if is_external_url:
|
img_str = str(img).strip()
|
||||||
# Keep the image if it doesn't match the URL we're deleting
|
if not img_str:
|
||||||
if img != normalized_url:
|
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)
|
updated_images.append(img)
|
||||||
else:
|
else:
|
||||||
# For local files, match by path or filename (keep images that don't match)
|
# Both are local paths - normalize both for comparison
|
||||||
stored_path = img if img.startswith('/') else f'/{img}'
|
stored_normalized = stored_path if stored_path.startswith('/') else f'/{stored_path}'
|
||||||
stored_filename = Path(stored_path).name if '/' in str(stored_path) else stored_path
|
stored_filename = Path(stored_normalized).name if '/' in stored_normalized 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):
|
# 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)
|
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:
|
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():
|
if file_path.exists():
|
||||||
|
try:
|
||||||
file_path.unlink()
|
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
|
room.images = updated_images
|
||||||
db.commit()
|
db.commit()
|
||||||
return {'status': 'success', 'message': 'Image deleted successfully', 'data': {'images': updated_images}}
|
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
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
@router.get('/{id}/booked-dates')
|
@router.get('/{id}/booked-dates')
|
||||||
|
|||||||
Binary file not shown.
252
Backend/src/shared/utils/image_optimization.py
Normal file
252
Backend/src/shared/utils/image_optimization.py
Normal 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
|
||||||
|
)
|
||||||
|
|
||||||
Binary file not shown.
@@ -740,7 +740,7 @@ async def upload_borica_certificate(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Create upload directory
|
# 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)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Generate unique filename
|
# Generate unique filename
|
||||||
@@ -1370,22 +1370,18 @@ async def upload_company_logo(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
||||||
if not image.content_type or not image.content_type.startswith('image/'):
|
# Validate image using comprehensive validation
|
||||||
raise HTTPException(
|
from ...shared.config.settings import settings
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
from ...shared.utils.file_validation import validate_uploaded_image
|
||||||
detail="File must be an 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()
|
# Optimize image before saving
|
||||||
if len(content) > 2 * 1024 * 1024:
|
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.COMPANY, preserve_original_format=False)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail="Logo file size must be less than 2MB"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "company"
|
||||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -1394,7 +1390,7 @@ async def upload_company_logo(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if old_logo_setting and old_logo_setting.value:
|
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():
|
if old_logo_path.exists() and old_logo_path.is_file():
|
||||||
try:
|
try:
|
||||||
old_logo_path.unlink()
|
old_logo_path.unlink()
|
||||||
@@ -1402,14 +1398,14 @@ async def upload_company_logo(
|
|||||||
logger.warning(f"Could not delete old logo: {e}")
|
logger.warning(f"Could not delete old logo: {e}")
|
||||||
|
|
||||||
|
|
||||||
ext = Path(image.filename).suffix or '.png'
|
# Use optimized extension, default to webp or png
|
||||||
|
ext = optimized_ext or '.webp'
|
||||||
filename = "logo.png"
|
filename = f"logo{ext}"
|
||||||
file_path = upload_dir / filename
|
file_path = upload_dir / filename
|
||||||
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, 'wb') as f:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
await f.write(content)
|
await f.write(optimized_content)
|
||||||
|
|
||||||
|
|
||||||
image_url = f"/uploads/company/{filename}"
|
image_url = f"/uploads/company/{filename}"
|
||||||
@@ -1462,32 +1458,54 @@ async def upload_company_favicon(
|
|||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
max_size = 500 * 1024 # 500KB for favicons
|
||||||
|
|
||||||
|
# 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 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:
|
if not image.content_type:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="File type could not be determined"
|
detail="File type could not be determined"
|
||||||
)
|
)
|
||||||
|
|
||||||
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico']
|
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/ico']
|
||||||
if image.content_type not in allowed_types:
|
if image.content_type not in allowed_types:
|
||||||
|
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png']):
|
||||||
filename_lower = (image.filename or '').lower()
|
|
||||||
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']):
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="Favicon must be .ico, .png, or .svg file"
|
detail="Favicon must be .ico or .png file"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
content = await validate_uploaded_image(image, max_size)
|
||||||
|
|
||||||
content = await image.read()
|
# Optimize favicon but preserve format (ICO/PNG)
|
||||||
if len(content) > 500 * 1024:
|
optimized_content, optimized_ext = await optimize_image_async(
|
||||||
raise HTTPException(
|
content,
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
ImageType.COMPANY,
|
||||||
detail="Favicon file size must be less than 500KB"
|
preserve_original_format=True # Keep original format for favicons
|
||||||
)
|
)
|
||||||
|
|
||||||
|
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "company"
|
||||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
|
|
||||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
@@ -1496,7 +1514,7 @@ async def upload_company_favicon(
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if old_favicon_setting and old_favicon_setting.value:
|
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():
|
if old_favicon_path.exists() and old_favicon_path.is_file():
|
||||||
try:
|
try:
|
||||||
old_favicon_path.unlink()
|
old_favicon_path.unlink()
|
||||||
@@ -1504,11 +1522,11 @@ async def upload_company_favicon(
|
|||||||
logger.warning(f"Could not delete old favicon: {e}")
|
logger.warning(f"Could not delete old favicon: {e}")
|
||||||
|
|
||||||
|
|
||||||
filename_lower = (image.filename or '').lower()
|
# Determine filename based on original or optimized extension
|
||||||
if filename_lower.endswith('.ico'):
|
if is_svg or filename_lower.endswith('.svg'):
|
||||||
filename = "favicon.ico"
|
|
||||||
elif filename_lower.endswith('.svg'):
|
|
||||||
filename = "favicon.svg"
|
filename = "favicon.svg"
|
||||||
|
elif optimized_ext == '.ico' or filename_lower.endswith('.ico'):
|
||||||
|
filename = "favicon.ico"
|
||||||
else:
|
else:
|
||||||
filename = "favicon.png"
|
filename = "favicon.png"
|
||||||
|
|
||||||
@@ -1516,7 +1534,7 @@ async def upload_company_favicon(
|
|||||||
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, 'wb') as f:
|
async with aiofiles.open(file_path, 'wb') as f:
|
||||||
await f.write(content)
|
await f.write(optimized_content)
|
||||||
|
|
||||||
|
|
||||||
image_url = f"/uploads/company/{filename}"
|
image_url = f"/uploads/company/{filename}"
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
X,
|
X,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
|
RefreshCw,
|
||||||
|
Play,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import Loading from '../../../shared/components/Loading';
|
import Loading from '../../../shared/components/Loading';
|
||||||
@@ -66,10 +68,23 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
fetchTasks();
|
fetchTasks();
|
||||||
}, [currentPage, filters]);
|
}, [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 () => {
|
const fetchTasks = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
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.room_id) params.room_id = parseInt(filters.room_id);
|
||||||
if (filters.status) params.status = filters.status;
|
if (filters.status) params.status = filters.status;
|
||||||
if (filters.task_type) params.task_type = filters.task_type;
|
if (filters.task_type) params.task_type = filters.task_type;
|
||||||
@@ -176,7 +191,30 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
setShowModal(true);
|
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) => {
|
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
|
// Double check that the task is assigned to the current user
|
||||||
if (!task.assigned_to) {
|
if (!task.assigned_to) {
|
||||||
toast.error('Task must be assigned before it can be marked as done');
|
toast.error('Task must be assigned before it can be marked as done');
|
||||||
@@ -192,7 +230,7 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
checklist_items: task.checklist_items?.map(item => ({ ...item, completed: true })) || [],
|
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();
|
fetchTasks();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.detail || 'Failed to mark task as done');
|
toast.error(error.response?.data?.detail || 'Failed to mark task as done');
|
||||||
@@ -322,6 +360,16 @@ 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"
|
className="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
<span>Refresh</span>
|
||||||
|
</button>
|
||||||
{(isAdmin || userInfo?.role === 'staff') && (
|
{(isAdmin || userInfo?.role === 'staff') && (
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
@@ -332,6 +380,7 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
@@ -347,18 +396,34 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<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 completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
|
||||||
const totalItems = task.checklist_items?.length || 0;
|
const totalItems = task.checklist_items?.length || 0;
|
||||||
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 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 (
|
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">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<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)}`}>
|
<span className={`px-2 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(task.status)}`}>
|
||||||
@@ -366,12 +431,13 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<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>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{totalItems > 0 ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="w-16 bg-gray-200 rounded-full h-2 mr-2">
|
<div className="w-16 bg-gray-200 rounded-full h-2 mr-2">
|
||||||
<div
|
<div
|
||||||
@@ -381,9 +447,13 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-gray-600">{progress}%</span>
|
<span className="text-xs text-gray-600">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">No checklist</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{task.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setViewingTask(task)}
|
onClick={() => setViewingTask(task)}
|
||||||
className="text-blue-600 hover:text-blue-900"
|
className="text-blue-600 hover:text-blue-900"
|
||||||
@@ -391,7 +461,34 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{isAdmin ? (
|
)}
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// For actual tasks
|
||||||
|
isAdmin ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(task)}
|
onClick={() => handleEdit(task)}
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
@@ -400,10 +497,20 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
// Housekeeping and staff can only edit their own assigned tasks
|
// Housekeeping and staff actions
|
||||||
(isHousekeeping || userInfo?.role === 'staff') &&
|
(isHousekeeping || userInfo?.role === 'staff') && (
|
||||||
task.assigned_to === userInfo?.id &&
|
<>
|
||||||
task.status !== 'completed' && (
|
{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
|
<button
|
||||||
onClick={() => handleEdit(task)}
|
onClick={() => handleEdit(task)}
|
||||||
@@ -412,14 +519,19 @@ const HousekeepingManagement: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
{task.status === 'in_progress' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleMarkAsDone(task)}
|
onClick={() => handleMarkAsDone(task)}
|
||||||
className="text-green-600 hover:text-green-900"
|
className="text-green-600 hover:text-green-900"
|
||||||
title="Mark as done"
|
title="Mark as done - Room ready for check-in"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface MaintenanceRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface HousekeepingTask {
|
export interface HousekeepingTask {
|
||||||
id: number;
|
id: number | null; // null for room status entries without tasks
|
||||||
room_id: number;
|
room_id: number;
|
||||||
room_number?: string;
|
room_number?: string;
|
||||||
booking_id?: number;
|
booking_id?: number;
|
||||||
@@ -42,6 +42,8 @@ export interface HousekeepingTask {
|
|||||||
quality_score?: number;
|
quality_score?: number;
|
||||||
estimated_duration_minutes?: number;
|
estimated_duration_minutes?: number;
|
||||||
actual_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 {
|
export interface ChecklistItem {
|
||||||
|
|||||||
@@ -526,6 +526,7 @@ const IPWhitelistTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop Table */}
|
{/* Desktop Table */}
|
||||||
@@ -701,6 +702,7 @@ const IPBlacklistTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop Table */}
|
{/* Desktop Table */}
|
||||||
@@ -1012,6 +1014,7 @@ const OAuthProvidersTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Desktop Table */}
|
{/* Desktop Table */}
|
||||||
|
|||||||
@@ -356,23 +356,29 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
date: today,
|
date: today,
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 50,
|
limit: 50,
|
||||||
|
include_cleaning_rooms: true, // Include rooms in cleaning status
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 'success' && response.data?.tasks) {
|
if (response.status === 'success' && response.data?.tasks) {
|
||||||
const userTasks = response.data.tasks.filter(
|
// Backend already filters to show assigned tasks and unassigned tasks
|
||||||
(task: HousekeepingTask) => task.assigned_to === userInfo?.id
|
// 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 pending = allTasks.filter((t: HousekeepingTask) => t.status === 'pending').length;
|
||||||
const in_progress = userTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
|
const in_progress = allTasks.filter((t: HousekeepingTask) => t.status === 'in_progress').length;
|
||||||
const completed = userTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
|
const completed = allTasks.filter((t: HousekeepingTask) => t.status === 'completed').length;
|
||||||
|
|
||||||
setStats({
|
setStats({
|
||||||
pending,
|
pending,
|
||||||
in_progress,
|
in_progress,
|
||||||
completed,
|
completed,
|
||||||
total: userTasks.length,
|
total: allTasks.length,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setTasks([]);
|
setTasks([]);
|
||||||
@@ -406,6 +412,8 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!userInfo?.id) return;
|
||||||
|
|
||||||
fetchTasks();
|
fetchTasks();
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -414,11 +422,168 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, 30000);
|
}, 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 () => {
|
return () => {
|
||||||
if (tasksAbortRef.current) {
|
if (tasksAbortRef.current) {
|
||||||
tasksAbortRef.current.abort();
|
tasksAbortRef.current.abort();
|
||||||
}
|
}
|
||||||
clearInterval(interval);
|
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]);
|
}, [userInfo?.id]);
|
||||||
|
|
||||||
@@ -513,17 +678,69 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
}, [currentFloorTasks]);
|
}, [currentFloorTasks]);
|
||||||
|
|
||||||
const handleStartTask = async (task: HousekeepingTask) => {
|
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;
|
if (updatingTasks.has(task.id)) return;
|
||||||
|
|
||||||
setUpdatingTasks(prev => new Set(prev).add(task.id));
|
setUpdatingTasks(prev => new Set(prev).add(task.id));
|
||||||
try {
|
try {
|
||||||
await advancedRoomService.updateHousekeepingTask(task.id, {
|
await advancedRoomService.updateHousekeepingTask(task.id, {
|
||||||
status: 'in_progress',
|
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();
|
await fetchTasks();
|
||||||
if (selectedTask?.id === task.id) {
|
if (selectedTask?.id === task.id) {
|
||||||
setSelectedTask({ ...task, status: 'in_progress' });
|
setSelectedTask({ ...task, status: 'in_progress', assigned_to: userInfo?.id });
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error('Error starting task', error);
|
logger.error('Error starting task', error);
|
||||||
@@ -538,6 +755,10 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateChecklist = async (task: HousekeepingTask, itemIndex: number, checked: boolean) => {
|
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 (updatingTasks.has(task.id)) return;
|
||||||
|
|
||||||
if (!task.checklist_items) return;
|
if (!task.checklist_items) return;
|
||||||
@@ -586,6 +807,10 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCompleteTask = async (task: HousekeepingTask) => {
|
const handleCompleteTask = async (task: HousekeepingTask) => {
|
||||||
|
if (!task.id) {
|
||||||
|
toast.error('Cannot complete task: Invalid task ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (updatingTasks.has(task.id)) return;
|
if (updatingTasks.has(task.id)) return;
|
||||||
|
|
||||||
const allCompleted = task.checklist_items?.every(item => item.completed) ?? true;
|
const allCompleted = task.checklist_items?.every(item => item.completed) ?? true;
|
||||||
@@ -608,7 +833,7 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
status: 'completed',
|
status: 'completed',
|
||||||
checklist_items: updatedChecklist,
|
checklist_items: updatedChecklist,
|
||||||
});
|
});
|
||||||
toast.success('Task completed successfully! 🎉');
|
toast.success('Task completed successfully! Room is now ready for check-in. 🎉');
|
||||||
await fetchTasks();
|
await fetchTasks();
|
||||||
closeTaskModal();
|
closeTaskModal();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -854,17 +1079,17 @@ const HousekeepingDashboardPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-5">
|
<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 completedItems = task.checklist_items?.filter(item => item.completed).length || 0;
|
||||||
const totalItems = task.checklist_items?.length || 0;
|
const totalItems = task.checklist_items?.length || 0;
|
||||||
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
|
const progress = totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
|
||||||
const isUpdating = updatingTasks.has(task.id);
|
const isUpdating = task.id ? updatingTasks.has(task.id) : updatingTasks.has(task.room_id);
|
||||||
const canStart = task.status === 'pending';
|
const canStart = task.status === 'pending' && (!task.assigned_to || task.assigned_to === userInfo?.id);
|
||||||
const canComplete = task.status === 'in_progress' || task.status === 'pending';
|
const canComplete = task.id && (task.status === 'in_progress' || task.status === 'pending') && task.assigned_to === userInfo?.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
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)}
|
onClick={() => openTaskModal(task)}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user