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 os
|
||||
from pathlib import Path
|
||||
# Add both the seeds_data directory and the Backend directory to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from sqlalchemy.orm import Session
|
||||
from src.shared.config.database import SessionLocal
|
||||
from src.models.role import Role
|
||||
from src.models.room_type import RoomType
|
||||
from src.models.user import User
|
||||
# Import all models needed for SQLAlchemy relationship setup
|
||||
from src.auth.models.role import Role
|
||||
from src.auth.models.user import User
|
||||
from src.rooms.models.room_type import RoomType
|
||||
from src.rooms.models.room import Room
|
||||
from src.rooms.models.rate_plan import RatePlan # For RoomType relationship
|
||||
from src.bookings.models.booking import Booking # For Room relationship
|
||||
import bcrypt
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
# Add both the seeds_data directory and the Backend directory to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from sqlalchemy.orm import Session
|
||||
from src.shared.config.database import SessionLocal, engine
|
||||
from src.models.room import Room, RoomStatus
|
||||
from src.models.room_type import RoomType
|
||||
from src.rooms.models.room import Room, RoomStatus
|
||||
from src.rooms.models.room_type import RoomType
|
||||
from datetime import datetime
|
||||
import json
|
||||
import random
|
||||
@@ -28,9 +30,9 @@ def seed_rooms(db: Session):
|
||||
print(f'\n✓ Found {len(room_types)} room type(s)')
|
||||
for rt in room_types:
|
||||
print(f' - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})')
|
||||
from src.models.booking import Booking
|
||||
from src.models.review import Review
|
||||
from src.models.favorite import Favorite
|
||||
from src.bookings.models.booking import Booking
|
||||
from src.reviews.models.review import Review
|
||||
from src.reviews.models.favorite import Favorite
|
||||
existing_rooms = db.query(Room).all()
|
||||
if existing_rooms:
|
||||
print(f'\n🗑️ Deleting {len(existing_rooms)} existing room(s)...')
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
# Add both the seeds_data directory and the Backend directory to the path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
from sqlalchemy.orm import Session
|
||||
from src.shared.config.database import SessionLocal
|
||||
from src.models.role import Role
|
||||
from src.models.user import User
|
||||
from src.auth.models.role import Role
|
||||
from src.auth.models.user import User
|
||||
import bcrypt
|
||||
from datetime import datetime
|
||||
|
||||
@@ -25,6 +27,7 @@ def seed_users(db: Session):
|
||||
admin_role = db.query(Role).filter(Role.name == 'admin').first()
|
||||
staff_role = db.query(Role).filter(Role.name == 'staff').first()
|
||||
customer_role = db.query(Role).filter(Role.name == 'customer').first()
|
||||
housekeeping_role = db.query(Role).filter(Role.name == 'housekeeping').first()
|
||||
|
||||
if not admin_role or not staff_role or not customer_role:
|
||||
print(' ❌ Roles not found! Please seed roles first.')
|
||||
@@ -105,12 +108,38 @@ def seed_users(db: Session):
|
||||
}
|
||||
]
|
||||
|
||||
# Add housekeeping users if role exists
|
||||
if housekeeping_role:
|
||||
users_data.extend([
|
||||
{
|
||||
'email': 'housekeeping@gnxsoft.com',
|
||||
'password': 'housekeeping123',
|
||||
'full_name': 'Housekeeping Staff',
|
||||
'phone': '+1 (555) 999-0000',
|
||||
'role': 'housekeeping',
|
||||
'currency': 'EUR',
|
||||
'is_active': True
|
||||
},
|
||||
{
|
||||
'email': 'housekeeping2@gnxsoft.com',
|
||||
'password': 'housekeeping123',
|
||||
'full_name': 'Housekeeping Staff 2',
|
||||
'phone': '+1 (555) 999-0001',
|
||||
'role': 'housekeeping',
|
||||
'currency': 'EUR',
|
||||
'is_active': True
|
||||
}
|
||||
])
|
||||
|
||||
role_map = {
|
||||
'admin': admin_role.id,
|
||||
'staff': staff_role.id,
|
||||
'customer': customer_role.id
|
||||
}
|
||||
|
||||
if housekeeping_role:
|
||||
role_map['housekeeping'] = housekeeping_role.id
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
|
||||
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)
|
||||
content = await validate_uploaded_image(image, max_avatar_size)
|
||||
|
||||
# Optimize image before saving
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.AVATAR)
|
||||
|
||||
# Use same path calculation as main.py: go from Backend/src/auth/routes/auth_routes.py
|
||||
# to Backend/uploads/avatars
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'avatars'
|
||||
@@ -519,12 +524,17 @@ async def upload_avatar(request: Request, image: UploadFile=File(...), current_u
|
||||
from ...shared.utils.sanitization import sanitize_filename
|
||||
original_filename = image.filename or 'avatar.png'
|
||||
sanitized_filename = sanitize_filename(original_filename)
|
||||
ext = Path(sanitized_filename).suffix or '.png'
|
||||
ext = Path(sanitized_filename).suffix or optimized_ext or '.webp'
|
||||
|
||||
# Update extension if format changed
|
||||
if optimized_ext:
|
||||
ext = optimized_ext
|
||||
|
||||
# Generate secure filename with user ID and UUID to prevent collisions
|
||||
filename = f'avatar-{current_user.id}-{uuid.uuid4()}{ext}'
|
||||
file_path = upload_dir / filename
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
await f.write(optimized_content)
|
||||
image_url = f'/uploads/avatars/{filename}'
|
||||
current_user.avatar = image_url
|
||||
db.commit()
|
||||
|
||||
Binary file not shown.
@@ -770,6 +770,31 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
|
||||
)
|
||||
db.add(housekeeping_task)
|
||||
db.flush() # Flush to get the task ID if needed for notifications
|
||||
|
||||
# Send notification to all housekeeping users via dedicated notification system
|
||||
try:
|
||||
from ...notifications.routes.notification_routes import notification_manager
|
||||
|
||||
task_data_notification = {
|
||||
'id': housekeeping_task.id,
|
||||
'room_id': housekeeping_task.room_id,
|
||||
'room_number': room.room_number,
|
||||
'task_type': housekeeping_task.task_type.value,
|
||||
'status': housekeeping_task.status.value,
|
||||
'scheduled_time': housekeeping_task.scheduled_time.isoformat() if housekeeping_task.scheduled_time else None,
|
||||
'assigned_to': housekeeping_task.assigned_to,
|
||||
'booking_number': booking.booking_number,
|
||||
'created_at': housekeeping_task.created_at.isoformat() if housekeeping_task.created_at else None
|
||||
}
|
||||
notification_data = {
|
||||
'type': 'housekeeping_task_available',
|
||||
'data': task_data_notification
|
||||
}
|
||||
|
||||
# Send notification to all housekeeping users
|
||||
await notification_manager.send_to_role('housekeeping', notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending housekeeping notification: {str(e)}', exc_info=True)
|
||||
elif new_status == BookingStatus.cancelled:
|
||||
# Update room status when booking is cancelled
|
||||
if booking.payments:
|
||||
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail='Banner not found')
|
||||
if banner.image_url and banner.image_url.startswith('/uploads/banners/'):
|
||||
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'banners' / Path(banner.image_url).name
|
||||
file_path = Path(__file__).parent.parent.parent.parent / 'uploads' / 'banners' / Path(banner.image_url).name
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
db.delete(banner)
|
||||
@@ -130,7 +130,7 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'File must be an image. Received: {image.content_type}')
|
||||
if not image.filename:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
|
||||
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'banners'
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'banners'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
ext = Path(image.filename).suffix or '.jpg'
|
||||
filename = f'banner-{uuid.uuid4()}{ext}'
|
||||
@@ -143,8 +143,17 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur
|
||||
# Validate file completely (MIME type, size, magic bytes, integrity)
|
||||
content = await validate_uploaded_image(image, max_size)
|
||||
|
||||
# Optimize image before saving
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.BANNER)
|
||||
|
||||
# Update filename extension if format changed
|
||||
if optimized_ext and optimized_ext != ext:
|
||||
filename = f'banner-{uuid.uuid4()}{optimized_ext}'
|
||||
file_path = upload_dir / filename
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
await f.write(optimized_content)
|
||||
image_url = f'/uploads/banners/{filename}'
|
||||
base_url = get_base_url(request)
|
||||
full_url = normalize_image_url(image_url, base_url)
|
||||
|
||||
@@ -532,7 +532,7 @@ async def upload_blog_image(
|
||||
if not image.filename:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
|
||||
|
||||
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'blog'
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'blog'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ext = Path(image.filename).suffix or '.jpg'
|
||||
@@ -546,8 +546,17 @@ async def upload_blog_image(
|
||||
|
||||
content = await validate_uploaded_image(image, max_size)
|
||||
|
||||
# Optimize image before saving
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.BLOG)
|
||||
|
||||
# Update filename extension if format changed
|
||||
if optimized_ext and optimized_ext != ext:
|
||||
filename = f'blog-{uuid.uuid4()}{optimized_ext}'
|
||||
file_path = upload_dir / filename
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
await f.write(optimized_content)
|
||||
|
||||
image_url = f'/uploads/blog/{filename}'
|
||||
base_url = get_base_url(request)
|
||||
|
||||
@@ -56,7 +56,7 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(...
|
||||
if not image.filename:
|
||||
logger.error('No filename provided in upload request')
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
|
||||
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'page-content'
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'page-content'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f'Upload directory: {upload_dir}')
|
||||
ext = Path(image.filename).suffix or '.jpg'
|
||||
@@ -70,9 +70,18 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(...
|
||||
# Validate file completely (MIME type, size, magic bytes, integrity)
|
||||
content = await validate_uploaded_image(image, max_size)
|
||||
|
||||
# Optimize image before saving
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.PAGE_CONTENT)
|
||||
|
||||
# Update filename extension if format changed
|
||||
if optimized_ext and optimized_ext != ext:
|
||||
filename = f'page-content-{uuid.uuid4()}{optimized_ext}'
|
||||
file_path = upload_dir / filename
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes')
|
||||
await f.write(optimized_content)
|
||||
logger.info(f'File saved successfully: {file_path}, size: {len(optimized_content)} bytes')
|
||||
image_url = f'/uploads/page-content/{filename}'
|
||||
base_url = get_base_url(request)
|
||||
full_url = normalize_image_url(image_url, base_url)
|
||||
|
||||
@@ -246,8 +246,22 @@ async def serve_upload_file(file_path: str, request: Request):
|
||||
# Get origin from request
|
||||
origin = request.headers.get('origin')
|
||||
|
||||
# Prepare response
|
||||
response = FileResponse(str(file_location))
|
||||
# Determine media type based on file extension
|
||||
media_type = None
|
||||
file_ext = file_location.suffix.lower()
|
||||
if file_ext == '.webp':
|
||||
media_type = 'image/webp'
|
||||
elif file_ext in ['.jpg', '.jpeg']:
|
||||
media_type = 'image/jpeg'
|
||||
elif file_ext == '.png':
|
||||
media_type = 'image/png'
|
||||
elif file_ext == '.gif':
|
||||
media_type = 'image/gif'
|
||||
elif file_ext == '.ico':
|
||||
media_type = 'image/x-icon'
|
||||
|
||||
# Prepare response with appropriate media type
|
||||
response = FileResponse(str(file_location), media_type=media_type)
|
||||
|
||||
# Add CORS headers if origin matches
|
||||
if origin:
|
||||
|
||||
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')
|
||||
return
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if not role or role.name not in ['staff', 'admin']:
|
||||
if not role or role.name not in ['staff', 'admin', 'housekeeping']:
|
||||
await websocket.close(code=1008, reason='Unauthorized role')
|
||||
return
|
||||
finally:
|
||||
|
||||
@@ -1,16 +1,115 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body, WebSocket, WebSocketDisconnect
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import authorize_roles, get_current_user
|
||||
from ...auth.models.user import User
|
||||
from ...auth.models.role import Role
|
||||
from ..models.notification import NotificationChannel, NotificationStatus, NotificationType
|
||||
from ..services.notification_service import NotificationService
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/notifications', tags=['notifications'])
|
||||
|
||||
# Dedicated Notification Connection Manager (separate from chat)
|
||||
class NotificationConnectionManager:
|
||||
"""Manages WebSocket connections for real-time notifications"""
|
||||
|
||||
def __init__(self):
|
||||
self.user_connections: dict[int, WebSocket] = {}
|
||||
self.role_connections: dict[str, List[WebSocket]] = {
|
||||
'admin': [],
|
||||
'staff': [],
|
||||
'housekeeping': [],
|
||||
'accountant': []
|
||||
}
|
||||
|
||||
def connect_user(self, user_id: int, websocket: WebSocket, role_name: str):
|
||||
"""Connect a user to the notification system"""
|
||||
self.user_connections[user_id] = websocket
|
||||
if role_name in self.role_connections:
|
||||
if websocket not in self.role_connections[role_name]:
|
||||
self.role_connections[role_name].append(websocket)
|
||||
logger.debug(f'User {user_id} ({role_name}) connected to notification system')
|
||||
|
||||
def disconnect_user(self, user_id: int, role_name: str):
|
||||
"""Disconnect a user from the notification system"""
|
||||
websocket = None
|
||||
if user_id in self.user_connections:
|
||||
websocket = self.user_connections[user_id]
|
||||
del self.user_connections[user_id]
|
||||
if role_name in self.role_connections and websocket:
|
||||
self.role_connections[role_name] = [
|
||||
ws for ws in self.role_connections[role_name]
|
||||
if ws != websocket
|
||||
]
|
||||
logger.debug(f'User {user_id} ({role_name}) disconnected from notification system')
|
||||
|
||||
async def send_to_user(self, user_id: int, message: dict):
|
||||
"""Send notification to a specific user"""
|
||||
if user_id in self.user_connections:
|
||||
try:
|
||||
websocket = self.user_connections[user_id]
|
||||
await websocket.send_json(message)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending notification to user {user_id}: {str(e)}', exc_info=True)
|
||||
# Remove broken connection
|
||||
if user_id in self.user_connections:
|
||||
del self.user_connections[user_id]
|
||||
return False
|
||||
return False
|
||||
|
||||
async def send_to_role(self, role_name: str, message: dict):
|
||||
"""Send notification to all users with a specific role"""
|
||||
if role_name not in self.role_connections:
|
||||
return 0
|
||||
|
||||
disconnected = []
|
||||
sent_count = 0
|
||||
|
||||
for websocket in self.role_connections[role_name]:
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending notification to {role_name} user: {str(e)}', exc_info=True)
|
||||
disconnected.append(websocket)
|
||||
|
||||
# Clean up disconnected websockets
|
||||
for ws in disconnected:
|
||||
if ws in self.role_connections[role_name]:
|
||||
self.role_connections[role_name].remove(ws)
|
||||
|
||||
return sent_count
|
||||
|
||||
async def broadcast(self, message: dict):
|
||||
"""Broadcast notification to all connected users"""
|
||||
disconnected = []
|
||||
sent_count = 0
|
||||
|
||||
for user_id, websocket in self.user_connections.items():
|
||||
try:
|
||||
await websocket.send_json(message)
|
||||
sent_count += 1
|
||||
except Exception as e:
|
||||
logger.error(f'Error broadcasting to user {user_id}: {str(e)}', exc_info=True)
|
||||
disconnected.append(user_id)
|
||||
|
||||
# Clean up disconnected websockets
|
||||
for user_id in disconnected:
|
||||
if user_id in self.user_connections:
|
||||
del self.user_connections[user_id]
|
||||
|
||||
return sent_count
|
||||
|
||||
# Global notification manager instance
|
||||
notification_manager = NotificationConnectionManager()
|
||||
|
||||
# Request/Response Models
|
||||
class NotificationSendRequest(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
@@ -304,3 +403,98 @@ async def update_preferences(
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# WebSocket endpoint for real-time notifications
|
||||
@router.websocket('/ws')
|
||||
async def websocket_notifications(websocket: WebSocket):
|
||||
"""WebSocket endpoint for real-time notifications (separate from chat)"""
|
||||
current_user = None
|
||||
role_name = None
|
||||
try:
|
||||
await websocket.accept()
|
||||
|
||||
# Security: Read token from cookies
|
||||
token = None
|
||||
if websocket.cookies:
|
||||
token = websocket.cookies.get('accessToken')
|
||||
|
||||
# Fallback to query parameter for backward compatibility
|
||||
if not token:
|
||||
query_params = dict(websocket.query_params)
|
||||
token = query_params.get('token')
|
||||
|
||||
if not token:
|
||||
await websocket.close(code=1008, reason='Token required')
|
||||
return
|
||||
|
||||
try:
|
||||
from ...security.middleware.auth import verify_token
|
||||
from ...shared.config.database import get_db
|
||||
payload = verify_token(token)
|
||||
user_id = payload.get('userId')
|
||||
if not user_id:
|
||||
await websocket.close(code=1008, reason='Invalid token payload')
|
||||
return
|
||||
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
current_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not current_user:
|
||||
await websocket.close(code=1008, reason='User not found')
|
||||
return
|
||||
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if not role:
|
||||
await websocket.close(code=1008, reason='User role not found')
|
||||
return
|
||||
|
||||
role_name = role.name
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f'WebSocket token verification error: {str(e)}', exc_info=True)
|
||||
await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}')
|
||||
return
|
||||
|
||||
# Connect user to notification system
|
||||
notification_manager.connect_user(current_user.id, websocket, role_name)
|
||||
|
||||
try:
|
||||
await websocket.send_json({
|
||||
'type': 'connected',
|
||||
'data': {
|
||||
'message': 'Connected to notification system',
|
||||
'user_id': current_user.id,
|
||||
'role': role_name
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending initial notification message: {str(e)}', exc_info=True, extra={'user_id': current_user.id})
|
||||
|
||||
# Keep connection alive and handle ping/pong
|
||||
while True:
|
||||
try:
|
||||
data = await websocket.receive_text()
|
||||
try:
|
||||
message_data = json.loads(data)
|
||||
if message_data.get('type') == 'ping':
|
||||
await websocket.send_json({'type': 'pong', 'data': 'pong'})
|
||||
except json.JSONDecodeError:
|
||||
await websocket.send_json({'type': 'pong', 'data': 'pong'})
|
||||
except WebSocketDisconnect:
|
||||
logger.info('Notification WebSocket disconnected normally', extra={'user_id': current_user.id})
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f'Notification WebSocket receive error: {str(e)}', exc_info=True, extra={'user_id': current_user.id})
|
||||
break
|
||||
except WebSocketDisconnect:
|
||||
logger.info('Notification WebSocket disconnected')
|
||||
except Exception as e:
|
||||
logger.error(f'Notification WebSocket error: {str(e)}', exc_info=True)
|
||||
finally:
|
||||
if current_user and role_name:
|
||||
try:
|
||||
notification_manager.disconnect_user(current_user.id, role_name)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -346,10 +346,11 @@ async def get_housekeeping_tasks(
|
||||
date: Optional[str] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
include_cleaning_rooms: bool = Query(True, description='Include rooms in cleaning status even without tasks'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get housekeeping tasks with filtering"""
|
||||
"""Get housekeeping tasks with filtering. Also includes rooms in cleaning status."""
|
||||
try:
|
||||
# Check user role - housekeeping and staff users should only see their assigned tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
@@ -359,8 +360,14 @@ async def get_housekeeping_tasks(
|
||||
query = db.query(HousekeepingTask)
|
||||
|
||||
# Filter by assigned_to for housekeeping and staff users (not admin)
|
||||
# But also include unassigned tasks so they can pick them up
|
||||
if is_housekeeping_or_staff:
|
||||
query = query.filter(HousekeepingTask.assigned_to == current_user.id)
|
||||
query = query.filter(
|
||||
or_(
|
||||
HousekeepingTask.assigned_to == current_user.id,
|
||||
HousekeepingTask.assigned_to.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(HousekeepingTask.room_id == room_id)
|
||||
@@ -379,7 +386,11 @@ async def get_housekeeping_tasks(
|
||||
tasks = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
task_room_ids = set()
|
||||
|
||||
# Process existing tasks
|
||||
for task in tasks:
|
||||
task_room_ids.add(task.room_id)
|
||||
result.append({
|
||||
'id': task.id,
|
||||
'room_id': task.room_id,
|
||||
@@ -396,9 +407,84 @@ async def get_housekeeping_tasks(
|
||||
'notes': task.notes,
|
||||
'quality_score': task.quality_score,
|
||||
'estimated_duration_minutes': task.estimated_duration_minutes,
|
||||
'actual_duration_minutes': task.actual_duration_minutes
|
||||
'actual_duration_minutes': task.actual_duration_minutes,
|
||||
'room_status': task.room.status.value if task.room else None
|
||||
})
|
||||
|
||||
# Include rooms in cleaning status that don't have tasks (or have unassigned tasks for housekeeping users)
|
||||
if include_cleaning_rooms:
|
||||
rooms_query = db.query(Room).filter(Room.status == RoomStatus.cleaning)
|
||||
|
||||
if room_id:
|
||||
rooms_query = rooms_query.filter(Room.id == room_id)
|
||||
|
||||
# For housekeeping/staff users, also include rooms with unassigned tasks
|
||||
if is_housekeeping_or_staff:
|
||||
# Get room IDs with unassigned tasks
|
||||
unassigned_task_rooms = db.query(HousekeepingTask.room_id).filter(
|
||||
and_(
|
||||
HousekeepingTask.assigned_to.is_(None),
|
||||
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress])
|
||||
)
|
||||
).distinct().all()
|
||||
unassigned_room_ids = [r[0] for r in unassigned_task_rooms]
|
||||
|
||||
# Include rooms in cleaning status OR rooms with unassigned tasks
|
||||
if unassigned_room_ids:
|
||||
rooms_query = db.query(Room).filter(
|
||||
or_(
|
||||
Room.status == RoomStatus.cleaning,
|
||||
Room.id.in_(unassigned_room_ids)
|
||||
)
|
||||
)
|
||||
if room_id:
|
||||
rooms_query = rooms_query.filter(Room.id == room_id)
|
||||
|
||||
cleaning_rooms = rooms_query.all()
|
||||
|
||||
# Add rooms in cleaning status that don't have tasks in current page results
|
||||
for room in cleaning_rooms:
|
||||
if room.id not in task_room_ids:
|
||||
# Check if there are any pending tasks for this room
|
||||
pending_tasks = db.query(HousekeepingTask).filter(
|
||||
and_(
|
||||
HousekeepingTask.room_id == room.id,
|
||||
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress])
|
||||
)
|
||||
).all()
|
||||
|
||||
# For housekeeping/staff, only show if there are unassigned tasks or if room is in cleaning
|
||||
if is_housekeeping_or_staff:
|
||||
has_unassigned = any(t.assigned_to is None for t in pending_tasks)
|
||||
if not has_unassigned and room.status != RoomStatus.cleaning:
|
||||
continue
|
||||
|
||||
# Create a virtual task entry for rooms in cleaning status
|
||||
result.append({
|
||||
'id': None, # No task ID since this is a room status entry
|
||||
'room_id': room.id,
|
||||
'room_number': room.room_number,
|
||||
'booking_id': None,
|
||||
'task_type': 'vacant', # Default task type
|
||||
'status': 'pending',
|
||||
'scheduled_time': datetime.utcnow().isoformat(),
|
||||
'started_at': None,
|
||||
'completed_at': None,
|
||||
'assigned_to': None,
|
||||
'assigned_staff_name': None,
|
||||
'checklist_items': [],
|
||||
'notes': 'Room is in cleaning mode',
|
||||
'quality_score': None,
|
||||
'estimated_duration_minutes': None,
|
||||
'actual_duration_minutes': None,
|
||||
'room_status': room.status.value,
|
||||
'is_room_status_only': True # Flag to indicate this is from room status, not a task
|
||||
})
|
||||
|
||||
# Update total count to include cleaning rooms
|
||||
if include_cleaning_rooms:
|
||||
total = len(result)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
@@ -418,11 +504,16 @@ async def get_housekeeping_tasks(
|
||||
@router.post('/housekeeping')
|
||||
async def create_housekeeping_task(
|
||||
task_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new housekeeping task"""
|
||||
try:
|
||||
# Check user role - housekeeping users can only assign tasks to themselves
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_admin = role and role.name == 'admin'
|
||||
is_housekeeping = role and role.name == 'housekeeping'
|
||||
|
||||
room = db.query(Room).filter(Room.id == task_data.get('room_id')).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
@@ -430,6 +521,14 @@ async def create_housekeeping_task(
|
||||
scheduled_time = datetime.fromisoformat(task_data['scheduled_time'].replace('Z', '+00:00'))
|
||||
assigned_to = task_data.get('assigned_to')
|
||||
|
||||
# Housekeeping users can only assign tasks to themselves
|
||||
if is_housekeeping and assigned_to and assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Housekeeping users can only assign tasks to themselves')
|
||||
|
||||
# If housekeeping user doesn't specify assigned_to, assign to themselves
|
||||
if is_housekeeping and not assigned_to:
|
||||
assigned_to = current_user.id
|
||||
|
||||
task = HousekeepingTask(
|
||||
room_id=task_data['room_id'],
|
||||
booking_id=task_data.get('booking_id'),
|
||||
@@ -450,8 +549,7 @@ async def create_housekeeping_task(
|
||||
# Send notification to assigned staff member if task is assigned
|
||||
if assigned_to:
|
||||
try:
|
||||
from ..routes.chat_routes import manager
|
||||
assigned_staff = db.query(User).filter(User.id == assigned_to).first()
|
||||
from ...notifications.routes.notification_routes import notification_manager
|
||||
task_data_notification = {
|
||||
'id': task.id,
|
||||
'room_id': task.room_id,
|
||||
@@ -467,13 +565,9 @@ async def create_housekeeping_task(
|
||||
'data': task_data_notification
|
||||
}
|
||||
# Send notification to the specific staff member
|
||||
if assigned_to in manager.staff_connections:
|
||||
try:
|
||||
await manager.staff_connections[assigned_to].send_json(notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending housekeeping task notification to staff {assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': assigned_to})
|
||||
await notification_manager.send_to_user(assigned_to, notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
|
||||
logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
@@ -504,21 +598,34 @@ async def update_housekeeping_task(
|
||||
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
|
||||
|
||||
if is_housekeeping_or_staff:
|
||||
# Housekeeping and staff can only update tasks assigned to them
|
||||
if task.assigned_to != current_user.id:
|
||||
# Housekeeping and staff can start unassigned tasks (assign to themselves)
|
||||
if task.assigned_to is None:
|
||||
# Allow housekeeping users to assign unassigned tasks to themselves
|
||||
if 'status' in task_data and task_data['status'] == 'in_progress':
|
||||
task.assigned_to = current_user.id
|
||||
task_data['assigned_to'] = current_user.id
|
||||
elif task.assigned_to != current_user.id:
|
||||
# If task is assigned, only the assigned user can update it
|
||||
raise HTTPException(status_code=403, detail='You can only update tasks assigned to you')
|
||||
# Housekeeping and staff cannot change assignment
|
||||
if 'assigned_to' in task_data and task_data.get('assigned_to') != task.assigned_to:
|
||||
# Housekeeping and staff cannot change assignment of already assigned tasks
|
||||
if 'assigned_to' in task_data and task.assigned_to is not None and task_data.get('assigned_to') != task.assigned_to:
|
||||
raise HTTPException(status_code=403, detail='You cannot change task assignment')
|
||||
|
||||
old_assigned_to = task.assigned_to
|
||||
assigned_to_changed = False
|
||||
|
||||
if 'assigned_to' in task_data and is_admin:
|
||||
new_assigned_to = task_data.get('assigned_to')
|
||||
if new_assigned_to != old_assigned_to:
|
||||
task.assigned_to = new_assigned_to
|
||||
assigned_to_changed = True
|
||||
# Handle assignment - admin can assign, housekeeping can self-assign unassigned tasks
|
||||
if 'assigned_to' in task_data:
|
||||
if is_admin:
|
||||
new_assigned_to = task_data.get('assigned_to')
|
||||
if new_assigned_to != old_assigned_to:
|
||||
task.assigned_to = new_assigned_to
|
||||
assigned_to_changed = True
|
||||
elif is_housekeeping_or_staff and task.assigned_to is None:
|
||||
# Housekeeping can assign unassigned tasks to themselves when starting
|
||||
if task_data.get('assigned_to') == current_user.id:
|
||||
task.assigned_to = current_user.id
|
||||
assigned_to_changed = True
|
||||
|
||||
if 'status' in task_data:
|
||||
new_status = HousekeepingStatus(task_data['status'])
|
||||
@@ -534,6 +641,9 @@ async def update_housekeeping_task(
|
||||
|
||||
if new_status == HousekeepingStatus.in_progress and not task.started_at:
|
||||
task.started_at = datetime.utcnow()
|
||||
# If task was unassigned, assign it to the current user
|
||||
if task.assigned_to is None and is_housekeeping_or_staff:
|
||||
task.assigned_to = current_user.id
|
||||
elif new_status == HousekeepingStatus.completed and not task.completed_at:
|
||||
task.completed_at = datetime.utcnow()
|
||||
if task.started_at:
|
||||
@@ -568,8 +678,23 @@ async def update_housekeeping_task(
|
||||
# Keep room as cleaning if there are other pending tasks
|
||||
room.status = RoomStatus.cleaning
|
||||
else:
|
||||
# No pending tasks and no maintenance - room is ready
|
||||
room.status = RoomStatus.available
|
||||
# No pending tasks and no maintenance - room is ready for check-in
|
||||
# Check if there are any upcoming bookings for this room
|
||||
from ...bookings.models.booking import Booking, BookingStatus
|
||||
upcoming_booking = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == room.id,
|
||||
Booking.status == BookingStatus.confirmed,
|
||||
Booking.check_in_date <= datetime.utcnow() + timedelta(days=1)
|
||||
)
|
||||
).first()
|
||||
|
||||
if upcoming_booking:
|
||||
# Room has upcoming booking, keep as available (ready for check-in)
|
||||
room.status = RoomStatus.available
|
||||
else:
|
||||
# No upcoming bookings, room is available
|
||||
room.status = RoomStatus.available
|
||||
|
||||
if 'checklist_items' in task_data:
|
||||
task.checklist_items = task_data['checklist_items']
|
||||
@@ -591,7 +716,7 @@ async def update_housekeeping_task(
|
||||
# Send notification if assignment changed
|
||||
if assigned_to_changed and task.assigned_to:
|
||||
try:
|
||||
from ..routes.chat_routes import manager
|
||||
from ...notifications.routes.notification_routes import notification_manager
|
||||
room = db.query(Room).filter(Room.id == task.room_id).first()
|
||||
task_data_notification = {
|
||||
'id': task.id,
|
||||
@@ -608,13 +733,9 @@ async def update_housekeeping_task(
|
||||
'data': task_data_notification
|
||||
}
|
||||
# Send notification to the newly assigned staff member
|
||||
if task.assigned_to in manager.staff_connections:
|
||||
try:
|
||||
await manager.staff_connections[task.assigned_to].send_json(notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Error sending housekeeping task notification to staff {task.assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': task.assigned_to})
|
||||
await notification_manager.send_to_user(task.assigned_to, notification_data)
|
||||
except Exception as e:
|
||||
logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
|
||||
logger.error(f'Error sending housekeeping task notification: {str(e)}', exc_info=True)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRooms
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
from ...reviews.models.review import Review, ReviewStatus
|
||||
from ...bookings.models.booking import Booking, BookingStatus
|
||||
from ...hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
|
||||
from ..services.room_service import get_rooms_with_ratings, get_amenities_list, normalize_images, get_base_url
|
||||
import os
|
||||
import aiofiles
|
||||
@@ -424,8 +425,39 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
|
||||
|
||||
if room_data.floor is not None:
|
||||
room.floor = room_data.floor
|
||||
old_status = room.status
|
||||
if room_data.status is not None:
|
||||
room.status = RoomStatus(room_data.status)
|
||||
new_status = RoomStatus(room_data.status)
|
||||
room.status = new_status
|
||||
|
||||
# If room status is changed to cleaning, create a housekeeping task if one doesn't exist
|
||||
if new_status == RoomStatus.cleaning and old_status != RoomStatus.cleaning:
|
||||
# Check if there's already a pending housekeeping task for this room
|
||||
existing_task = db.query(HousekeepingTask).filter(
|
||||
and_(
|
||||
HousekeepingTask.room_id == room.id,
|
||||
HousekeepingTask.status.in_([HousekeepingStatus.pending, HousekeepingStatus.in_progress])
|
||||
)
|
||||
).first()
|
||||
|
||||
if not existing_task:
|
||||
# Create a new housekeeping task for the cleaning room
|
||||
cleaning_task = HousekeepingTask(
|
||||
room_id=room.id,
|
||||
task_type=HousekeepingType.vacant,
|
||||
status=HousekeepingStatus.pending,
|
||||
scheduled_time=datetime.utcnow(),
|
||||
created_by=current_user.id,
|
||||
checklist_items=[
|
||||
{'item': 'Deep clean bathroom', 'completed': False, 'notes': ''},
|
||||
{'item': 'Change linens', 'completed': False, 'notes': ''},
|
||||
{'item': 'Vacuum and mop', 'completed': False, 'notes': ''},
|
||||
{'item': 'Dust surfaces', 'completed': False, 'notes': ''},
|
||||
{'item': 'Check amenities', 'completed': False, 'notes': ''}
|
||||
],
|
||||
notes='Room set to cleaning mode'
|
||||
)
|
||||
db.add(cleaning_task)
|
||||
if room_data.featured is not None:
|
||||
room.featured = room_data.featured
|
||||
if room_data.price is not None:
|
||||
@@ -545,24 +577,55 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'rooms'
|
||||
# Calculate upload directory to match main.py (Backend/uploads/rooms)
|
||||
# From Backend/src/rooms/routes/room_routes.py -> Backend/
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'rooms'
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Import validation and optimization utilities
|
||||
from ...shared.config.settings import settings
|
||||
from ...shared.utils.file_validation import validate_uploaded_image
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
|
||||
image_urls = []
|
||||
for image in images:
|
||||
if not image.content_type or not image.content_type.startswith('image/'):
|
||||
continue
|
||||
if not image.filename:
|
||||
continue
|
||||
import uuid
|
||||
ext = Path(image.filename).suffix or '.jpg'
|
||||
filename = f'room-{uuid.uuid4()}{ext}'
|
||||
file_path = upload_dir / filename
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
content = await image.read()
|
||||
if not content:
|
||||
|
||||
try:
|
||||
# Validate the image
|
||||
content = await validate_uploaded_image(image, settings.MAX_UPLOAD_SIZE)
|
||||
|
||||
# Optimize image before saving
|
||||
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.ROOM)
|
||||
|
||||
import uuid
|
||||
ext = Path(image.filename).suffix or '.jpg'
|
||||
# Update extension if format changed
|
||||
if optimized_ext:
|
||||
ext = optimized_ext
|
||||
filename = f'room-{uuid.uuid4()}{ext}'
|
||||
file_path = upload_dir / filename
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(optimized_content)
|
||||
|
||||
# Verify file was saved
|
||||
if not file_path.exists():
|
||||
logger.error(f'File was not saved: {file_path}')
|
||||
continue
|
||||
await f.write(content)
|
||||
image_urls.append(f'/uploads/rooms/{filename}')
|
||||
|
||||
logger.info(f'Successfully uploaded and optimized image: {filename} ({len(optimized_content)} bytes)')
|
||||
image_urls.append(f'/uploads/rooms/{filename}')
|
||||
except HTTPException:
|
||||
# Skip invalid images and continue with others
|
||||
logger.warning(f'Skipping invalid image: {image.filename}')
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f'Error processing image {image.filename}: {str(e)}', exc_info=True)
|
||||
continue
|
||||
|
||||
# Handle existing_images - it might be a list, a JSON string, or None
|
||||
existing_images = room.images or []
|
||||
@@ -595,20 +658,44 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
|
||||
room = db.query(Room).filter(Room.id == id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
# For external URLs, keep the full URL for matching
|
||||
# For local files, normalize to path
|
||||
|
||||
# Normalize the input URL to extract the path part
|
||||
# The frontend may send a full URL like "http://localhost:8000/uploads/rooms/image.webp"
|
||||
# but the database stores relative paths like "/uploads/rooms/image.webp"
|
||||
is_external_url = image_url.startswith('http://') or image_url.startswith('https://')
|
||||
normalized_url = image_url
|
||||
normalized_path = image_url
|
||||
filename = None
|
||||
|
||||
if is_external_url:
|
||||
# For external URLs, use the full URL as-is for matching
|
||||
normalized_url = image_url
|
||||
# Extract the path from the full URL
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed_url = urlparse(image_url)
|
||||
normalized_path = parsed_url.path # Extract path like "/uploads/rooms/image.webp"
|
||||
|
||||
# Check if it's a local uploads path (not an external image service)
|
||||
if normalized_path.startswith('/uploads/'):
|
||||
is_external_url = False # It's a local file with full URL
|
||||
filename = Path(normalized_path).name
|
||||
else:
|
||||
# Truly external URL (like Unsplash)
|
||||
normalized_path = image_url
|
||||
except Exception as e:
|
||||
logger.warning(f'Error parsing URL {image_url}: {str(e)}')
|
||||
# Fallback: try to extract path manually
|
||||
if '/uploads/' in image_url:
|
||||
match = image_url.split('/uploads/', 1)
|
||||
if len(match) == 2:
|
||||
normalized_path = f'/uploads/{match[1]}'
|
||||
is_external_url = False
|
||||
filename = Path(normalized_path).name
|
||||
else:
|
||||
# For local files, normalize the path
|
||||
if not normalized_url.startswith('/'):
|
||||
normalized_url = f'/{normalized_url}'
|
||||
filename = Path(normalized_url).name
|
||||
# Local file path - normalize it
|
||||
if not normalized_path.startswith('/'):
|
||||
normalized_path = f'/{normalized_path}'
|
||||
filename = Path(normalized_path).name
|
||||
|
||||
logger.info(f'Deleting image: original={image_url}, normalized_path={normalized_path}, filename={filename}, is_external={is_external_url}')
|
||||
|
||||
# Handle existing_images - it might be a list, a JSON string, or None
|
||||
existing_images = room.images or []
|
||||
@@ -626,24 +713,52 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
|
||||
|
||||
updated_images = []
|
||||
for img in existing_images:
|
||||
# For external URLs, match by full URL (keep images that don't match)
|
||||
if is_external_url:
|
||||
# Keep the image if it doesn't match the URL we're deleting
|
||||
if img != normalized_url:
|
||||
# Convert stored image to string for comparison
|
||||
img_str = str(img).strip()
|
||||
if not img_str:
|
||||
continue
|
||||
|
||||
# Normalize stored image path
|
||||
stored_path = img_str
|
||||
stored_is_external = stored_path.startswith('http://') or stored_path.startswith('https://')
|
||||
|
||||
if stored_is_external and is_external_url:
|
||||
# Both are external URLs - match exactly
|
||||
if img_str != image_url:
|
||||
updated_images.append(img)
|
||||
elif stored_is_external and not is_external_url:
|
||||
# Stored is external, deleting is local - keep it
|
||||
updated_images.append(img)
|
||||
elif not stored_is_external and is_external_url:
|
||||
# Stored is local, deleting is external - keep it
|
||||
updated_images.append(img)
|
||||
else:
|
||||
# For local files, match by path or filename (keep images that don't match)
|
||||
stored_path = img if img.startswith('/') else f'/{img}'
|
||||
stored_filename = Path(stored_path).name if '/' in str(stored_path) else stored_path
|
||||
# Keep the image if it doesn't match any of the comparison criteria
|
||||
if img != normalized_url and stored_path != normalized_url and (not filename or stored_filename != filename):
|
||||
# Both are local paths - normalize both for comparison
|
||||
stored_normalized = stored_path if stored_path.startswith('/') else f'/{stored_path}'
|
||||
stored_filename = Path(stored_normalized).name if '/' in stored_normalized else stored_path
|
||||
|
||||
# Match by full path or by filename
|
||||
path_matches = (stored_normalized == normalized_path or stored_path == normalized_path)
|
||||
filename_matches = (filename and stored_filename == filename)
|
||||
|
||||
if not (path_matches or filename_matches):
|
||||
# Keep images that don't match
|
||||
updated_images.append(img)
|
||||
|
||||
# Only try to delete the file if it's a local file (filename exists)
|
||||
logger.info(f'Images before: {len(existing_images)}, after: {len(updated_images)}')
|
||||
|
||||
# Only try to delete the physical file if it's a local file (filename exists)
|
||||
if filename:
|
||||
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'rooms' / filename
|
||||
file_path = Path(__file__).parent.parent.parent.parent / 'uploads' / 'rooms' / filename
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
try:
|
||||
file_path.unlink()
|
||||
logger.info(f'Deleted file: {file_path}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Could not delete file {file_path}: {str(e)}')
|
||||
else:
|
||||
logger.warning(f'File does not exist: {file_path}')
|
||||
|
||||
room.images = updated_images
|
||||
db.commit()
|
||||
return {'status': 'success', 'message': 'Image deleted successfully', 'data': {'images': updated_images}}
|
||||
@@ -651,6 +766,7 @@ async def delete_room_images(id: int, image_url: str=Query(..., description='Ima
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error deleting room image: {str(e)}', exc_info=True, extra={'room_id': id, 'image_url': image_url})
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/{id}/booked-dates')
|
||||
|
||||
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
|
||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "certificates" / "borica"
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "certificates" / "borica"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
@@ -1370,22 +1370,18 @@ async def upload_company_logo(
|
||||
):
|
||||
try:
|
||||
|
||||
if not image.content_type or not image.content_type.startswith('image/'):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File must be an image"
|
||||
)
|
||||
# Validate image using comprehensive validation
|
||||
from ...shared.config.settings import settings
|
||||
from ...shared.utils.file_validation import validate_uploaded_image
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
|
||||
|
||||
content = await image.read()
|
||||
if len(content) > 2 * 1024 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Logo file size must be less than 2MB"
|
||||
)
|
||||
max_size = 2 * 1024 * 1024 # 2MB for logos
|
||||
content = await validate_uploaded_image(image, max_size)
|
||||
|
||||
# Optimize image before saving
|
||||
optimized_content, optimized_ext = await optimize_image_async(content, ImageType.COMPANY, preserve_original_format=False)
|
||||
|
||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "company"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -1394,7 +1390,7 @@ async def upload_company_logo(
|
||||
).first()
|
||||
|
||||
if old_logo_setting and old_logo_setting.value:
|
||||
old_logo_path = Path(__file__).parent.parent.parent / old_logo_setting.value.lstrip('/')
|
||||
old_logo_path = Path(__file__).parent.parent.parent.parent / old_logo_setting.value.lstrip('/')
|
||||
if old_logo_path.exists() and old_logo_path.is_file():
|
||||
try:
|
||||
old_logo_path.unlink()
|
||||
@@ -1402,14 +1398,14 @@ async def upload_company_logo(
|
||||
logger.warning(f"Could not delete old logo: {e}")
|
||||
|
||||
|
||||
ext = Path(image.filename).suffix or '.png'
|
||||
|
||||
filename = "logo.png"
|
||||
# Use optimized extension, default to webp or png
|
||||
ext = optimized_ext or '.webp'
|
||||
filename = f"logo{ext}"
|
||||
file_path = upload_dir / filename
|
||||
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
await f.write(optimized_content)
|
||||
|
||||
|
||||
image_url = f"/uploads/company/{filename}"
|
||||
@@ -1462,32 +1458,54 @@ async def upload_company_favicon(
|
||||
):
|
||||
try:
|
||||
|
||||
if not image.content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File type could not be determined"
|
||||
)
|
||||
# Validate image
|
||||
from ...shared.config.settings import settings
|
||||
from ...shared.utils.file_validation import validate_uploaded_image
|
||||
from ...shared.utils.image_optimization import optimize_image_async, ImageType
|
||||
|
||||
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico']
|
||||
if image.content_type not in allowed_types:
|
||||
|
||||
filename_lower = (image.filename or '').lower()
|
||||
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']):
|
||||
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 must be .ico, .png, or .svg file"
|
||||
detail="Favicon file size must be less than 500KB"
|
||||
)
|
||||
|
||||
|
||||
content = await image.read()
|
||||
if len(content) > 500 * 1024:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Favicon file size must be less than 500KB"
|
||||
# Keep SVG as-is
|
||||
optimized_content = content
|
||||
optimized_ext = '.svg'
|
||||
else:
|
||||
# For ICO/PNG, validate and optimize
|
||||
if not image.content_type:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File type could not be determined"
|
||||
)
|
||||
|
||||
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/ico']
|
||||
if image.content_type not in allowed_types:
|
||||
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png']):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Favicon must be .ico or .png file"
|
||||
)
|
||||
|
||||
content = await validate_uploaded_image(image, max_size)
|
||||
|
||||
# Optimize favicon but preserve format (ICO/PNG)
|
||||
optimized_content, optimized_ext = await optimize_image_async(
|
||||
content,
|
||||
ImageType.COMPANY,
|
||||
preserve_original_format=True # Keep original format for favicons
|
||||
)
|
||||
|
||||
|
||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
|
||||
upload_dir = Path(__file__).parent.parent.parent.parent / "uploads" / "company"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
@@ -1496,7 +1514,7 @@ async def upload_company_favicon(
|
||||
).first()
|
||||
|
||||
if old_favicon_setting and old_favicon_setting.value:
|
||||
old_favicon_path = Path(__file__).parent.parent.parent / old_favicon_setting.value.lstrip('/')
|
||||
old_favicon_path = Path(__file__).parent.parent.parent.parent / old_favicon_setting.value.lstrip('/')
|
||||
if old_favicon_path.exists() and old_favicon_path.is_file():
|
||||
try:
|
||||
old_favicon_path.unlink()
|
||||
@@ -1504,11 +1522,11 @@ async def upload_company_favicon(
|
||||
logger.warning(f"Could not delete old favicon: {e}")
|
||||
|
||||
|
||||
filename_lower = (image.filename or '').lower()
|
||||
if filename_lower.endswith('.ico'):
|
||||
filename = "favicon.ico"
|
||||
elif filename_lower.endswith('.svg'):
|
||||
# Determine filename based on original or optimized extension
|
||||
if is_svg or filename_lower.endswith('.svg'):
|
||||
filename = "favicon.svg"
|
||||
elif optimized_ext == '.ico' or filename_lower.endswith('.ico'):
|
||||
filename = "favicon.ico"
|
||||
else:
|
||||
filename = "favicon.png"
|
||||
|
||||
@@ -1516,7 +1534,7 @@ async def upload_company_favicon(
|
||||
|
||||
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
await f.write(optimized_content)
|
||||
|
||||
|
||||
image_url = f"/uploads/company/{filename}"
|
||||
|
||||
Reference in New Issue
Block a user