diff --git a/Backend/alembic/versions/__pycache__/add_guest_requests_table.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_guest_requests_table.cpython-312.pyc
new file mode 100644
index 00000000..09d42114
Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_guest_requests_table.cpython-312.pyc differ
diff --git a/Backend/alembic/versions/__pycache__/add_inventory_management_tables.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_inventory_management_tables.cpython-312.pyc
new file mode 100644
index 00000000..916e7c56
Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_inventory_management_tables.cpython-312.pyc differ
diff --git a/Backend/alembic/versions/__pycache__/add_photos_to_housekeeping_tasks.cpython-312.pyc b/Backend/alembic/versions/__pycache__/add_photos_to_housekeeping_tasks.cpython-312.pyc
new file mode 100644
index 00000000..029b575e
Binary files /dev/null and b/Backend/alembic/versions/__pycache__/add_photos_to_housekeeping_tasks.cpython-312.pyc differ
diff --git a/Backend/alembic/versions/__pycache__/d032f2351965_add_financial_audit_trail_table.cpython-312.pyc b/Backend/alembic/versions/__pycache__/d032f2351965_add_financial_audit_trail_table.cpython-312.pyc
new file mode 100644
index 00000000..f36e5455
Binary files /dev/null and b/Backend/alembic/versions/__pycache__/d032f2351965_add_financial_audit_trail_table.cpython-312.pyc differ
diff --git a/Backend/alembic/versions/add_guest_requests_table.py b/Backend/alembic/versions/add_guest_requests_table.py
new file mode 100644
index 00000000..cfa31a35
--- /dev/null
+++ b/Backend/alembic/versions/add_guest_requests_table.py
@@ -0,0 +1,73 @@
+"""add_guest_requests_table
+
+Revision ID: guest_requests_001
+Revises: inventory_management_001
+Create Date: 2025-01-02 14:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+
+# revision identifiers, used by Alembic.
+revision = 'guest_requests_001'
+down_revision = 'inventory_management_001'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create guest_requests table
+ op.create_table(
+ 'guest_requests',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('booking_id', sa.Integer(), nullable=False),
+ sa.Column('room_id', sa.Integer(), nullable=False),
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('request_type', sa.Enum(
+ 'extra_towels', 'extra_pillows', 'room_cleaning', 'turndown_service',
+ 'amenities', 'maintenance', 'room_service', 'other',
+ name='requesttype'
+ ), nullable=False),
+ sa.Column('status', sa.Enum(
+ 'pending', 'in_progress', 'fulfilled', 'cancelled',
+ name='requeststatus'
+ ), nullable=False, server_default='pending'),
+ sa.Column('priority', sa.Enum(
+ 'low', 'normal', 'high', 'urgent',
+ name='requestpriority'
+ ), nullable=False, server_default='normal'),
+ sa.Column('title', sa.String(255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('assigned_to', sa.Integer(), nullable=True),
+ sa.Column('fulfilled_by', sa.Integer(), nullable=True),
+ sa.Column('requested_at', sa.DateTime(), nullable=False),
+ sa.Column('started_at', sa.DateTime(), nullable=True),
+ sa.Column('fulfilled_at', sa.DateTime(), nullable=True),
+ sa.Column('guest_notes', sa.Text(), nullable=True),
+ sa.Column('staff_notes', sa.Text(), nullable=True),
+ sa.Column('response_time_minutes', sa.Integer(), nullable=True),
+ sa.Column('fulfillment_time_minutes', sa.Integer(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
+ sa.ForeignKeyConstraint(['room_id'], ['rooms.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['assigned_to'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['fulfilled_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_guest_requests_id'), 'guest_requests', ['id'], unique=False)
+ op.create_index(op.f('ix_guest_requests_booking_id'), 'guest_requests', ['booking_id'], unique=False)
+ op.create_index(op.f('ix_guest_requests_room_id'), 'guest_requests', ['room_id'], unique=False)
+ op.create_index(op.f('ix_guest_requests_requested_at'), 'guest_requests', ['requested_at'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f('ix_guest_requests_requested_at'), table_name='guest_requests')
+ op.drop_index(op.f('ix_guest_requests_room_id'), table_name='guest_requests')
+ op.drop_index(op.f('ix_guest_requests_booking_id'), table_name='guest_requests')
+ op.drop_index(op.f('ix_guest_requests_id'), table_name='guest_requests')
+ op.drop_table('guest_requests')
+
diff --git a/Backend/alembic/versions/add_inventory_management_tables.py b/Backend/alembic/versions/add_inventory_management_tables.py
new file mode 100644
index 00000000..b7a30e02
--- /dev/null
+++ b/Backend/alembic/versions/add_inventory_management_tables.py
@@ -0,0 +1,171 @@
+"""add_inventory_management_tables
+
+Revision ID: inventory_management_001
+Revises: add_photos_to_housekeeping_tasks
+Create Date: 2025-01-02 12:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+
+# revision identifiers, used by Alembic.
+revision = 'inventory_management_001'
+down_revision = 'add_photos_housekeeping'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create inventory_items table
+ op.create_table(
+ 'inventory_items',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('name', sa.String(255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('category', sa.Enum(
+ 'cleaning_supplies', 'linens', 'toiletries', 'amenities',
+ 'maintenance', 'food_beverage', 'other',
+ name='inventorycategory'
+ ), nullable=False),
+ sa.Column('unit', sa.Enum(
+ 'piece', 'box', 'bottle', 'roll', 'pack', 'liter',
+ 'kilogram', 'meter', 'other',
+ name='inventoryunit'
+ ), nullable=False),
+ sa.Column('current_quantity', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
+ sa.Column('minimum_quantity', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
+ sa.Column('maximum_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('reorder_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('unit_cost', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('supplier', sa.String(255), nullable=True),
+ sa.Column('supplier_contact', sa.Text(), nullable=True),
+ sa.Column('storage_location', sa.String(255), nullable=True),
+ sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
+ sa.Column('is_tracked', sa.Boolean(), nullable=False, server_default='1'),
+ sa.Column('barcode', sa.String(100), nullable=True),
+ sa.Column('sku', sa.String(100), nullable=True),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.Column('created_by', sa.Integer(), nullable=True),
+ sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_inventory_items_id'), 'inventory_items', ['id'], unique=False)
+ op.create_index(op.f('ix_inventory_items_name'), 'inventory_items', ['name'], unique=False)
+ op.create_index(op.f('ix_inventory_items_barcode'), 'inventory_items', ['barcode'], unique=True)
+ op.create_index(op.f('ix_inventory_items_sku'), 'inventory_items', ['sku'], unique=True)
+
+ # Create inventory_transactions table
+ op.create_table(
+ 'inventory_transactions',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('item_id', sa.Integer(), nullable=False),
+ sa.Column('transaction_type', sa.Enum(
+ 'consumption', 'adjustment', 'received', 'transfer',
+ 'damaged', 'returned',
+ name='transactiontype'
+ ), nullable=False),
+ sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('quantity_before', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('quantity_after', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('reference_type', sa.String(50), nullable=True),
+ sa.Column('reference_id', sa.Integer(), nullable=True),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('cost', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('performed_by', sa.Integer(), nullable=True),
+ sa.Column('transaction_date', sa.DateTime(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['item_id'], ['inventory_items.id'], ),
+ sa.ForeignKeyConstraint(['performed_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_inventory_transactions_id'), 'inventory_transactions', ['id'], unique=False)
+ op.create_index(op.f('ix_inventory_transactions_item_id'), 'inventory_transactions', ['item_id'], unique=False)
+ op.create_index(op.f('ix_inventory_transactions_reference_id'), 'inventory_transactions', ['reference_id'], unique=False)
+ op.create_index(op.f('ix_inventory_transactions_transaction_date'), 'inventory_transactions', ['transaction_date'], unique=False)
+
+ # Create inventory_reorder_requests table
+ op.create_table(
+ 'inventory_reorder_requests',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('item_id', sa.Integer(), nullable=False),
+ sa.Column('requested_quantity', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('current_quantity', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('minimum_quantity', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('status', sa.Enum(
+ 'pending', 'approved', 'ordered', 'received', 'cancelled',
+ name='reorderstatus'
+ ), nullable=False, server_default='pending'),
+ sa.Column('priority', sa.String(20), nullable=False, server_default='normal'),
+ sa.Column('requested_by', sa.Integer(), nullable=False),
+ sa.Column('requested_at', sa.DateTime(), nullable=False),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('approved_by', sa.Integer(), nullable=True),
+ sa.Column('approved_at', sa.DateTime(), nullable=True),
+ sa.Column('approval_notes', sa.Text(), nullable=True),
+ sa.Column('order_number', sa.String(100), nullable=True),
+ sa.Column('expected_delivery_date', sa.DateTime(), nullable=True),
+ sa.Column('received_quantity', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('received_at', sa.DateTime(), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['item_id'], ['inventory_items.id'], ),
+ sa.ForeignKeyConstraint(['requested_by'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['approved_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_inventory_reorder_requests_id'), 'inventory_reorder_requests', ['id'], unique=False)
+ op.create_index(op.f('ix_inventory_reorder_requests_item_id'), 'inventory_reorder_requests', ['item_id'], unique=False)
+ op.create_index(op.f('ix_inventory_reorder_requests_order_number'), 'inventory_reorder_requests', ['order_number'], unique=False)
+ op.create_index(op.f('ix_inventory_reorder_requests_requested_at'), 'inventory_reorder_requests', ['requested_at'], unique=False)
+
+ # Create inventory_task_consumptions table
+ op.create_table(
+ 'inventory_task_consumptions',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('task_id', sa.Integer(), nullable=False),
+ sa.Column('item_id', sa.Integer(), nullable=False),
+ sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=False),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('recorded_by', sa.Integer(), nullable=True),
+ sa.Column('recorded_at', sa.DateTime(), nullable=False),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['task_id'], ['housekeeping_tasks.id'], ),
+ sa.ForeignKeyConstraint(['item_id'], ['inventory_items.id'], ),
+ sa.ForeignKeyConstraint(['recorded_by'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_inventory_task_consumptions_id'), 'inventory_task_consumptions', ['id'], unique=False)
+ op.create_index(op.f('ix_inventory_task_consumptions_task_id'), 'inventory_task_consumptions', ['task_id'], unique=False)
+ op.create_index(op.f('ix_inventory_task_consumptions_item_id'), 'inventory_task_consumptions', ['item_id'], unique=False)
+ op.create_index(op.f('ix_inventory_task_consumptions_recorded_at'), 'inventory_task_consumptions', ['recorded_at'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f('ix_inventory_task_consumptions_recorded_at'), table_name='inventory_task_consumptions')
+ op.drop_index(op.f('ix_inventory_task_consumptions_item_id'), table_name='inventory_task_consumptions')
+ op.drop_index(op.f('ix_inventory_task_consumptions_task_id'), table_name='inventory_task_consumptions')
+ op.drop_index(op.f('ix_inventory_task_consumptions_id'), table_name='inventory_task_consumptions')
+ op.drop_table('inventory_task_consumptions')
+
+ op.drop_index(op.f('ix_inventory_reorder_requests_requested_at'), table_name='inventory_reorder_requests')
+ op.drop_index(op.f('ix_inventory_reorder_requests_order_number'), table_name='inventory_reorder_requests')
+ op.drop_index(op.f('ix_inventory_reorder_requests_item_id'), table_name='inventory_reorder_requests')
+ op.drop_index(op.f('ix_inventory_reorder_requests_id'), table_name='inventory_reorder_requests')
+ op.drop_table('inventory_reorder_requests')
+
+ op.drop_index(op.f('ix_inventory_transactions_transaction_date'), table_name='inventory_transactions')
+ op.drop_index(op.f('ix_inventory_transactions_reference_id'), table_name='inventory_transactions')
+ op.drop_index(op.f('ix_inventory_transactions_item_id'), table_name='inventory_transactions')
+ op.drop_index(op.f('ix_inventory_transactions_id'), table_name='inventory_transactions')
+ op.drop_table('inventory_transactions')
+
+ op.drop_index(op.f('ix_inventory_items_sku'), table_name='inventory_items')
+ op.drop_index(op.f('ix_inventory_items_barcode'), table_name='inventory_items')
+ op.drop_index(op.f('ix_inventory_items_name'), table_name='inventory_items')
+ op.drop_index(op.f('ix_inventory_items_id'), table_name='inventory_items')
+ op.drop_table('inventory_items')
+
diff --git a/Backend/alembic/versions/add_photos_to_housekeeping_tasks.py b/Backend/alembic/versions/add_photos_to_housekeeping_tasks.py
new file mode 100644
index 00000000..7a909ce1
--- /dev/null
+++ b/Backend/alembic/versions/add_photos_to_housekeeping_tasks.py
@@ -0,0 +1,28 @@
+"""add_photos_to_housekeeping_tasks
+
+Revision ID: add_photos_housekeeping
+Revises: d032f2351965
+Create Date: 2025-01-02 12:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+
+# revision identifiers, used by Alembic.
+revision = 'add_photos_housekeeping'
+down_revision = 'd032f2351965'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Add photos column to housekeeping_tasks table
+ op.add_column('housekeeping_tasks', sa.Column('photos', sa.JSON(), nullable=True))
+
+
+def downgrade() -> None:
+ # Remove photos column
+ op.drop_column('housekeeping_tasks', 'photos')
+
diff --git a/Backend/alembic/versions/add_staff_shifts_tables.py b/Backend/alembic/versions/add_staff_shifts_tables.py
new file mode 100644
index 00000000..f77eaea4
--- /dev/null
+++ b/Backend/alembic/versions/add_staff_shifts_tables.py
@@ -0,0 +1,97 @@
+"""add_staff_shifts_tables
+
+Revision ID: staff_shifts_001
+Revises: guest_requests_001
+Create Date: 2025-01-02 16:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+# revision identifiers, used by Alembic.
+revision = 'staff_shifts_001'
+down_revision = 'guest_requests_001'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Create staff_shifts table
+ op.create_table(
+ 'staff_shifts',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('staff_id', sa.Integer(), nullable=False),
+ sa.Column('shift_date', sa.DateTime(), nullable=False),
+ sa.Column('shift_type', sa.Enum('morning', 'afternoon', 'night', 'full_day', 'custom', name='shifttype'), nullable=False),
+ sa.Column('start_time', sa.Time(), nullable=False),
+ sa.Column('end_time', sa.Time(), nullable=False),
+ sa.Column('status', sa.Enum('scheduled', 'in_progress', 'completed', 'cancelled', 'no_show', name='shiftstatus'), nullable=False, server_default='scheduled'),
+ sa.Column('actual_start_time', sa.DateTime(), nullable=True),
+ sa.Column('actual_end_time', sa.DateTime(), nullable=True),
+ sa.Column('break_duration_minutes', sa.Integer(), nullable=True, server_default='30'),
+ sa.Column('assigned_by', sa.Integer(), nullable=True),
+ sa.Column('department', sa.String(length=100), nullable=True),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('handover_notes', sa.Text(), nullable=True),
+ sa.Column('tasks_completed', sa.Integer(), nullable=True, server_default='0'),
+ sa.Column('tasks_assigned', sa.Integer(), nullable=True, server_default='0'),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['assigned_by'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_staff_shifts_staff_id'), 'staff_shifts', ['staff_id'], unique=False)
+ op.create_index(op.f('ix_staff_shifts_shift_date'), 'staff_shifts', ['shift_date'], unique=False)
+
+ # Create staff_tasks table
+ op.create_table(
+ 'staff_tasks',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('shift_id', sa.Integer(), nullable=True),
+ sa.Column('staff_id', sa.Integer(), nullable=False),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.Text(), nullable=True),
+ sa.Column('task_type', sa.String(length=100), nullable=False),
+ sa.Column('priority', sa.Enum('low', 'normal', 'high', 'urgent', name='stafftaskpriority'), nullable=False, server_default='normal'),
+ sa.Column('status', sa.Enum('pending', 'assigned', 'in_progress', 'completed', 'cancelled', 'on_hold', name='stafftaskstatus'), nullable=False, server_default='pending'),
+ sa.Column('scheduled_start', sa.DateTime(), nullable=True),
+ sa.Column('scheduled_end', sa.DateTime(), nullable=True),
+ sa.Column('actual_start', sa.DateTime(), nullable=True),
+ sa.Column('actual_end', sa.DateTime(), nullable=True),
+ sa.Column('estimated_duration_minutes', sa.Integer(), nullable=True),
+ sa.Column('actual_duration_minutes', sa.Integer(), nullable=True),
+ sa.Column('assigned_by', sa.Integer(), nullable=True),
+ sa.Column('due_date', sa.DateTime(), nullable=True),
+ sa.Column('related_booking_id', sa.Integer(), nullable=True),
+ sa.Column('related_room_id', sa.Integer(), nullable=True),
+ sa.Column('related_guest_request_id', sa.Integer(), nullable=True),
+ sa.Column('related_maintenance_id', sa.Integer(), nullable=True),
+ sa.Column('notes', sa.Text(), nullable=True),
+ sa.Column('completion_notes', sa.Text(), nullable=True),
+ sa.Column('is_recurring', sa.Boolean(), nullable=False, server_default='0'),
+ sa.Column('recurrence_pattern', sa.String(length=100), nullable=True),
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+ sa.Column('updated_at', sa.DateTime(), nullable=False),
+ sa.ForeignKeyConstraint(['assigned_by'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['related_booking_id'], ['bookings.id'], ),
+ sa.ForeignKeyConstraint(['related_guest_request_id'], ['guest_requests.id'], ),
+ sa.ForeignKeyConstraint(['related_maintenance_id'], ['room_maintenance.id'], ),
+ sa.ForeignKeyConstraint(['related_room_id'], ['rooms.id'], ),
+ sa.ForeignKeyConstraint(['shift_id'], ['staff_shifts.id'], ),
+ sa.ForeignKeyConstraint(['staff_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_staff_tasks_shift_id'), 'staff_tasks', ['shift_id'], unique=False)
+ op.create_index(op.f('ix_staff_tasks_staff_id'), 'staff_tasks', ['staff_id'], unique=False)
+
+
+def downgrade() -> None:
+ op.drop_index(op.f('ix_staff_tasks_staff_id'), table_name='staff_tasks')
+ op.drop_index(op.f('ix_staff_tasks_shift_id'), table_name='staff_tasks')
+ op.drop_table('staff_tasks')
+ op.drop_index(op.f('ix_staff_shifts_shift_date'), table_name='staff_shifts')
+ op.drop_index(op.f('ix_staff_shifts_staff_id'), table_name='staff_shifts')
+ op.drop_table('staff_shifts')
+
diff --git a/Backend/alembic/versions/d032f2351965_add_financial_audit_trail_table.py b/Backend/alembic/versions/d032f2351965_add_financial_audit_trail_table.py
new file mode 100644
index 00000000..91fdef05
--- /dev/null
+++ b/Backend/alembic/versions/d032f2351965_add_financial_audit_trail_table.py
@@ -0,0 +1,106 @@
+"""add_financial_audit_trail_table
+
+Revision ID: d032f2351965
+Revises: 6f7f8689fc98
+Create Date: 2025-12-03 23:00:00.000000
+
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+
+
+# revision identifiers, used by Alembic.
+revision = 'd032f2351965'
+down_revision = '6f7f8689fc98'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ # Check if table already exists
+ from sqlalchemy import inspect
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ tables = inspector.get_table_names()
+
+ if 'financial_audit_trail' not in tables:
+ # Create financial_audit_trail table
+ op.create_table(
+ 'financial_audit_trail',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+
+ # Action details
+ sa.Column('action_type', sa.Enum(
+ 'payment_created', 'payment_completed', 'payment_refunded',
+ 'payment_failed', 'invoice_created', 'invoice_updated',
+ 'invoice_paid', 'refund_processed', 'price_modified',
+ 'discount_applied', 'promotion_applied',
+ name='financialactiontype'
+ ), nullable=False),
+ sa.Column('action_description', sa.Text(), nullable=False),
+
+ # Related entities
+ sa.Column('payment_id', sa.Integer(), nullable=True),
+ sa.Column('invoice_id', sa.Integer(), nullable=True),
+ sa.Column('booking_id', sa.Integer(), nullable=True),
+
+ # Financial details
+ sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('previous_amount', sa.Numeric(precision=10, scale=2), nullable=True),
+ sa.Column('currency', sa.String(length=3), nullable=True, server_default='USD'),
+
+ # User information
+ sa.Column('performed_by', sa.Integer(), nullable=False),
+ sa.Column('performed_by_email', sa.String(length=255), nullable=True),
+
+ # Additional context
+ sa.Column('audit_metadata', sa.JSON(), nullable=True),
+ sa.Column('notes', sa.Text(), nullable=True),
+
+ # Timestamp
+ sa.Column('created_at', sa.DateTime(), nullable=False),
+
+ # Primary key
+ sa.PrimaryKeyConstraint('id'),
+
+ # Foreign keys
+ sa.ForeignKeyConstraint(['payment_id'], ['payments.id'], name='fk_financial_audit_payment'),
+ sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], name='fk_financial_audit_invoice'),
+ sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], name='fk_financial_audit_booking'),
+ sa.ForeignKeyConstraint(['performed_by'], ['users.id'], name='fk_financial_audit_user')
+ )
+
+ # Create indexes
+ op.create_index(op.f('ix_financial_audit_trail_id'), 'financial_audit_trail', ['id'], unique=False)
+ op.create_index(op.f('ix_financial_audit_trail_action_type'), 'financial_audit_trail', ['action_type'], unique=False)
+ op.create_index(op.f('ix_financial_audit_trail_payment_id'), 'financial_audit_trail', ['payment_id'], unique=False)
+ op.create_index(op.f('ix_financial_audit_trail_invoice_id'), 'financial_audit_trail', ['invoice_id'], unique=False)
+ op.create_index(op.f('ix_financial_audit_trail_booking_id'), 'financial_audit_trail', ['booking_id'], unique=False)
+ op.create_index(op.f('ix_financial_audit_trail_performed_by'), 'financial_audit_trail', ['performed_by'], unique=False)
+ op.create_index(op.f('ix_financial_audit_trail_created_at'), 'financial_audit_trail', ['created_at'], unique=False)
+
+ # Create composite indexes
+ op.create_index('idx_financial_audit_created', 'financial_audit_trail', ['created_at'], unique=False)
+ op.create_index('idx_financial_audit_action', 'financial_audit_trail', ['action_type', 'created_at'], unique=False)
+ op.create_index('idx_financial_audit_user', 'financial_audit_trail', ['performed_by', 'created_at'], unique=False)
+ op.create_index('idx_financial_audit_booking', 'financial_audit_trail', ['booking_id', 'created_at'], unique=False)
+
+
+def downgrade() -> None:
+ # Drop indexes first
+ op.drop_index('idx_financial_audit_booking', table_name='financial_audit_trail')
+ op.drop_index('idx_financial_audit_user', table_name='financial_audit_trail')
+ op.drop_index('idx_financial_audit_action', table_name='financial_audit_trail')
+ op.drop_index('idx_financial_audit_created', table_name='financial_audit_trail')
+
+ op.drop_index(op.f('ix_financial_audit_trail_created_at'), table_name='financial_audit_trail')
+ op.drop_index(op.f('ix_financial_audit_trail_performed_by'), table_name='financial_audit_trail')
+ op.drop_index(op.f('ix_financial_audit_trail_booking_id'), table_name='financial_audit_trail')
+ op.drop_index(op.f('ix_financial_audit_trail_invoice_id'), table_name='financial_audit_trail')
+ op.drop_index(op.f('ix_financial_audit_trail_payment_id'), table_name='financial_audit_trail')
+ op.drop_index(op.f('ix_financial_audit_trail_action_type'), table_name='financial_audit_trail')
+ op.drop_index(op.f('ix_financial_audit_trail_id'), table_name='financial_audit_trail')
+
+ # Drop table
+ op.drop_table('financial_audit_trail')
diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc
index c4431ea3..fabf3a55 100644
Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ
diff --git a/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc b/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc
index 2899b11d..4b677f4e 100644
Binary files a/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc and b/Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc differ
diff --git a/Backend/src/auth/routes/user_routes.py b/Backend/src/auth/routes/user_routes.py
index e4cf116d..30bf9084 100644
--- a/Backend/src/auth/routes/user_routes.py
+++ b/Backend/src/auth/routes/user_routes.py
@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_
from typing import Optional
import bcrypt
@@ -14,8 +14,8 @@ from ...analytics.services.audit_service import audit_service
from ..schemas.user import CreateUserRequest, UpdateUserRequest
router = APIRouter(prefix='/users', tags=['users'])
-@router.get('/', dependencies=[Depends(authorize_roles('admin'))])
-async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+@router.get('/')
+async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
query = db.query(User)
if search:
@@ -110,15 +110,25 @@ async def create_user(
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}')
-async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+async def update_user(id: int, request: Request, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
"""Update a user with validated input using Pydantic schema."""
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
if not can_manage_users(current_user, db) and current_user.id != id:
raise HTTPException(status_code=403, detail='Forbidden')
- user = db.query(User).filter(User.id == id).first()
+ user = db.query(User).options(joinedload(User.role)).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
+ # Track changes for audit logging
+ changes = {}
+ old_role_id = user.role_id
+ old_role_name = user.role.name if user.role else None
+ old_is_active = user.is_active
+
# Check email uniqueness if being updated
if user_data.email and user_data.email != user.email:
existing = db.query(User).filter(User.email == user_data.email).first()
@@ -129,15 +139,89 @@ async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.email is not None and can_manage_users(current_user, db):
+ if user_data.email != user.email:
+ changes['email'] = {'old': user.email, 'new': user_data.email}
user.email = user_data.email
if user_data.phone_number is not None:
user.phone = user_data.phone_number
if user_data.role_id is not None and can_manage_users(current_user, db):
+ # SECURITY: Prevent admin from changing their own role
+ if current_user.id == id:
+ raise HTTPException(
+ status_code=400,
+ detail='You cannot change your own role. Please ask another admin to do it.'
+ )
+ new_role = db.query(Role).filter(Role.id == user_data.role_id).first()
+ new_role_name = new_role.name if new_role else None
+ if user_data.role_id != old_role_id:
+ changes['role'] = {'old': old_role_name, 'new': new_role_name, 'old_id': old_role_id, 'new_id': user_data.role_id}
user.role_id = user_data.role_id
if user_data.is_active is not None and can_manage_users(current_user, db):
+ if user_data.is_active != old_is_active:
+ changes['is_active'] = {'old': old_is_active, 'new': user_data.is_active}
user.is_active = user_data.is_active
db.commit()
db.refresh(user)
+
+ # SECURITY: Log sensitive user changes for audit trail
+ if changes and can_manage_users(current_user, db):
+ try:
+ audit_details = {
+ 'updated_user_id': user.id,
+ 'updated_user_email': user.email,
+ 'changes': changes
+ }
+
+ # Log role changes separately as high-priority audit events
+ if 'role' in changes:
+ await audit_service.log_action(
+ db=db,
+ action='user_role_changed',
+ resource_type='user',
+ user_id=current_user.id,
+ resource_id=user.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=audit_details,
+ status='success'
+ )
+
+ # Log account status changes (activation/deactivation)
+ if 'is_active' in changes:
+ action_name = 'user_deactivated' if not user_data.is_active else 'user_activated'
+ await audit_service.log_action(
+ db=db,
+ action=action_name,
+ resource_type='user',
+ user_id=current_user.id,
+ resource_id=user.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=audit_details,
+ status='success'
+ )
+
+ # Log other significant changes
+ if 'email' in changes:
+ await audit_service.log_action(
+ db=db,
+ action='user_email_changed',
+ resource_type='user',
+ user_id=current_user.id,
+ resource_id=user.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=audit_details,
+ status='success'
+ )
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.warning(f'Failed to log user update audit: {e}')
+
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}
return success_response(data={'user': user_dict}, message='User updated successfully')
except HTTPException:
@@ -147,16 +231,59 @@ async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
-async def delete_user(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def delete_user(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
- user = db.query(User).filter(User.id == id).first()
+ user = db.query(User).options(joinedload(User.role)).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail='User not found')
+
+ # SECURITY: Prevent admin from deleting themselves
+ if current_user.id == id:
+ raise HTTPException(
+ status_code=400,
+ detail='You cannot delete your own account. Please ask another admin to do it.'
+ )
+
+ # Capture user info before deletion for audit
+ deleted_user_info = {
+ 'user_id': user.id,
+ 'email': user.email,
+ 'full_name': user.full_name,
+ 'role': user.role.name if user.role else None,
+ 'role_id': user.role_id,
+ 'is_active': user.is_active
+ }
+
active_bookings = db.query(Booking).filter(Booking.user_id == id, Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count()
if active_bookings > 0:
raise HTTPException(status_code=400, detail='Cannot delete user with active bookings')
+
db.delete(user)
db.commit()
+
+ # SECURITY: Log user deletion for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='user_deleted',
+ resource_type='user',
+ user_id=current_user.id,
+ resource_id=id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=deleted_user_info,
+ status='success'
+ )
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.warning(f'Failed to log user deletion audit: {e}')
+
return success_response(message='User deleted successfully')
except HTTPException:
raise
diff --git a/Backend/src/bookings/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/bookings/routes/__pycache__/booking_routes.cpython-312.pyc
index cca1b89f..d4c4dffc 100644
Binary files a/Backend/src/bookings/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/bookings/routes/__pycache__/booking_routes.cpython-312.pyc differ
diff --git a/Backend/src/bookings/routes/__pycache__/upsell_routes.cpython-312.pyc b/Backend/src/bookings/routes/__pycache__/upsell_routes.cpython-312.pyc
new file mode 100644
index 00000000..ebe41c94
Binary files /dev/null and b/Backend/src/bookings/routes/__pycache__/upsell_routes.cpython-312.pyc differ
diff --git a/Backend/src/bookings/routes/booking_routes.py b/Backend/src/bookings/routes/booking_routes.py
index 80e8e71e..b31d5184 100644
--- a/Backend/src/bookings/routes/booking_routes.py
+++ b/Backend/src/bookings/routes/booking_routes.py
@@ -25,6 +25,7 @@ from ...shared.utils.email_templates import booking_confirmation_email_template,
from ...loyalty.services.loyalty_service import LoyaltyService
from ...shared.utils.currency_helpers import get_currency_symbol
from ...shared.utils.response_helpers import success_response
+from ...analytics.services.audit_service import audit_service
from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest
from ..schemas.admin_booking import AdminCreateBookingRequest
router = APIRouter(prefix='/bookings', tags=['bookings'])
@@ -609,8 +610,12 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
raise HTTPException(status_code=500, detail='An error occurred while fetching booking')
@router.patch('/{id}/cancel')
-async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+async def cancel_booking(id: int, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
booking = db.query(Booking).filter(Booking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
@@ -618,6 +623,8 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
raise HTTPException(status_code=403, detail='Forbidden')
if booking.status == BookingStatus.cancelled:
raise HTTPException(status_code=400, detail='Booking already cancelled')
+
+ old_status = booking.status.value if hasattr(booking.status, 'value') else str(booking.status)
# Customers can only cancel pending bookings
# Admin/Staff can cancel any booking via update_booking endpoint
if booking.status != BookingStatus.pending:
@@ -674,6 +681,34 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
if payments_updated:
db.flush()
db.commit()
+
+ # SECURITY: Log booking cancellation for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='booking_cancelled',
+ resource_type='booking',
+ user_id=current_user.id,
+ resource_id=booking.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'booking_number': booking.booking_number,
+ 'old_status': old_status,
+ 'new_status': 'cancelled',
+ 'booking_user_id': booking.user_id,
+ 'room_id': booking.room_id if booking.room else None,
+ 'total_price': float(booking.total_price) if booking.total_price else 0.0,
+ 'payments_updated': payments_updated
+ },
+ status='success'
+ )
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.warning(f'Failed to log booking cancellation audit: {e}')
+
try:
from ...system.models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
@@ -692,7 +727,11 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))])
-async def update_booking(id: int, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+async def update_booking(id: int, request: Request, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
booking = db.query(Booking).options(
selectinload(Booking.payments),
@@ -701,6 +740,7 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
if not booking:
raise HTTPException(status_code=404, detail='Booking not found')
old_status = booking.status
+ old_status_value = old_status.value if hasattr(old_status, 'value') else str(old_status)
status_value = booking_data.status
room = booking.room
new_status = None
@@ -869,6 +909,39 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
db.commit()
+ # SECURITY: Log booking status changes for audit trail (especially cancellations)
+ if status_value and old_status != booking.status:
+ try:
+ new_status_value = booking.status.value if hasattr(booking.status, 'value') else str(booking.status)
+ action_name = 'booking_status_changed'
+ if new_status == BookingStatus.cancelled:
+ action_name = 'booking_cancelled_by_admin'
+
+ await audit_service.log_action(
+ db=db,
+ action=action_name,
+ resource_type='booking',
+ user_id=current_user.id,
+ resource_id=booking.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'booking_number': booking.booking_number,
+ 'old_status': old_status_value,
+ 'new_status': new_status_value,
+ 'booking_user_id': booking.user_id,
+ 'room_id': booking.room_id if booking.room else None,
+ 'total_price': float(booking.total_price) if booking.total_price else 0.0,
+ 'changed_by': 'admin' if current_user.role and current_user.role.name == 'admin' else 'staff'
+ },
+ status='success'
+ )
+ except Exception as e:
+ import logging
+ logger = logging.getLogger(__name__)
+ logger.warning(f'Failed to log booking status change audit: {e}')
+
# Send booking confirmation notification if status changed to confirmed
if new_status == BookingStatus.confirmed:
try:
diff --git a/Backend/src/bookings/routes/upsell_routes.py b/Backend/src/bookings/routes/upsell_routes.py
new file mode 100644
index 00000000..3d954e38
--- /dev/null
+++ b/Backend/src/bookings/routes/upsell_routes.py
@@ -0,0 +1,93 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+from typing import List, Optional
+from datetime import datetime
+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 ..models.booking import Booking, BookingStatus
+from ...hotel_services.models.service_usage import ServiceUsage
+from ...hotel_services.models.service import Service
+from ...rooms.models.room import Room
+from pydantic import BaseModel
+
+logger = get_logger(__name__)
+router = APIRouter(prefix='/bookings', tags=['upsells'])
+
+
+class AddServiceUsageRequest(BaseModel):
+ booking_id: int
+ service_id: int
+ quantity: int = 1
+ unit_price: Optional[float] = None
+ notes: Optional[str] = None
+
+
+@router.post('/service-usage')
+async def add_service_usage(
+ request: AddServiceUsageRequest,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Add a service usage to an existing booking"""
+ try:
+ # Verify booking exists and is checked in
+ booking = db.query(Booking).filter(Booking.id == request.booking_id).first()
+ if not booking:
+ raise HTTPException(status_code=404, detail='Booking not found')
+
+ if booking.status != BookingStatus.checked_in:
+ raise HTTPException(
+ status_code=400,
+ detail='Services can only be added to checked-in bookings'
+ )
+
+ # Verify service exists and is active
+ service = db.query(Service).filter(Service.id == request.service_id).first()
+ if not service:
+ raise HTTPException(status_code=404, detail='Service not found')
+
+ if not service.is_active:
+ raise HTTPException(status_code=400, detail='Service is not active')
+
+ # Use provided unit_price or service price
+ unit_price = request.unit_price if request.unit_price is not None else float(service.price)
+ total_price = unit_price * request.quantity
+
+ # Create service usage
+ service_usage = ServiceUsage(
+ booking_id=request.booking_id,
+ service_id=request.service_id,
+ quantity=request.quantity,
+ unit_price=unit_price,
+ total_price=total_price,
+ notes=request.notes,
+ usage_date=datetime.utcnow()
+ )
+
+ db.add(service_usage)
+
+ # Update booking total price
+ booking.total_price = (float(booking.total_price) if booking.total_price else 0) + total_price
+
+ db.commit()
+ db.refresh(service_usage)
+
+ return {
+ 'status': 'success',
+ 'message': 'Service added to booking successfully',
+ 'data': {
+ 'id': service_usage.id,
+ 'service_name': service.name,
+ 'quantity': service_usage.quantity,
+ 'total_price': float(service_usage.total_price)
+ }
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f'Error adding service usage: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f'Failed to add service: {str(e)}')
+
diff --git a/Backend/src/guest_management/routes/__pycache__/complaint_routes.cpython-312.pyc b/Backend/src/guest_management/routes/__pycache__/complaint_routes.cpython-312.pyc
index 4fb4ea20..2a4db580 100644
Binary files a/Backend/src/guest_management/routes/__pycache__/complaint_routes.cpython-312.pyc and b/Backend/src/guest_management/routes/__pycache__/complaint_routes.cpython-312.pyc differ
diff --git a/Backend/src/guest_management/routes/__pycache__/guest_profile_routes.cpython-312.pyc b/Backend/src/guest_management/routes/__pycache__/guest_profile_routes.cpython-312.pyc
index 54c3d84a..5a7354dc 100644
Binary files a/Backend/src/guest_management/routes/__pycache__/guest_profile_routes.cpython-312.pyc and b/Backend/src/guest_management/routes/__pycache__/guest_profile_routes.cpython-312.pyc differ
diff --git a/Backend/src/guest_management/routes/complaint_routes.py b/Backend/src/guest_management/routes/complaint_routes.py
index 7d8a9509..be7274fa 100644
--- a/Backend/src/guest_management/routes/complaint_routes.py
+++ b/Backend/src/guest_management/routes/complaint_routes.py
@@ -1,7 +1,7 @@
"""
Routes for guest complaint management.
"""
-from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from typing import Optional
@@ -18,6 +18,7 @@ from ..schemas.complaint import (
AddComplaintUpdateRequest, ResolveComplaintRequest
)
from ...shared.utils.response_helpers import success_response
+from ...analytics.services.audit_service import audit_service
logger = get_logger(__name__)
router = APIRouter(prefix='/complaints', tags=['complaints'])
@@ -26,10 +27,15 @@ router = APIRouter(prefix='/complaints', tags=['complaints'])
@router.post('/')
async def create_complaint(
complaint_data: CreateComplaintRequest,
+ request: Request,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new guest complaint."""
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
# Verify booking ownership if booking_id provided
if complaint_data.booking_id:
@@ -74,6 +80,31 @@ async def create_complaint(
db.commit()
db.refresh(complaint)
+ # SECURITY: Log complaint creation for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='complaint_created',
+ resource_type='complaint',
+ user_id=current_user.id,
+ resource_id=complaint.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'complaint_id': complaint.id,
+ 'title': complaint.title,
+ 'category': complaint.category.value,
+ 'priority': complaint.priority.value,
+ 'status': complaint.status.value,
+ 'booking_id': complaint.booking_id,
+ 'room_id': complaint.room_id
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log complaint creation audit: {e}')
+
return success_response(
data={'complaint': {
'id': complaint.id,
@@ -127,6 +158,15 @@ async def get_complaints(
# Staff/admin can filter by assignee
query = query.filter(GuestComplaint.assigned_to == assigned_to)
+ from sqlalchemy.orm import joinedload
+ from ...rooms.models.room import Room
+
+ # Eager load relationships
+ query = query.options(
+ joinedload(GuestComplaint.guest),
+ joinedload(GuestComplaint.assignee)
+ )
+
# Apply filters
if status:
try:
@@ -151,16 +191,29 @@ async def get_complaints(
complaints_data = []
for complaint in complaints:
+ # Get room number if room_id exists
+ room_number = None
+ if complaint.room_id:
+ room = db.query(Room).filter(Room.id == complaint.room_id).first()
+ if room:
+ room_number = room.room_number
+
complaints_data.append({
'id': complaint.id,
'title': complaint.title,
+ 'description': complaint.description,
'category': complaint.category.value,
'priority': complaint.priority.value,
'status': complaint.status.value,
'guest_id': complaint.guest_id,
+ 'guest_name': complaint.guest.full_name if complaint.guest else None,
'booking_id': complaint.booking_id,
'room_id': complaint.room_id,
+ 'room_number': room_number,
'assigned_to': complaint.assigned_to,
+ 'assigned_staff_name': complaint.assignee.full_name if complaint.assignee else None,
+ 'resolution_notes': complaint.resolution,
+ 'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
'created_at': complaint.created_at.isoformat(),
'updated_at': complaint.updated_at.isoformat()
})
@@ -192,21 +245,37 @@ async def get_complaint(
):
"""Get a specific complaint with details."""
try:
- complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
+ from sqlalchemy.orm import joinedload
+ from ...rooms.models.room import Room
+
+ complaint = db.query(GuestComplaint).options(
+ joinedload(GuestComplaint.guest),
+ joinedload(GuestComplaint.assignee),
+ joinedload(GuestComplaint.room)
+ ).filter(GuestComplaint.id == complaint_id).first()
if not complaint:
raise HTTPException(status_code=404, detail='Complaint not found')
# Check access
from ...shared.utils.role_helpers import is_admin, is_staff
- if not (is_admin(current_user, db) or is_staff(current_user, db)):
+ is_admin_user = is_admin(current_user, db)
+ is_staff_user = is_staff(current_user, db)
+ if not (is_admin_user or is_staff_user):
if complaint.guest_id != current_user.id:
raise HTTPException(status_code=403, detail='Access denied')
# Get updates
- updates = db.query(ComplaintUpdate).filter(
+ updates = db.query(ComplaintUpdate).options(
+ joinedload(ComplaintUpdate.updater)
+ ).filter(
ComplaintUpdate.complaint_id == complaint_id
).order_by(ComplaintUpdate.created_at.asc()).all()
+ # Get room number
+ room_number = None
+ if complaint.room:
+ room_number = complaint.room.room_number
+
complaint_data = {
'id': complaint.id,
'title': complaint.title,
@@ -215,15 +284,18 @@ async def get_complaint(
'priority': complaint.priority.value,
'status': complaint.status.value,
'guest_id': complaint.guest_id,
+ 'guest_name': complaint.guest.full_name if complaint.guest else None,
'booking_id': complaint.booking_id,
'room_id': complaint.room_id,
+ 'room_number': room_number,
'assigned_to': complaint.assigned_to,
- 'resolution': complaint.resolution,
+ 'assigned_staff_name': complaint.assignee.full_name if complaint.assignee else None,
+ 'resolution_notes': complaint.resolution,
'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
'resolved_by': complaint.resolved_by,
'guest_satisfaction_rating': complaint.guest_satisfaction_rating,
'guest_feedback': complaint.guest_feedback,
- 'internal_notes': complaint.internal_notes if (is_admin(current_user, db) or is_staff(current_user, db)) else None,
+ 'internal_notes': complaint.internal_notes if (is_admin_user or is_staff_user) else None,
'attachments': complaint.attachments,
'requires_follow_up': complaint.requires_follow_up,
'follow_up_date': complaint.follow_up_date.isoformat() if complaint.follow_up_date else None,
@@ -234,6 +306,7 @@ async def get_complaint(
'update_type': u.update_type,
'description': u.description,
'updated_by': u.updated_by,
+ 'updated_by_name': u.updater.full_name if u.updater else None,
'created_at': u.created_at.isoformat()
} for u in updates]
}
@@ -253,17 +326,27 @@ async def get_complaint(
async def update_complaint(
complaint_id: int,
update_data: UpdateComplaintRequest,
+ request: Request,
current_user: User = Depends(authorize_roles('admin', 'staff')),
db: Session = Depends(get_db)
):
"""Update a complaint (admin/staff only)."""
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
if not complaint:
db.rollback()
raise HTTPException(status_code=404, detail='Complaint not found')
- # Track changes
+ # Track changes for audit
+ old_values = {
+ 'status': complaint.status.value,
+ 'priority': complaint.priority.value,
+ 'assigned_to': complaint.assigned_to
+ }
changes = []
if update_data.status:
@@ -308,6 +391,53 @@ async def update_complaint(
db.add(update)
db.commit()
+
+ # SECURITY: Log complaint status change for audit trail
+ if update_data.status and old_values['status'] != update_data.status:
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='complaint_status_changed',
+ resource_type='complaint',
+ user_id=current_user.id,
+ resource_id=complaint.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'complaint_id': complaint.id,
+ 'old_status': old_values['status'],
+ 'new_status': update_data.status,
+ 'guest_id': complaint.guest_id,
+ 'resolved_by': current_user.id if update_data.status == 'resolved' else None
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log complaint status change audit: {e}')
+
+ # SECURITY: Log complaint assignment change for audit trail
+ if update_data.assigned_to is not None and old_values['assigned_to'] != update_data.assigned_to:
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='complaint_assigned',
+ resource_type='complaint',
+ user_id=current_user.id,
+ resource_id=complaint.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'complaint_id': complaint.id,
+ 'old_assigned_to': old_values['assigned_to'],
+ 'new_assigned_to': update_data.assigned_to,
+ 'guest_id': complaint.guest_id
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log complaint assignment audit: {e}')
db.refresh(complaint)
return success_response(
@@ -332,16 +462,23 @@ async def update_complaint(
async def resolve_complaint(
complaint_id: int,
resolve_data: ResolveComplaintRequest,
+ request: Request,
current_user: User = Depends(authorize_roles('admin', 'staff')),
db: Session = Depends(get_db)
):
"""Resolve a complaint."""
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
if not complaint:
db.rollback()
raise HTTPException(status_code=404, detail='Complaint not found')
+ old_status = complaint.status.value
+
complaint.status = ComplaintStatus.resolved
complaint.resolution = resolve_data.resolution
complaint.resolved_at = datetime.utcnow()
@@ -366,6 +503,32 @@ async def resolve_complaint(
db.add(update)
db.commit()
+
+ # SECURITY: Log complaint resolution for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='complaint_resolved',
+ resource_type='complaint',
+ user_id=current_user.id,
+ resource_id=complaint.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'complaint_id': complaint.id,
+ 'old_status': old_status,
+ 'new_status': 'resolved',
+ 'guest_id': complaint.guest_id,
+ 'resolved_by': current_user.id,
+ 'satisfaction_rating': resolve_data.guest_satisfaction_rating,
+ 'has_feedback': bool(resolve_data.guest_feedback)
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log complaint resolution audit: {e}')
+
db.refresh(complaint)
return success_response(
diff --git a/Backend/src/guest_management/routes/guest_profile_routes.py b/Backend/src/guest_management/routes/guest_profile_routes.py
index 88a0c01b..84de4f0f 100644
--- a/Backend/src/guest_management/routes/guest_profile_routes.py
+++ b/Backend/src/guest_management/routes/guest_profile_routes.py
@@ -378,6 +378,70 @@ async def remove_tag_from_guest(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
+# Get Communications
+@router.get('/communications')
+async def get_communications(
+ user_id: Optional[int] = Query(None),
+ communication_type: Optional[str] = Query(None),
+ direction: Optional[str] = Query(None),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Get guest communications with filtering"""
+ try:
+ from sqlalchemy import or_, func, desc
+ from sqlalchemy.orm import joinedload
+
+ query = db.query(GuestCommunication).options(
+ joinedload(GuestCommunication.user),
+ joinedload(GuestCommunication.staff)
+ )
+
+ if user_id:
+ query = query.filter(GuestCommunication.user_id == user_id)
+
+ if communication_type:
+ query = query.filter(GuestCommunication.communication_type == CommunicationType(communication_type))
+
+ if direction:
+ query = query.filter(GuestCommunication.direction == CommunicationDirection(direction))
+
+ total = query.count()
+ communications = query.order_by(desc(GuestCommunication.created_at)).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'communications': [
+ {
+ 'id': comm.id,
+ 'user_id': comm.user_id,
+ 'guest_name': comm.user.full_name if comm.user else None,
+ 'communication_type': comm.communication_type.value,
+ 'direction': comm.direction.value,
+ 'subject': comm.subject,
+ 'content': comm.content,
+ 'booking_id': comm.booking_id,
+ 'is_automated': comm.is_automated,
+ 'created_at': comm.created_at.isoformat() if comm.created_at else None,
+ 'staff_name': comm.staff.full_name if comm.staff else None,
+ }
+ for comm in communications
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching communications: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail=f'Failed to fetch communications: {str(e)}')
+
# Create Communication Record
@router.post('/{user_id}/communications')
async def create_communication(
diff --git a/Backend/src/hotel_services/models/__init__.py b/Backend/src/hotel_services/models/__init__.py
index e69de29b..e00d88b6 100644
--- a/Backend/src/hotel_services/models/__init__.py
+++ b/Backend/src/hotel_services/models/__init__.py
@@ -0,0 +1,8 @@
+from .housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
+from .inventory_item import InventoryItem, InventoryCategory, InventoryUnit
+from .inventory_transaction import InventoryTransaction, TransactionType
+from .inventory_reorder_request import InventoryReorderRequest, ReorderStatus
+from .inventory_task_consumption import InventoryTaskConsumption
+from .guest_request import GuestRequest, RequestType, RequestStatus, RequestPriority
+from .staff_shift import StaffShift, StaffTask, ShiftType, ShiftStatus, StaffTaskPriority, StaffTaskStatus
+
diff --git a/Backend/src/hotel_services/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/__init__.cpython-312.pyc
index c775b9aa..425a2e9a 100644
Binary files a/Backend/src/hotel_services/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/hotel_services/models/__pycache__/__init__.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/guest_request.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/guest_request.cpython-312.pyc
new file mode 100644
index 00000000..92f3fc2c
Binary files /dev/null and b/Backend/src/hotel_services/models/__pycache__/guest_request.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/housekeeping_task.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/housekeeping_task.cpython-312.pyc
index cc5540a6..05b8f233 100644
Binary files a/Backend/src/hotel_services/models/__pycache__/housekeeping_task.cpython-312.pyc and b/Backend/src/hotel_services/models/__pycache__/housekeeping_task.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/inventory_item.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/inventory_item.cpython-312.pyc
new file mode 100644
index 00000000..d5fe91a5
Binary files /dev/null and b/Backend/src/hotel_services/models/__pycache__/inventory_item.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/inventory_reorder_request.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/inventory_reorder_request.cpython-312.pyc
new file mode 100644
index 00000000..bc4bbed6
Binary files /dev/null and b/Backend/src/hotel_services/models/__pycache__/inventory_reorder_request.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/inventory_task_consumption.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/inventory_task_consumption.cpython-312.pyc
new file mode 100644
index 00000000..a00b25ab
Binary files /dev/null and b/Backend/src/hotel_services/models/__pycache__/inventory_task_consumption.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/inventory_transaction.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/inventory_transaction.cpython-312.pyc
new file mode 100644
index 00000000..3495eb74
Binary files /dev/null and b/Backend/src/hotel_services/models/__pycache__/inventory_transaction.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/__pycache__/staff_shift.cpython-312.pyc b/Backend/src/hotel_services/models/__pycache__/staff_shift.cpython-312.pyc
new file mode 100644
index 00000000..83821bdf
Binary files /dev/null and b/Backend/src/hotel_services/models/__pycache__/staff_shift.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/models/guest_request.py b/Backend/src/hotel_services/models/guest_request.py
new file mode 100644
index 00000000..b1ad1b94
--- /dev/null
+++ b/Backend/src/hotel_services/models/guest_request.py
@@ -0,0 +1,70 @@
+from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean
+from sqlalchemy.orm import relationship
+from datetime import datetime
+import enum
+from ...shared.config.database import Base
+
+class RequestType(str, enum.Enum):
+ extra_towels = 'extra_towels'
+ extra_pillows = 'extra_pillows'
+ room_cleaning = 'room_cleaning'
+ turndown_service = 'turndown_service'
+ amenities = 'amenities'
+ maintenance = 'maintenance'
+ room_service = 'room_service'
+ other = 'other'
+
+class RequestStatus(str, enum.Enum):
+ pending = 'pending'
+ in_progress = 'in_progress'
+ fulfilled = 'fulfilled'
+ cancelled = 'cancelled'
+
+class RequestPriority(str, enum.Enum):
+ low = 'low'
+ normal = 'normal'
+ high = 'high'
+ urgent = 'urgent'
+
+class GuestRequest(Base):
+ __tablename__ = 'guest_requests'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True)
+ room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
+ user_id = Column(Integer, ForeignKey('users.id'), nullable=False) # Guest who made the request
+
+ request_type = Column(Enum(RequestType), nullable=False)
+ status = Column(Enum(RequestStatus), nullable=False, default=RequestStatus.pending)
+ priority = Column(Enum(RequestPriority), nullable=False, default=RequestPriority.normal)
+
+ title = Column(String(255), nullable=False)
+ description = Column(Text, nullable=True)
+
+ # Assignment
+ assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True) # Housekeeping staff
+ fulfilled_by = Column(Integer, ForeignKey('users.id'), nullable=True)
+
+ # Timestamps
+ requested_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
+ started_at = Column(DateTime, nullable=True)
+ fulfilled_at = Column(DateTime, nullable=True)
+
+ # Notes
+ guest_notes = Column(Text, nullable=True)
+ staff_notes = Column(Text, nullable=True)
+
+ # Response time tracking
+ response_time_minutes = Column(Integer, nullable=True) # Time from request to start
+ fulfillment_time_minutes = Column(Integer, nullable=True) # Time from start to fulfillment
+
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ booking = relationship('Booking')
+ room = relationship('Room')
+ guest = relationship('User', foreign_keys=[user_id])
+ assigned_staff = relationship('User', foreign_keys=[assigned_to])
+ fulfilled_staff = relationship('User', foreign_keys=[fulfilled_by])
+
diff --git a/Backend/src/hotel_services/models/housekeeping_task.py b/Backend/src/hotel_services/models/housekeeping_task.py
index 51dd6541..085c2089 100644
--- a/Backend/src/hotel_services/models/housekeeping_task.py
+++ b/Backend/src/hotel_services/models/housekeeping_task.py
@@ -41,6 +41,7 @@ class HousekeepingTask(Base):
checklist_items = Column(JSON, nullable=True) # Array of {item: string, completed: bool, notes: string}
notes = Column(Text, nullable=True)
issues_found = Column(Text, nullable=True)
+ photos = Column(JSON, nullable=True) # Array of photo URLs: ["/uploads/housekeeping/photo1.jpg", ...]
# Quality control
inspected_by = Column(Integer, ForeignKey('users.id'), nullable=True)
diff --git a/Backend/src/hotel_services/models/inventory_item.py b/Backend/src/hotel_services/models/inventory_item.py
new file mode 100644
index 00000000..059bba66
--- /dev/null
+++ b/Backend/src/hotel_services/models/inventory_item.py
@@ -0,0 +1,67 @@
+from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, Enum, ForeignKey, DateTime, JSON
+from sqlalchemy.orm import relationship
+from datetime import datetime
+import enum
+from ...shared.config.database import Base
+
+class InventoryCategory(str, enum.Enum):
+ cleaning_supplies = 'cleaning_supplies'
+ linens = 'linens'
+ toiletries = 'toiletries'
+ amenities = 'amenities'
+ maintenance = 'maintenance'
+ food_beverage = 'food_beverage'
+ other = 'other'
+
+class InventoryUnit(str, enum.Enum):
+ piece = 'piece'
+ box = 'box'
+ bottle = 'bottle'
+ roll = 'roll'
+ pack = 'pack'
+ liter = 'liter'
+ kilogram = 'kilogram'
+ meter = 'meter'
+ other = 'other'
+
+class InventoryItem(Base):
+ __tablename__ = 'inventory_items'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ name = Column(String(255), nullable=False, index=True)
+ description = Column(Text, nullable=True)
+ category = Column(Enum(InventoryCategory), nullable=False, default=InventoryCategory.other)
+ unit = Column(Enum(InventoryUnit), nullable=False, default=InventoryUnit.piece)
+
+ # Stock tracking
+ current_quantity = Column(Numeric(10, 2), nullable=False, default=0)
+ minimum_quantity = Column(Numeric(10, 2), nullable=False, default=0) # Reorder threshold
+ maximum_quantity = Column(Numeric(10, 2), nullable=True) # Max stock level
+ reorder_quantity = Column(Numeric(10, 2), nullable=True) # Suggested reorder amount
+
+ # Pricing
+ unit_cost = Column(Numeric(10, 2), nullable=True)
+ supplier = Column(String(255), nullable=True)
+ supplier_contact = Column(Text, nullable=True)
+
+ # Location
+ storage_location = Column(String(255), nullable=True) # e.g., "Housekeeping Storage Room A"
+
+ # Status
+ is_active = Column(Boolean, nullable=False, default=True)
+ is_tracked = Column(Boolean, nullable=False, default=True) # Whether to track consumption
+
+ # Metadata
+ barcode = Column(String(100), nullable=True, unique=True, index=True)
+ sku = Column(String(100), nullable=True, unique=True, index=True)
+ notes = Column(Text, nullable=True)
+
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+ created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
+
+ # Relationships
+ transactions = relationship('InventoryTransaction', back_populates='item', cascade='all, delete-orphan')
+ reorder_requests = relationship('InventoryReorderRequest', back_populates='item', cascade='all, delete-orphan')
+ task_consumptions = relationship('InventoryTaskConsumption', back_populates='item', cascade='all, delete-orphan')
+
diff --git a/Backend/src/hotel_services/models/inventory_reorder_request.py b/Backend/src/hotel_services/models/inventory_reorder_request.py
new file mode 100644
index 00000000..6495e83f
--- /dev/null
+++ b/Backend/src/hotel_services/models/inventory_reorder_request.py
@@ -0,0 +1,53 @@
+from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Numeric, Boolean
+from sqlalchemy.orm import relationship
+from datetime import datetime
+import enum
+from ...shared.config.database import Base
+
+class ReorderStatus(str, enum.Enum):
+ pending = 'pending'
+ approved = 'approved'
+ ordered = 'ordered'
+ received = 'received'
+ cancelled = 'cancelled'
+
+class InventoryReorderRequest(Base):
+ __tablename__ = 'inventory_reorder_requests'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ item_id = Column(Integer, ForeignKey('inventory_items.id'), nullable=False, index=True)
+
+ # Request details
+ requested_quantity = Column(Numeric(10, 2), nullable=False)
+ current_quantity = Column(Numeric(10, 2), nullable=False) # Stock at time of request
+ minimum_quantity = Column(Numeric(10, 2), nullable=False) # Threshold
+
+ # Status
+ status = Column(Enum(ReorderStatus), nullable=False, default=ReorderStatus.pending)
+ priority = Column(String(20), nullable=False, default='normal') # low, normal, high, urgent
+
+ # Request info
+ requested_by = Column(Integer, ForeignKey('users.id'), nullable=False)
+ requested_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
+ notes = Column(Text, nullable=True)
+
+ # Approval
+ approved_by = Column(Integer, ForeignKey('users.id'), nullable=True)
+ approved_at = Column(DateTime, nullable=True)
+ approval_notes = Column(Text, nullable=True)
+
+ # Order tracking
+ order_number = Column(String(100), nullable=True, index=True)
+ expected_delivery_date = Column(DateTime, nullable=True)
+ received_quantity = Column(Numeric(10, 2), nullable=True)
+ received_at = Column(DateTime, nullable=True)
+
+ # Timestamps
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ item = relationship('InventoryItem', back_populates='reorder_requests')
+ requester = relationship('User', foreign_keys=[requested_by])
+ approver = relationship('User', foreign_keys=[approved_by])
+
diff --git a/Backend/src/hotel_services/models/inventory_task_consumption.py b/Backend/src/hotel_services/models/inventory_task_consumption.py
new file mode 100644
index 00000000..695d23d1
--- /dev/null
+++ b/Backend/src/hotel_services/models/inventory_task_consumption.py
@@ -0,0 +1,24 @@
+from sqlalchemy import Column, Integer, Numeric, ForeignKey, DateTime, Text
+from sqlalchemy.orm import relationship
+from datetime import datetime
+from ...shared.config.database import Base
+
+class InventoryTaskConsumption(Base):
+ __tablename__ = 'inventory_task_consumptions'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ task_id = Column(Integer, ForeignKey('housekeeping_tasks.id'), nullable=False, index=True)
+ item_id = Column(Integer, ForeignKey('inventory_items.id'), nullable=False, index=True)
+
+ quantity = Column(Numeric(10, 2), nullable=False)
+ notes = Column(Text, nullable=True)
+
+ recorded_by = Column(Integer, ForeignKey('users.id'), nullable=True)
+ recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
+
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+
+ # Relationships
+ task = relationship('HousekeepingTask')
+ item = relationship('InventoryItem', back_populates='task_consumptions')
+
diff --git a/Backend/src/hotel_services/models/inventory_transaction.py b/Backend/src/hotel_services/models/inventory_transaction.py
new file mode 100644
index 00000000..8c05dc72
--- /dev/null
+++ b/Backend/src/hotel_services/models/inventory_transaction.py
@@ -0,0 +1,43 @@
+from sqlalchemy import Column, Integer, String, Numeric, Text, Enum, ForeignKey, DateTime, JSON
+from sqlalchemy.orm import relationship
+from datetime import datetime
+import enum
+from ...shared.config.database import Base
+
+class TransactionType(str, enum.Enum):
+ consumption = 'consumption' # Used in tasks
+ adjustment = 'adjustment' # Manual adjustment (correction)
+ received = 'received' # Stock received from supplier
+ transfer = 'transfer' # Transfer between locations
+ damaged = 'damaged' # Damaged/lost items
+ returned = 'returned' # Returned to supplier
+
+class InventoryTransaction(Base):
+ __tablename__ = 'inventory_transactions'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ item_id = Column(Integer, ForeignKey('inventory_items.id'), nullable=False, index=True)
+
+ transaction_type = Column(Enum(TransactionType), nullable=False)
+ quantity = Column(Numeric(10, 2), nullable=False) # Positive for received, negative for consumption
+ quantity_before = Column(Numeric(10, 2), nullable=False) # Stock before transaction
+ quantity_after = Column(Numeric(10, 2), nullable=False) # Stock after transaction
+
+ # Reference
+ reference_type = Column(String(50), nullable=True) # e.g., 'housekeeping_task', 'purchase_order'
+ reference_id = Column(Integer, nullable=True, index=True) # ID of related record
+
+ # Details
+ notes = Column(Text, nullable=True)
+ cost = Column(Numeric(10, 2), nullable=True) # Cost for this transaction
+
+ # User tracking
+ performed_by = Column(Integer, ForeignKey('users.id'), nullable=True)
+
+ # Timestamp
+ transaction_date = Column(DateTime, nullable=False, default=datetime.utcnow, index=True)
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+
+ # Relationships
+ item = relationship('InventoryItem', back_populates='transactions')
+
diff --git a/Backend/src/hotel_services/models/staff_shift.py b/Backend/src/hotel_services/models/staff_shift.py
new file mode 100644
index 00000000..5c832811
--- /dev/null
+++ b/Backend/src/hotel_services/models/staff_shift.py
@@ -0,0 +1,122 @@
+from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean, Time, Numeric
+from sqlalchemy.orm import relationship
+from datetime import datetime, date, time
+import enum
+from ...shared.config.database import Base
+
+class ShiftType(str, enum.Enum):
+ morning = 'morning' # 6 AM - 2 PM
+ afternoon = 'afternoon' # 2 PM - 10 PM
+ night = 'night' # 10 PM - 6 AM
+ full_day = 'full_day' # 8 AM - 8 PM
+ custom = 'custom' # Custom hours
+
+class ShiftStatus(str, enum.Enum):
+ scheduled = 'scheduled'
+ in_progress = 'in_progress'
+ completed = 'completed'
+ cancelled = 'cancelled'
+ no_show = 'no_show'
+
+class StaffShift(Base):
+ __tablename__ = 'staff_shifts'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ staff_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
+
+ # Shift timing
+ shift_date = Column(DateTime, nullable=False, index=True) # Date of the shift
+ shift_type = Column(Enum(ShiftType), nullable=False)
+ start_time = Column(Time, nullable=False)
+ end_time = Column(Time, nullable=False)
+
+ # Status
+ status = Column(Enum(ShiftStatus), nullable=False, default=ShiftStatus.scheduled)
+
+ # Actual times (for tracking)
+ actual_start_time = Column(DateTime, nullable=True)
+ actual_end_time = Column(DateTime, nullable=True)
+
+ # Break times
+ break_duration_minutes = Column(Integer, nullable=True, default=30) # Total break time
+
+ # Assignment
+ assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True) # Admin/staff who assigned
+ department = Column(String(100), nullable=True) # reception, housekeeping, maintenance, etc.
+
+ # Notes
+ notes = Column(Text, nullable=True)
+ handover_notes = Column(Text, nullable=True) # Notes for next shift
+
+ # Performance tracking
+ tasks_completed = Column(Integer, nullable=True, default=0)
+ tasks_assigned = Column(Integer, nullable=True, default=0)
+
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ staff = relationship('User', foreign_keys=[staff_id])
+ assigner = relationship('User', foreign_keys=[assigned_by])
+ tasks = relationship('StaffTask', back_populates='shift', cascade='all, delete-orphan')
+
+class StaffTaskPriority(str, enum.Enum):
+ low = 'low'
+ normal = 'normal'
+ high = 'high'
+ urgent = 'urgent'
+
+class StaffTaskStatus(str, enum.Enum):
+ pending = 'pending'
+ assigned = 'assigned'
+ in_progress = 'in_progress'
+ completed = 'completed'
+ cancelled = 'cancelled'
+ on_hold = 'on_hold'
+
+class StaffTask(Base):
+ __tablename__ = 'staff_tasks'
+
+ id = Column(Integer, primary_key=True, index=True, autoincrement=True)
+ shift_id = Column(Integer, ForeignKey('staff_shifts.id'), nullable=True, index=True)
+ staff_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
+
+ # Task details
+ title = Column(String(255), nullable=False)
+ description = Column(Text, nullable=True)
+ task_type = Column(String(100), nullable=False) # housekeeping, maintenance, guest_service, etc.
+ priority = Column(Enum(StaffTaskPriority), nullable=False, default=StaffTaskPriority.normal)
+ status = Column(Enum(StaffTaskStatus), nullable=False, default=StaffTaskStatus.pending)
+
+ # Timing
+ scheduled_start = Column(DateTime, nullable=True)
+ scheduled_end = Column(DateTime, nullable=True)
+ actual_start = Column(DateTime, nullable=True)
+ actual_end = Column(DateTime, nullable=True)
+ estimated_duration_minutes = Column(Integer, nullable=True)
+ actual_duration_minutes = Column(Integer, nullable=True)
+
+ # Assignment
+ assigned_by = Column(Integer, ForeignKey('users.id'), nullable=True)
+ due_date = Column(DateTime, nullable=True)
+
+ # Related entities
+ related_booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
+ related_room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
+ related_guest_request_id = Column(Integer, ForeignKey('guest_requests.id'), nullable=True)
+ related_maintenance_id = Column(Integer, ForeignKey('room_maintenance.id'), nullable=True)
+
+ # Notes and completion
+ notes = Column(Text, nullable=True)
+ completion_notes = Column(Text, nullable=True)
+ is_recurring = Column(Boolean, nullable=False, default=False)
+ recurrence_pattern = Column(String(100), nullable=True) # daily, weekly, monthly
+
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
+
+ # Relationships
+ staff = relationship('User', foreign_keys=[staff_id])
+ assigner = relationship('User', foreign_keys=[assigned_by])
+ shift = relationship('StaffShift', back_populates='tasks')
+
diff --git a/Backend/src/hotel_services/routes/__init__.py b/Backend/src/hotel_services/routes/__init__.py
index e69de29b..e2a8be14 100644
--- a/Backend/src/hotel_services/routes/__init__.py
+++ b/Backend/src/hotel_services/routes/__init__.py
@@ -0,0 +1,2 @@
+from . import inventory_routes, guest_request_routes, staff_shift_routes
+
diff --git a/Backend/src/hotel_services/routes/__pycache__/__init__.cpython-312.pyc b/Backend/src/hotel_services/routes/__pycache__/__init__.cpython-312.pyc
index 6fb3ef38..35009593 100644
Binary files a/Backend/src/hotel_services/routes/__pycache__/__init__.cpython-312.pyc and b/Backend/src/hotel_services/routes/__pycache__/__init__.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc b/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc
new file mode 100644
index 00000000..c269fc1c
Binary files /dev/null and b/Backend/src/hotel_services/routes/__pycache__/guest_request_routes.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/routes/__pycache__/inventory_routes.cpython-312.pyc b/Backend/src/hotel_services/routes/__pycache__/inventory_routes.cpython-312.pyc
new file mode 100644
index 00000000..32ad694e
Binary files /dev/null and b/Backend/src/hotel_services/routes/__pycache__/inventory_routes.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/routes/__pycache__/staff_shift_routes.cpython-312.pyc b/Backend/src/hotel_services/routes/__pycache__/staff_shift_routes.cpython-312.pyc
new file mode 100644
index 00000000..fac73582
Binary files /dev/null and b/Backend/src/hotel_services/routes/__pycache__/staff_shift_routes.cpython-312.pyc differ
diff --git a/Backend/src/hotel_services/routes/guest_request_routes.py b/Backend/src/hotel_services/routes/guest_request_routes.py
new file mode 100644
index 00000000..5baaf8d9
--- /dev/null
+++ b/Backend/src/hotel_services/routes/guest_request_routes.py
@@ -0,0 +1,401 @@
+from fastapi import APIRouter, Depends, HTTPException, status, Query
+from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import and_, or_, desc
+from typing import List, Optional
+from datetime import datetime
+from ...shared.config.database import get_db
+from ...shared.config.logging_config import get_logger
+from ...security.middleware.auth import get_current_user, authorize_roles
+from ...auth.models.user import User
+from ...auth.models.role import Role
+from ..models.guest_request import GuestRequest, RequestType, RequestStatus, RequestPriority
+from ...bookings.models.booking import Booking, BookingStatus
+from ...rooms.models.room import Room
+from pydantic import BaseModel
+
+logger = get_logger(__name__)
+router = APIRouter(prefix='/guest-requests', tags=['guest-requests'])
+
+
+# ==================== Pydantic Schemas ====================
+
+class GuestRequestCreate(BaseModel):
+ booking_id: int
+ room_id: int
+ request_type: str
+ title: str
+ description: Optional[str] = None
+ priority: str = 'normal'
+ guest_notes: Optional[str] = None
+
+class GuestRequestUpdate(BaseModel):
+ status: Optional[str] = None
+ assigned_to: Optional[int] = None
+ staff_notes: Optional[str] = None
+
+
+# ==================== Guest Requests ====================
+
+@router.get('/')
+async def get_guest_requests(
+ status: Optional[str] = Query(None),
+ request_type: Optional[str] = Query(None),
+ room_id: Optional[int] = Query(None),
+ assigned_to: Optional[int] = Query(None),
+ priority: Optional[str] = Query(None),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get guest requests with filtering"""
+ try:
+ query = db.query(GuestRequest).options(
+ joinedload(GuestRequest.booking),
+ joinedload(GuestRequest.room),
+ joinedload(GuestRequest.guest)
+ )
+
+ # Check if user is housekeeping - they can only see requests assigned to them or unassigned
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping:
+ query = query.filter(
+ or_(
+ GuestRequest.assigned_to == current_user.id,
+ GuestRequest.assigned_to.is_(None)
+ )
+ )
+
+ if status:
+ query = query.filter(GuestRequest.status == status)
+
+ if request_type:
+ query = query.filter(GuestRequest.request_type == request_type)
+
+ if room_id:
+ query = query.filter(GuestRequest.room_id == room_id)
+
+ if assigned_to:
+ query = query.filter(GuestRequest.assigned_to == assigned_to)
+
+ if priority:
+ query = query.filter(GuestRequest.priority == priority)
+
+ # Only show requests for checked-in bookings (guests must be in the room)
+ query = query.join(Booking).filter(
+ Booking.status == BookingStatus.checked_in
+ )
+
+ total = query.count()
+ requests = query.order_by(
+ desc(GuestRequest.priority == RequestPriority.urgent),
+ desc(GuestRequest.priority == RequestPriority.high),
+ desc(GuestRequest.requested_at)
+ ).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'requests': [
+ {
+ 'id': req.id,
+ 'booking_id': req.booking_id,
+ 'room_id': req.room_id,
+ 'room_number': req.room.room_number if req.room else None,
+ 'user_id': req.user_id,
+ 'guest_name': req.guest.full_name if req.guest else None,
+ 'request_type': req.request_type.value,
+ 'status': req.status.value,
+ 'priority': req.priority.value,
+ 'title': req.title,
+ 'description': req.description,
+ 'guest_notes': req.guest_notes,
+ 'staff_notes': req.staff_notes,
+ 'assigned_to': req.assigned_to,
+ 'assigned_staff_name': req.assigned_staff.full_name if req.assigned_staff else None,
+ 'fulfilled_by': req.fulfilled_by,
+ 'requested_at': req.requested_at.isoformat() if req.requested_at else None,
+ 'started_at': req.started_at.isoformat() if req.started_at else None,
+ 'fulfilled_at': req.fulfilled_at.isoformat() if req.fulfilled_at else None,
+ 'response_time_minutes': req.response_time_minutes,
+ 'fulfillment_time_minutes': req.fulfillment_time_minutes,
+ }
+ for req in requests
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching guest requests: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch guest requests')
+
+@router.post('/')
+async def create_guest_request(
+ request_data: GuestRequestCreate,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db)
+):
+ """Create a new guest request"""
+ try:
+ # Verify booking belongs to user
+ booking = db.query(Booking).filter(Booking.id == request_data.booking_id).first()
+ if not booking:
+ raise HTTPException(status_code=404, detail='Booking not found')
+
+ if booking.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only create requests for your own bookings')
+
+ # Guests can only create requests when they are checked in (in the room)
+ if booking.status != BookingStatus.checked_in:
+ raise HTTPException(
+ status_code=400,
+ detail='You can only create requests when you are checked in. Please check in first or contact reception.'
+ )
+
+ # Verify room matches booking
+ if booking.room_id != request_data.room_id:
+ raise HTTPException(status_code=400, detail='Room ID does not match booking')
+
+ guest_request = GuestRequest(
+ booking_id=request_data.booking_id,
+ room_id=request_data.room_id,
+ user_id=current_user.id,
+ request_type=RequestType(request_data.request_type),
+ priority=RequestPriority(request_data.priority),
+ title=request_data.title,
+ description=request_data.description,
+ guest_notes=request_data.guest_notes,
+ )
+
+ db.add(guest_request)
+ db.commit()
+ db.refresh(guest_request)
+
+ return {
+ 'status': 'success',
+ 'message': 'Guest request created successfully',
+ 'data': {'id': guest_request.id}
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error creating guest request: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to create guest request')
+
+@router.put('/{request_id}')
+async def update_guest_request(
+ request_id: int,
+ request_data: GuestRequestUpdate,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Update a guest request (assign, update status, add notes)"""
+ try:
+ guest_request = db.query(GuestRequest).filter(GuestRequest.id == request_id).first()
+ if not guest_request:
+ raise HTTPException(status_code=404, detail='Guest request not found')
+
+ # Check permissions
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping:
+ # Housekeeping can only update requests assigned to them or unassigned
+ if guest_request.assigned_to and guest_request.assigned_to != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only update requests assigned to you')
+
+ update_data = request_data.dict(exclude_unset=True)
+
+ # Handle status changes
+ if 'status' in update_data:
+ new_status = RequestStatus(update_data['status'])
+ old_status = guest_request.status
+
+ # Track timestamps
+ if new_status == RequestStatus.in_progress and old_status == RequestStatus.pending:
+ guest_request.started_at = datetime.utcnow()
+ if guest_request.requested_at:
+ delta = datetime.utcnow() - guest_request.requested_at
+ guest_request.response_time_minutes = int(delta.total_seconds() / 60)
+ # Auto-assign if not assigned
+ if not guest_request.assigned_to:
+ guest_request.assigned_to = current_user.id
+
+ elif new_status == RequestStatus.fulfilled and old_status != RequestStatus.fulfilled:
+ guest_request.fulfilled_at = datetime.utcnow()
+ guest_request.fulfilled_by = current_user.id
+ if guest_request.started_at:
+ delta = datetime.utcnow() - guest_request.started_at
+ guest_request.fulfillment_time_minutes = int(delta.total_seconds() / 60)
+
+ update_data['status'] = new_status
+
+ if 'assigned_to' in update_data:
+ update_data['assigned_to'] = update_data['assigned_to'] if update_data['assigned_to'] else None
+
+ for key, value in update_data.items():
+ setattr(guest_request, key, value)
+
+ db.commit()
+ db.refresh(guest_request)
+
+ return {
+ 'status': 'success',
+ 'message': 'Guest request updated successfully'
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error updating guest request: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to update guest request')
+
+@router.get('/{request_id}')
+async def get_guest_request(
+ request_id: int,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get a single guest request"""
+ try:
+ request = db.query(GuestRequest).options(
+ joinedload(GuestRequest.booking),
+ joinedload(GuestRequest.room),
+ joinedload(GuestRequest.guest),
+ joinedload(GuestRequest.assigned_staff),
+ joinedload(GuestRequest.fulfilled_staff)
+ ).filter(GuestRequest.id == request_id).first()
+
+ if not request:
+ raise HTTPException(status_code=404, detail='Guest request not found')
+
+ # Check permissions
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping and request.assigned_to and request.assigned_to != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only view requests assigned to you')
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'id': request.id,
+ 'booking_id': request.booking_id,
+ 'room_id': request.room_id,
+ 'room_number': request.room.room_number if request.room else None,
+ 'user_id': request.user_id,
+ 'guest_name': request.guest.full_name if request.guest else None,
+ 'guest_email': request.guest.email if request.guest else None,
+ 'request_type': request.request_type.value,
+ 'status': request.status.value,
+ 'priority': request.priority.value,
+ 'title': request.title,
+ 'description': request.description,
+ 'guest_notes': request.guest_notes,
+ 'staff_notes': request.staff_notes,
+ 'assigned_to': request.assigned_to,
+ 'assigned_staff_name': request.assigned_staff.full_name if request.assigned_staff else None,
+ 'fulfilled_by': request.fulfilled_by,
+ 'fulfilled_staff_name': request.fulfilled_staff.full_name if request.fulfilled_staff else None,
+ 'requested_at': request.requested_at.isoformat() if request.requested_at else None,
+ 'started_at': request.started_at.isoformat() if request.started_at else None,
+ 'fulfilled_at': request.fulfilled_at.isoformat() if request.fulfilled_at else None,
+ 'response_time_minutes': request.response_time_minutes,
+ 'fulfillment_time_minutes': request.fulfillment_time_minutes,
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching guest request: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch guest request')
+
+@router.post('/{request_id}/assign')
+async def assign_request(
+ request_id: int,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Assign a request to the current user (housekeeping)"""
+ try:
+ request = db.query(GuestRequest).filter(GuestRequest.id == request_id).first()
+ if not request:
+ raise HTTPException(status_code=404, detail='Guest request not found')
+
+ if request.status == RequestStatus.fulfilled:
+ raise HTTPException(status_code=400, detail='Cannot assign a fulfilled request')
+
+ request.assigned_to = current_user.id
+ if request.status == RequestStatus.pending:
+ request.status = RequestStatus.in_progress
+ request.started_at = datetime.utcnow()
+ if request.requested_at:
+ delta = datetime.utcnow() - request.requested_at
+ request.response_time_minutes = int(delta.total_seconds() / 60)
+
+ db.commit()
+
+ return {
+ 'status': 'success',
+ 'message': 'Request assigned successfully'
+ }
+ except Exception as e:
+ logger.error(f'Error assigning request: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to assign request')
+
+@router.post('/{request_id}/fulfill')
+async def fulfill_request(
+ request_id: int,
+ staff_notes: Optional[str] = None,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Mark a request as fulfilled"""
+ try:
+ request = db.query(GuestRequest).filter(GuestRequest.id == request_id).first()
+ if not request:
+ raise HTTPException(status_code=404, detail='Guest request not found')
+
+ # Check permissions
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping and request.assigned_to != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only fulfill requests assigned to you')
+
+ if request.status == RequestStatus.fulfilled:
+ raise HTTPException(status_code=400, detail='Request is already fulfilled')
+
+ request.status = RequestStatus.fulfilled
+ request.fulfilled_by = current_user.id
+ request.fulfilled_at = datetime.utcnow()
+
+ if staff_notes:
+ request.staff_notes = (request.staff_notes or '') + f'\n{staff_notes}' if request.staff_notes else staff_notes
+
+ if request.started_at:
+ delta = datetime.utcnow() - request.started_at
+ request.fulfillment_time_minutes = int(delta.total_seconds() / 60)
+ elif request.requested_at:
+ # If never started, calculate from request time
+ delta = datetime.utcnow() - request.requested_at
+ request.fulfillment_time_minutes = int(delta.total_seconds() / 60)
+
+ db.commit()
+
+ return {
+ 'status': 'success',
+ 'message': 'Request marked as fulfilled'
+ }
+ except Exception as e:
+ logger.error(f'Error fulfilling request: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to fulfill request')
+
diff --git a/Backend/src/hotel_services/routes/inventory_routes.py b/Backend/src/hotel_services/routes/inventory_routes.py
new file mode 100644
index 00000000..07fb9ea5
--- /dev/null
+++ b/Backend/src/hotel_services/routes/inventory_routes.py
@@ -0,0 +1,565 @@
+from fastapi import APIRouter, Depends, HTTPException, status, Query
+from sqlalchemy.orm import Session
+from sqlalchemy import and_, or_, func, desc
+from typing import List, Optional
+from datetime import datetime
+from ...shared.config.database import get_db
+from ...shared.config.logging_config import get_logger
+from ...security.middleware.auth import get_current_user, authorize_roles
+from ...auth.models.user import User
+from ...auth.models.role import Role
+from ..models.inventory_item import InventoryItem, InventoryCategory, InventoryUnit
+from ..models.inventory_transaction import InventoryTransaction, TransactionType
+from ..models.inventory_reorder_request import InventoryReorderRequest, ReorderStatus
+from ..models.inventory_task_consumption import InventoryTaskConsumption
+from ...hotel_services.models.housekeeping_task import HousekeepingTask
+from pydantic import BaseModel
+from decimal import Decimal
+
+logger = get_logger(__name__)
+router = APIRouter(prefix='/inventory', tags=['inventory-management'])
+
+
+# ==================== Pydantic Schemas ====================
+
+class InventoryItemCreate(BaseModel):
+ name: str
+ description: Optional[str] = None
+ category: str
+ unit: str
+ minimum_quantity: Decimal
+ maximum_quantity: Optional[Decimal] = None
+ reorder_quantity: Optional[Decimal] = None
+ unit_cost: Optional[Decimal] = None
+ supplier: Optional[str] = None
+ supplier_contact: Optional[str] = None
+ storage_location: Optional[str] = None
+ barcode: Optional[str] = None
+ sku: Optional[str] = None
+ notes: Optional[str] = None
+
+class InventoryItemUpdate(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ category: Optional[str] = None
+ unit: Optional[str] = None
+ minimum_quantity: Optional[Decimal] = None
+ maximum_quantity: Optional[Decimal] = None
+ reorder_quantity: Optional[Decimal] = None
+ unit_cost: Optional[Decimal] = None
+ supplier: Optional[str] = None
+ supplier_contact: Optional[str] = None
+ storage_location: Optional[str] = None
+ is_active: Optional[bool] = None
+ is_tracked: Optional[bool] = None
+ barcode: Optional[str] = None
+ sku: Optional[str] = None
+ notes: Optional[str] = None
+
+class InventoryTransactionCreate(BaseModel):
+ item_id: int
+ transaction_type: str
+ quantity: Decimal
+ notes: Optional[str] = None
+ cost: Optional[Decimal] = None
+ reference_type: Optional[str] = None
+ reference_id: Optional[int] = None
+
+class ReorderRequestCreate(BaseModel):
+ item_id: int
+ requested_quantity: Decimal
+ priority: str = 'normal'
+ notes: Optional[str] = None
+
+class TaskConsumptionCreate(BaseModel):
+ task_id: int
+ item_id: int
+ quantity: Decimal
+ notes: Optional[str] = None
+
+
+# ==================== Inventory Items ====================
+
+@router.get('/items')
+async def get_inventory_items(
+ category: Optional[str] = Query(None),
+ low_stock: Optional[bool] = Query(None),
+ is_active: Optional[bool] = Query(None),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get inventory items with filtering"""
+ try:
+ query = db.query(InventoryItem)
+
+ if category:
+ query = query.filter(InventoryItem.category == category)
+
+ if is_active is not None:
+ query = query.filter(InventoryItem.is_active == is_active)
+
+ if low_stock:
+ query = query.filter(InventoryItem.current_quantity <= InventoryItem.minimum_quantity)
+
+ total = query.count()
+ items = query.order_by(desc(InventoryItem.created_at)).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'items': [
+ {
+ 'id': item.id,
+ 'name': item.name,
+ 'description': item.description,
+ 'category': item.category.value,
+ 'unit': item.unit.value,
+ 'current_quantity': float(item.current_quantity),
+ 'minimum_quantity': float(item.minimum_quantity),
+ 'maximum_quantity': float(item.maximum_quantity) if item.maximum_quantity else None,
+ 'reorder_quantity': float(item.reorder_quantity) if item.reorder_quantity else None,
+ 'unit_cost': float(item.unit_cost) if item.unit_cost else None,
+ 'supplier': item.supplier,
+ 'storage_location': item.storage_location,
+ 'is_active': item.is_active,
+ 'is_tracked': item.is_tracked,
+ 'barcode': item.barcode,
+ 'sku': item.sku,
+ 'is_low_stock': item.current_quantity <= item.minimum_quantity,
+ 'created_at': item.created_at.isoformat() if item.created_at else None,
+ }
+ for item in items
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching inventory items: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch inventory items')
+
+@router.post('/items')
+async def create_inventory_item(
+ item_data: InventoryItemCreate,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Create a new inventory item"""
+ try:
+ item = InventoryItem(
+ name=item_data.name,
+ description=item_data.description,
+ category=InventoryCategory(item_data.category),
+ unit=InventoryUnit(item_data.unit),
+ minimum_quantity=item_data.minimum_quantity,
+ maximum_quantity=item_data.maximum_quantity,
+ reorder_quantity=item_data.reorder_quantity,
+ unit_cost=item_data.unit_cost,
+ supplier=item_data.supplier,
+ supplier_contact=item_data.supplier_contact,
+ storage_location=item_data.storage_location,
+ barcode=item_data.barcode,
+ sku=item_data.sku,
+ notes=item_data.notes,
+ current_quantity=Decimal('0'),
+ created_by=current_user.id,
+ )
+ db.add(item)
+ db.commit()
+ db.refresh(item)
+
+ return {
+ 'status': 'success',
+ 'message': 'Inventory item created successfully',
+ 'data': {'id': item.id}
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error creating inventory item: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to create inventory item')
+
+@router.put('/items/{item_id}')
+async def update_inventory_item(
+ item_id: int,
+ item_data: InventoryItemUpdate,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Update an inventory item"""
+ try:
+ item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail='Inventory item not found')
+
+ update_data = item_data.dict(exclude_unset=True)
+ if 'category' in update_data:
+ update_data['category'] = InventoryCategory(update_data['category'])
+ if 'unit' in update_data:
+ update_data['unit'] = InventoryUnit(update_data['unit'])
+
+ for key, value in update_data.items():
+ setattr(item, key, value)
+
+ db.commit()
+ db.refresh(item)
+
+ return {
+ 'status': 'success',
+ 'message': 'Inventory item updated successfully'
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error updating inventory item: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to update inventory item')
+
+@router.get('/items/{item_id}')
+async def get_inventory_item(
+ item_id: int,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get a single inventory item"""
+ try:
+ item = db.query(InventoryItem).filter(InventoryItem.id == item_id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail='Inventory item not found')
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'id': item.id,
+ 'name': item.name,
+ 'description': item.description,
+ 'category': item.category.value,
+ 'unit': item.unit.value,
+ 'current_quantity': float(item.current_quantity),
+ 'minimum_quantity': float(item.minimum_quantity),
+ 'maximum_quantity': float(item.maximum_quantity) if item.maximum_quantity else None,
+ 'reorder_quantity': float(item.reorder_quantity) if item.reorder_quantity else None,
+ 'unit_cost': float(item.unit_cost) if item.unit_cost else None,
+ 'supplier': item.supplier,
+ 'supplier_contact': item.supplier_contact,
+ 'storage_location': item.storage_location,
+ 'is_active': item.is_active,
+ 'is_tracked': item.is_tracked,
+ 'barcode': item.barcode,
+ 'sku': item.sku,
+ 'notes': item.notes,
+ 'is_low_stock': item.current_quantity <= item.minimum_quantity,
+ 'created_at': item.created_at.isoformat() if item.created_at else None,
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching inventory item: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch inventory item')
+
+
+# ==================== Inventory Transactions ====================
+
+@router.post('/transactions')
+async def create_transaction(
+ transaction_data: InventoryTransactionCreate,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Create an inventory transaction (consumption, adjustment, received, etc.)"""
+ try:
+ item = db.query(InventoryItem).filter(InventoryItem.id == transaction_data.item_id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail='Inventory item not found')
+
+ quantity_before = item.current_quantity
+ transaction_type = TransactionType(transaction_data.transaction_type)
+
+ # Calculate new quantity based on transaction type
+ if transaction_type in [TransactionType.consumption, TransactionType.damaged, TransactionType.transfer]:
+ quantity_change = -abs(transaction_data.quantity)
+ elif transaction_type in [TransactionType.received, TransactionType.returned]:
+ quantity_change = abs(transaction_data.quantity)
+ else: # adjustment
+ quantity_change = transaction_data.quantity
+
+ quantity_after = quantity_before + quantity_change
+
+ # Create transaction
+ transaction = InventoryTransaction(
+ item_id=transaction_data.item_id,
+ transaction_type=transaction_type,
+ quantity=quantity_change,
+ quantity_before=quantity_before,
+ quantity_after=quantity_after,
+ reference_type=transaction_data.reference_type,
+ reference_id=transaction_data.reference_id,
+ notes=transaction_data.notes,
+ cost=transaction_data.cost,
+ performed_by=current_user.id,
+ transaction_date=datetime.utcnow(),
+ )
+
+ # Update item quantity
+ item.current_quantity = quantity_after
+
+ db.add(transaction)
+ db.commit()
+ db.refresh(transaction)
+
+ return {
+ 'status': 'success',
+ 'message': 'Transaction created successfully',
+ 'data': {'id': transaction.id, 'new_quantity': float(quantity_after)}
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid transaction type: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error creating transaction: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to create transaction')
+
+@router.get('/items/{item_id}/transactions')
+async def get_item_transactions(
+ item_id: int,
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get transactions for a specific item"""
+ try:
+ query = db.query(InventoryTransaction).filter(InventoryTransaction.item_id == item_id)
+ total = query.count()
+ transactions = query.order_by(desc(InventoryTransaction.transaction_date)).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'transactions': [
+ {
+ 'id': t.id,
+ 'transaction_type': t.transaction_type.value,
+ 'quantity': float(t.quantity),
+ 'quantity_before': float(t.quantity_before),
+ 'quantity_after': float(t.quantity_after),
+ 'notes': t.notes,
+ 'cost': float(t.cost) if t.cost else None,
+ 'transaction_date': t.transaction_date.isoformat() if t.transaction_date else None,
+ }
+ for t in transactions
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching transactions: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch transactions')
+
+
+# ==================== Reorder Requests ====================
+
+@router.post('/reorder-requests')
+async def create_reorder_request(
+ request_data: ReorderRequestCreate,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Create a reorder request for low stock items"""
+ try:
+ item = db.query(InventoryItem).filter(InventoryItem.id == request_data.item_id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail='Inventory item not found')
+
+ reorder_request = InventoryReorderRequest(
+ item_id=request_data.item_id,
+ requested_quantity=request_data.requested_quantity,
+ current_quantity=item.current_quantity,
+ minimum_quantity=item.minimum_quantity,
+ priority=request_data.priority,
+ notes=request_data.notes,
+ requested_by=current_user.id,
+ )
+
+ db.add(reorder_request)
+ db.commit()
+ db.refresh(reorder_request)
+
+ return {
+ 'status': 'success',
+ 'message': 'Reorder request created successfully',
+ 'data': {'id': reorder_request.id}
+ }
+ except Exception as e:
+ logger.error(f'Error creating reorder request: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to create reorder request')
+
+@router.get('/reorder-requests')
+async def get_reorder_requests(
+ status: Optional[str] = Query(None),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get reorder requests"""
+ try:
+ query = db.query(InventoryReorderRequest).join(InventoryItem)
+
+ # Check if user is housekeeping - they can only see their own requests
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping:
+ query = query.filter(InventoryReorderRequest.requested_by == current_user.id)
+
+ if status:
+ query = query.filter(InventoryReorderRequest.status == status)
+
+ total = query.count()
+ requests = query.order_by(desc(InventoryReorderRequest.requested_at)).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'requests': [
+ {
+ 'id': r.id,
+ 'item_id': r.item_id,
+ 'item_name': r.item.name,
+ 'requested_quantity': float(r.requested_quantity),
+ 'current_quantity': float(r.current_quantity),
+ 'minimum_quantity': float(r.minimum_quantity),
+ 'status': r.status.value,
+ 'priority': r.priority,
+ 'notes': r.notes,
+ 'requested_at': r.requested_at.isoformat() if r.requested_at else None,
+ }
+ for r in requests
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching reorder requests: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch reorder requests')
+
+
+# ==================== Task Consumption ====================
+
+@router.post('/task-consumption')
+async def record_task_consumption(
+ consumption_data: TaskConsumptionCreate,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Record inventory consumption for a housekeeping task"""
+ try:
+ task = db.query(HousekeepingTask).filter(HousekeepingTask.id == consumption_data.task_id).first()
+ if not task:
+ raise HTTPException(status_code=404, detail='Housekeeping task not found')
+
+ item = db.query(InventoryItem).filter(InventoryItem.id == consumption_data.item_id).first()
+ if not item:
+ raise HTTPException(status_code=404, detail='Inventory item not found')
+
+ # Check if user is housekeeping - they can only record for their own tasks
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping and task.assigned_to != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only record consumption for your assigned tasks')
+
+ # Create consumption record
+ consumption = InventoryTaskConsumption(
+ task_id=consumption_data.task_id,
+ item_id=consumption_data.item_id,
+ quantity=consumption_data.quantity,
+ notes=consumption_data.notes,
+ recorded_by=current_user.id,
+ recorded_at=datetime.utcnow(),
+ )
+
+ # Create transaction and update stock
+ quantity_before = item.current_quantity
+ quantity_after = quantity_before - abs(consumption_data.quantity)
+
+ transaction = InventoryTransaction(
+ item_id=consumption_data.item_id,
+ transaction_type=TransactionType.consumption,
+ quantity=-abs(consumption_data.quantity),
+ quantity_before=quantity_before,
+ quantity_after=quantity_after,
+ reference_type='housekeeping_task',
+ reference_id=consumption_data.task_id,
+ notes=f'Consumed for task {consumption_data.task_id}',
+ performed_by=current_user.id,
+ transaction_date=datetime.utcnow(),
+ )
+
+ item.current_quantity = quantity_after
+
+ db.add(consumption)
+ db.add(transaction)
+ db.commit()
+
+ return {
+ 'status': 'success',
+ 'message': 'Consumption recorded successfully'
+ }
+ except Exception as e:
+ logger.error(f'Error recording consumption: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail='Failed to record consumption')
+
+@router.get('/low-stock')
+async def get_low_stock_items(
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Get all items with low stock (current_quantity <= minimum_quantity)"""
+ try:
+ items = db.query(InventoryItem).filter(
+ InventoryItem.is_active == True,
+ InventoryItem.current_quantity <= InventoryItem.minimum_quantity
+ ).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'items': [
+ {
+ 'id': item.id,
+ 'name': item.name,
+ 'category': item.category.value,
+ 'current_quantity': float(item.current_quantity),
+ 'minimum_quantity': float(item.minimum_quantity),
+ 'unit': item.unit.value,
+ 'reorder_quantity': float(item.reorder_quantity) if item.reorder_quantity else None,
+ }
+ for item in items
+ ],
+ 'count': len(items)
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching low stock items: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch low stock items')
+
diff --git a/Backend/src/hotel_services/routes/staff_shift_routes.py b/Backend/src/hotel_services/routes/staff_shift_routes.py
new file mode 100644
index 00000000..e6f14984
--- /dev/null
+++ b/Backend/src/hotel_services/routes/staff_shift_routes.py
@@ -0,0 +1,464 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session, joinedload
+from sqlalchemy import and_, or_, func, desc
+from typing import List, Optional
+from datetime import datetime, date, time, timedelta
+from ...shared.config.database import get_db
+from ...shared.config.logging_config import get_logger
+from ...security.middleware.auth import get_current_user, authorize_roles
+from ...auth.models.user import User
+from ...auth.models.role import Role
+from ..models.staff_shift import (
+ StaffShift, StaffTask, ShiftType, ShiftStatus,
+ StaffTaskPriority, StaffTaskStatus
+)
+
+logger = get_logger(__name__)
+router = APIRouter(prefix='/staff-shifts', tags=['staff-shifts'])
+
+
+@router.get('/')
+async def get_shifts(
+ staff_id: Optional[int] = Query(None),
+ shift_date: Optional[str] = Query(None),
+ status: Optional[str] = Query(None),
+ department: Optional[str] = Query(None),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Get staff shifts with filtering"""
+ try:
+ query = db.query(StaffShift).options(
+ joinedload(StaffShift.staff),
+ joinedload(StaffShift.assigner)
+ )
+
+ # Check if user is staff (not admin) - staff should only see their own shifts
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_staff = role and role.name == 'staff'
+
+ if is_staff:
+ query = query.filter(StaffShift.staff_id == current_user.id)
+ elif staff_id:
+ query = query.filter(StaffShift.staff_id == staff_id)
+
+ if shift_date:
+ try:
+ date_obj = datetime.fromisoformat(shift_date.replace('Z', '+00:00')).date()
+ query = query.filter(func.date(StaffShift.shift_date) == date_obj)
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid date format. Please use YYYY-MM-DD.")
+
+ if status:
+ query = query.filter(StaffShift.status == ShiftStatus(status))
+
+ if department:
+ query = query.filter(StaffShift.department == department)
+
+ total = query.count()
+ shifts = query.order_by(desc(StaffShift.shift_date), desc(StaffShift.start_time)).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'shifts': [
+ {
+ 'id': shift.id,
+ 'staff_id': shift.staff_id,
+ 'staff_name': shift.staff.full_name if shift.staff else None,
+ 'shift_date': shift.shift_date.isoformat() if shift.shift_date else None,
+ 'shift_type': shift.shift_type.value,
+ 'start_time': shift.start_time.isoformat() if shift.start_time else None,
+ 'end_time': shift.end_time.isoformat() if shift.end_time else None,
+ 'status': shift.status.value,
+ 'actual_start_time': shift.actual_start_time.isoformat() if shift.actual_start_time else None,
+ 'actual_end_time': shift.actual_end_time.isoformat() if shift.actual_end_time else None,
+ 'break_duration_minutes': shift.break_duration_minutes,
+ 'department': shift.department,
+ 'notes': shift.notes,
+ 'handover_notes': shift.handover_notes,
+ 'tasks_completed': shift.tasks_completed,
+ 'tasks_assigned': shift.tasks_assigned,
+ 'assigned_by_name': shift.assigner.full_name if shift.assigner else None,
+ }
+ for shift in shifts
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching shifts: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail=f'Failed to fetch shifts: {str(e)}')
+
+
+@router.post('/')
+async def create_shift(
+ shift_data: dict,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Create a new staff shift"""
+ try:
+ # Only admin can create shifts for other staff
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_admin = role and role.name == 'admin'
+
+ staff_id = shift_data.get('staff_id')
+ if not is_admin and staff_id != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only create shifts for yourself')
+
+ if not staff_id:
+ staff_id = current_user.id
+
+ shift = StaffShift(
+ staff_id=staff_id,
+ shift_date=datetime.fromisoformat(shift_data['shift_date'].replace('Z', '+00:00')),
+ shift_type=ShiftType(shift_data.get('shift_type', 'custom')),
+ start_time=time.fromisoformat(shift_data['start_time']) if isinstance(shift_data.get('start_time'), str) else shift_data.get('start_time'),
+ end_time=time.fromisoformat(shift_data['end_time']) if isinstance(shift_data.get('end_time'), str) else shift_data.get('end_time'),
+ status=ShiftStatus(shift_data.get('status', 'scheduled')),
+ break_duration_minutes=shift_data.get('break_duration_minutes', 30),
+ department=shift_data.get('department'),
+ notes=shift_data.get('notes'),
+ assigned_by=current_user.id if is_admin else None,
+ )
+
+ db.add(shift)
+ db.commit()
+ db.refresh(shift)
+
+ return {
+ 'status': 'success',
+ 'message': 'Shift created successfully',
+ 'data': {'id': shift.id}
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error creating shift: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f'Failed to create shift: {str(e)}')
+
+
+@router.put('/{shift_id}')
+async def update_shift(
+ shift_id: int,
+ shift_data: dict,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Update a staff shift"""
+ try:
+ shift = db.query(StaffShift).filter(StaffShift.id == shift_id).first()
+ if not shift:
+ raise HTTPException(status_code=404, detail='Shift not found')
+
+ # Check permissions
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_admin = role and role.name == 'admin'
+
+ if not is_admin and shift.staff_id != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only update your own shifts')
+
+ # Update fields
+ if 'shift_date' in shift_data:
+ shift.shift_date = datetime.fromisoformat(shift_data['shift_date'].replace('Z', '+00:00'))
+ if 'shift_type' in shift_data:
+ shift.shift_type = ShiftType(shift_data['shift_type'])
+ if 'start_time' in shift_data:
+ shift.start_time = time.fromisoformat(shift_data['start_time']) if isinstance(shift_data['start_time'], str) else shift_data['start_time']
+ if 'end_time' in shift_data:
+ shift.end_time = time.fromisoformat(shift_data['end_time']) if isinstance(shift_data['end_time'], str) else shift_data['end_time']
+ if 'status' in shift_data:
+ shift.status = ShiftStatus(shift_data['status'])
+ if shift_data['status'] == 'in_progress' and not shift.actual_start_time:
+ shift.actual_start_time = datetime.utcnow()
+ elif shift_data['status'] == 'completed' and not shift.actual_end_time:
+ shift.actual_end_time = datetime.utcnow()
+ if 'break_duration_minutes' in shift_data:
+ shift.break_duration_minutes = shift_data['break_duration_minutes']
+ if 'department' in shift_data:
+ shift.department = shift_data['department']
+ if 'notes' in shift_data:
+ shift.notes = shift_data['notes']
+ if 'handover_notes' in shift_data:
+ shift.handover_notes = shift_data['handover_notes']
+
+ db.commit()
+
+ return {
+ 'status': 'success',
+ 'message': 'Shift updated successfully'
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error updating shift: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f'Failed to update shift: {str(e)}')
+
+
+@router.get('/tasks')
+async def get_staff_tasks(
+ staff_id: Optional[int] = Query(None),
+ shift_id: Optional[int] = Query(None),
+ status: Optional[str] = Query(None),
+ priority: Optional[str] = Query(None),
+ page: int = Query(1, ge=1),
+ limit: int = Query(20, ge=1, le=100),
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Get staff tasks with filtering"""
+ try:
+ query = db.query(StaffTask).options(
+ joinedload(StaffTask.staff),
+ joinedload(StaffTask.assigner),
+ joinedload(StaffTask.shift)
+ )
+
+ # Check if user is staff (not admin) - staff should only see their own tasks
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_staff = role and role.name == 'staff'
+
+ if is_staff:
+ query = query.filter(StaffTask.staff_id == current_user.id)
+ elif staff_id:
+ query = query.filter(StaffTask.staff_id == staff_id)
+
+ if shift_id:
+ query = query.filter(StaffTask.shift_id == shift_id)
+
+ if status:
+ query = query.filter(StaffTask.status == StaffTaskStatus(status))
+
+ if priority:
+ query = query.filter(StaffTask.priority == StaffTaskPriority(priority))
+
+ total = query.count()
+ tasks = query.order_by(
+ desc(StaffTask.priority == StaffTaskPriority.urgent),
+ desc(StaffTask.priority == StaffTaskPriority.high),
+ desc(StaffTask.due_date)
+ ).offset((page - 1) * limit).limit(limit).all()
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'tasks': [
+ {
+ 'id': task.id,
+ 'shift_id': task.shift_id,
+ 'staff_id': task.staff_id,
+ 'staff_name': task.staff.full_name if task.staff else None,
+ 'title': task.title,
+ 'description': task.description,
+ 'task_type': task.task_type,
+ 'priority': task.priority.value,
+ 'status': task.status.value,
+ 'scheduled_start': task.scheduled_start.isoformat() if task.scheduled_start else None,
+ 'scheduled_end': task.scheduled_end.isoformat() if task.scheduled_end else None,
+ 'actual_start': task.actual_start.isoformat() if task.actual_start else None,
+ 'actual_end': task.actual_end.isoformat() if task.actual_end else None,
+ 'estimated_duration_minutes': task.estimated_duration_minutes,
+ 'actual_duration_minutes': task.actual_duration_minutes,
+ 'due_date': task.due_date.isoformat() if task.due_date else None,
+ 'related_booking_id': task.related_booking_id,
+ 'related_room_id': task.related_room_id,
+ 'related_guest_request_id': task.related_guest_request_id,
+ 'related_maintenance_id': task.related_maintenance_id,
+ 'notes': task.notes,
+ 'completion_notes': task.completion_notes,
+ 'assigned_by_name': task.assigner.full_name if task.assigner else None,
+ }
+ for task in tasks
+ ],
+ 'pagination': {
+ 'total': total,
+ 'page': page,
+ 'limit': limit,
+ 'total_pages': (total + limit - 1) // limit
+ }
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching tasks: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail=f'Failed to fetch tasks: {str(e)}')
+
+
+@router.post('/tasks')
+async def create_task(
+ task_data: dict,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Create a new staff task"""
+ try:
+ task = StaffTask(
+ shift_id=task_data.get('shift_id'),
+ staff_id=task_data.get('staff_id', current_user.id),
+ title=task_data['title'],
+ description=task_data.get('description'),
+ task_type=task_data.get('task_type', 'general'),
+ priority=StaffTaskPriority(task_data.get('priority', 'normal')),
+ status=StaffTaskStatus(task_data.get('status', 'pending')),
+ scheduled_start=datetime.fromisoformat(task_data['scheduled_start'].replace('Z', '+00:00')) if task_data.get('scheduled_start') else None,
+ scheduled_end=datetime.fromisoformat(task_data['scheduled_end'].replace('Z', '+00:00')) if task_data.get('scheduled_end') else None,
+ estimated_duration_minutes=task_data.get('estimated_duration_minutes'),
+ due_date=datetime.fromisoformat(task_data['due_date'].replace('Z', '+00:00')) if task_data.get('due_date') else None,
+ related_booking_id=task_data.get('related_booking_id'),
+ related_room_id=task_data.get('related_room_id'),
+ related_guest_request_id=task_data.get('related_guest_request_id'),
+ related_maintenance_id=task_data.get('related_maintenance_id'),
+ notes=task_data.get('notes'),
+ assigned_by=current_user.id,
+ )
+
+ db.add(task)
+
+ # Update shift task count if shift_id is provided
+ if task.shift_id:
+ shift = db.query(StaffShift).filter(StaffShift.id == task.shift_id).first()
+ if shift:
+ shift.tasks_assigned = (shift.tasks_assigned or 0) + 1
+
+ db.commit()
+ db.refresh(task)
+
+ return {
+ 'status': 'success',
+ 'message': 'Task created successfully',
+ 'data': {'id': task.id}
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error creating task: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f'Failed to create task: {str(e)}')
+
+
+@router.put('/tasks/{task_id}')
+async def update_task(
+ task_id: int,
+ task_data: dict,
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Update a staff task"""
+ try:
+ task = db.query(StaffTask).filter(StaffTask.id == task_id).first()
+ if not task:
+ raise HTTPException(status_code=404, detail='Task not found')
+
+ # Check permissions
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_admin = role and role.name == 'admin'
+
+ if not is_admin and task.staff_id != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only update your own tasks')
+
+ # Update fields
+ if 'title' in task_data:
+ task.title = task_data['title']
+ if 'description' in task_data:
+ task.description = task_data['description']
+ if 'priority' in task_data:
+ task.priority = StaffTaskPriority(task_data['priority'])
+ if 'status' in task_data:
+ old_status = task.status
+ task.status = StaffTaskStatus(task_data['status'])
+
+ # Track timestamps
+ if task_data['status'] == 'in_progress' and old_status != StaffTaskStatus.in_progress:
+ task.actual_start = datetime.utcnow()
+ elif task_data['status'] == 'completed' and old_status != StaffTaskStatus.completed:
+ task.actual_end = datetime.utcnow()
+ if task.actual_start:
+ delta = task.actual_end - task.actual_start
+ task.actual_duration_minutes = int(delta.total_seconds() / 60)
+
+ # Update shift task count
+ if task.shift_id:
+ shift = db.query(StaffShift).filter(StaffShift.id == task.shift_id).first()
+ if shift:
+ shift.tasks_completed = (shift.tasks_completed or 0) + 1
+ if 'completion_notes' in task_data:
+ task.completion_notes = task_data['completion_notes']
+ if 'notes' in task_data:
+ task.notes = task_data['notes']
+
+ db.commit()
+
+ return {
+ 'status': 'success',
+ 'message': 'Task updated successfully'
+ }
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
+ except Exception as e:
+ logger.error(f'Error updating task: {str(e)}', exc_info=True)
+ db.rollback()
+ raise HTTPException(status_code=500, detail=f'Failed to update task: {str(e)}')
+
+
+@router.get('/workload')
+async def get_workload_summary(
+ date: Optional[str] = Query(None),
+ current_user: User = Depends(authorize_roles('admin', 'staff')),
+ db: Session = Depends(get_db)
+):
+ """Get workload summary for staff members"""
+ try:
+ query_date = datetime.utcnow().date()
+ if date:
+ try:
+ query_date = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
+ except ValueError:
+ raise HTTPException(status_code=400, detail="Invalid date format. Please use YYYY-MM-DD.")
+
+ # Get all active staff
+ staff_query = db.query(User).join(Role).filter(Role.name == 'staff')
+
+ workload_summary = []
+ for staff in staff_query.all():
+ # Get shifts for the date
+ shifts = db.query(StaffShift).filter(
+ StaffShift.staff_id == staff.id,
+ func.date(StaffShift.shift_date) == query_date
+ ).all()
+
+ # Get tasks
+ tasks = db.query(StaffTask).filter(
+ StaffTask.staff_id == staff.id,
+ func.date(StaffTask.scheduled_start) == query_date if StaffTask.scheduled_start else False
+ ).all()
+
+ workload_summary.append({
+ 'staff_id': staff.id,
+ 'staff_name': staff.full_name,
+ 'shifts_count': len(shifts),
+ 'tasks_count': len(tasks),
+ 'tasks_completed': len([t for t in tasks if t.status == StaffTaskStatus.completed]),
+ 'tasks_pending': len([t for t in tasks if t.status == StaffTaskStatus.pending]),
+ 'tasks_in_progress': len([t for t in tasks if t.status == StaffTaskStatus.in_progress]),
+ })
+
+ return {
+ 'status': 'success',
+ 'data': {
+ 'date': query_date.isoformat(),
+ 'workload': workload_summary
+ }
+ }
+ except Exception as e:
+ logger.error(f'Error fetching workload: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail=f'Failed to fetch workload: {str(e)}')
+
diff --git a/Backend/src/main.py b/Backend/src/main.py
index eb2a55cf..0d767547 100644
--- a/Backend/src/main.py
+++ b/Backend/src/main.py
@@ -289,8 +289,9 @@ app.mount('/uploads-static', StaticFiles(directory=str(uploads_dir)), name='uplo
from .auth.routes import auth_routes, user_routes
from .rooms.routes import room_routes, advanced_room_routes, rate_plan_routes
from .bookings.routes import booking_routes, group_booking_routes
+from .bookings.routes.upsell_routes import router as upsell_routes
from .payments.routes import payment_routes, invoice_routes, financial_routes, audit_trail_routes
-from .hotel_services.routes import service_routes, service_booking_routes
+from .hotel_services.routes import service_routes, service_booking_routes, inventory_routes, guest_request_routes, staff_shift_routes
from .content.routes import (
banner_routes, page_content_routes, home_routes, about_routes,
contact_routes, contact_content_routes, footer_routes, privacy_routes,
@@ -318,6 +319,7 @@ app.include_router(auth_routes.router, prefix=api_prefix)
app.include_router(room_routes.router, prefix=api_prefix)
app.include_router(booking_routes.router, prefix=api_prefix)
app.include_router(group_booking_routes.router, prefix=api_prefix)
+app.include_router(upsell_routes, prefix=api_prefix)
app.include_router(payment_routes.router, prefix=api_prefix)
app.include_router(invoice_routes.router, prefix=api_prefix)
app.include_router(financial_routes.router, prefix=api_prefix)
@@ -353,6 +355,9 @@ app.include_router(workflow_routes.router, prefix=api_prefix)
app.include_router(task_routes.router, prefix=api_prefix)
app.include_router(notification_routes.router, prefix=api_prefix)
app.include_router(advanced_room_routes.router, prefix=api_prefix)
+app.include_router(inventory_routes.router, prefix=api_prefix)
+app.include_router(guest_request_routes.router, prefix=api_prefix)
+app.include_router(staff_shift_routes.router, prefix=api_prefix)
app.include_router(rate_plan_routes.router, prefix=api_prefix)
app.include_router(package_routes.router, prefix=api_prefix)
app.include_router(security_routes.router, prefix=api_prefix)
diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py
index 63bfa73b..66fdefae 100644
--- a/Backend/src/models/__init__.py
+++ b/Backend/src/models/__init__.py
@@ -17,6 +17,13 @@ from ..rooms.models.room_maintenance import RoomMaintenance, MaintenanceType, Ma
from ..rooms.models.room_inspection import RoomInspection, InspectionType, InspectionStatus
from ..rooms.models.rate_plan import RatePlan, RatePlanRule, RatePlanType, RatePlanStatus
+# Hotel Services models
+from ..hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
+from ..hotel_services.models.inventory_item import InventoryItem, InventoryCategory, InventoryUnit
+from ..hotel_services.models.inventory_transaction import InventoryTransaction, TransactionType
+from ..hotel_services.models.inventory_reorder_request import InventoryReorderRequest, ReorderStatus
+from ..hotel_services.models.inventory_task_consumption import InventoryTaskConsumption
+
# Booking models
from ..bookings.models.booking import Booking
from ..bookings.models.checkin_checkout import CheckInCheckOut
@@ -34,6 +41,12 @@ from ..hotel_services.models.service import Service
from ..hotel_services.models.service_usage import ServiceUsage
from ..hotel_services.models.service_booking import ServiceBooking, ServiceBookingItem, ServicePayment, ServiceBookingStatus, ServicePaymentStatus, ServicePaymentMethod
from ..hotel_services.models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
+from ..hotel_services.models.inventory_item import InventoryItem, InventoryCategory, InventoryUnit
+from ..hotel_services.models.inventory_transaction import InventoryTransaction, TransactionType
+from ..hotel_services.models.inventory_reorder_request import InventoryReorderRequest, ReorderStatus
+from ..hotel_services.models.inventory_task_consumption import InventoryTaskConsumption
+from ..hotel_services.models.guest_request import GuestRequest, RequestType, RequestStatus, RequestPriority
+from ..hotel_services.models.staff_shift import StaffShift, StaffTask, ShiftType, ShiftStatus, StaffTaskPriority, StaffTaskStatus
# Content models
from ..content.models.banner import Banner
@@ -98,6 +111,12 @@ __all__ = [
# Hotel Services
'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod',
'HousekeepingTask', 'HousekeepingStatus', 'HousekeepingType',
+ 'InventoryItem', 'InventoryCategory', 'InventoryUnit',
+ 'InventoryTransaction', 'TransactionType',
+ 'InventoryReorderRequest', 'ReorderStatus',
+ 'InventoryTaskConsumption',
+ 'GuestRequest', 'RequestType', 'RequestStatus', 'RequestPriority',
+ 'StaffShift', 'StaffTask', 'ShiftType', 'ShiftStatus', 'StaffTaskPriority', 'StaffTaskStatus',
# Content
'Banner', 'BlogPost', 'PageContent', 'PageType', 'CookiePolicy', 'CookieIntegrationConfig',
# Reviews
diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc
index 821060be..210ca95b 100644
Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ
diff --git a/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc
index 17e5ff5d..685956e7 100644
Binary files a/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/audit_trail_routes.cpython-312.pyc differ
diff --git a/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc
index 337f4185..acd4bb43 100644
Binary files a/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/invoice_routes.cpython-312.pyc differ
diff --git a/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc
index 381dc70e..039143d3 100644
Binary files a/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc and b/Backend/src/payments/routes/__pycache__/payment_routes.cpython-312.pyc differ
diff --git a/Backend/src/payments/routes/audit_trail_routes.py b/Backend/src/payments/routes/audit_trail_routes.py
index 59438852..66961afa 100644
--- a/Backend/src/payments/routes/audit_trail_routes.py
+++ b/Backend/src/payments/routes/audit_trail_routes.py
@@ -3,6 +3,7 @@ Routes for financial audit trail access.
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
+from sqlalchemy.exc import ProgrammingError, OperationalError
from typing import Optional
from datetime import datetime
from ...shared.config.database import get_db
@@ -87,24 +88,29 @@ async def get_financial_audit_trail(
})
# Get total count for pagination
- total_query = db.query(FinancialAuditTrail)
- if payment_id:
- total_query = total_query.filter(FinancialAuditTrail.payment_id == payment_id)
- if invoice_id:
- total_query = total_query.filter(FinancialAuditTrail.invoice_id == invoice_id)
- if booking_id:
- total_query = total_query.filter(FinancialAuditTrail.booking_id == booking_id)
- if action_type_enum:
- total_query = total_query.filter(FinancialAuditTrail.action_type == action_type_enum)
- if user_id:
- total_query = total_query.filter(FinancialAuditTrail.performed_by == user_id)
- if start:
- total_query = total_query.filter(FinancialAuditTrail.created_at >= start)
- if end:
- total_query = total_query.filter(FinancialAuditTrail.created_at <= end)
-
- total_count = total_query.count()
- total_pages = (total_count + limit - 1) // limit
+ try:
+ total_query = db.query(FinancialAuditTrail)
+ if payment_id:
+ total_query = total_query.filter(FinancialAuditTrail.payment_id == payment_id)
+ if invoice_id:
+ total_query = total_query.filter(FinancialAuditTrail.invoice_id == invoice_id)
+ if booking_id:
+ total_query = total_query.filter(FinancialAuditTrail.booking_id == booking_id)
+ if action_type_enum:
+ total_query = total_query.filter(FinancialAuditTrail.action_type == action_type_enum)
+ if user_id:
+ total_query = total_query.filter(FinancialAuditTrail.performed_by == user_id)
+ if start:
+ total_query = total_query.filter(FinancialAuditTrail.created_at >= start)
+ if end:
+ total_query = total_query.filter(FinancialAuditTrail.created_at <= end)
+
+ total_count = total_query.count()
+ total_pages = (total_count + limit - 1) // limit
+ except (ProgrammingError, OperationalError):
+ # If table doesn't exist, count is 0
+ total_count = 0
+ total_pages = 0
return success_response(
data={
@@ -120,6 +126,26 @@ async def get_financial_audit_trail(
)
except HTTPException:
raise
+ except (ProgrammingError, OperationalError) as e:
+ # Handle case where financial_audit_trail table doesn't exist
+ error_msg = str(e)
+ if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
+ logger.warning(f'Financial audit trail table not found: {error_msg}')
+ return success_response(
+ data={
+ 'audit_trail': [],
+ 'pagination': {
+ 'page': page,
+ 'limit': limit,
+ 'total': 0,
+ 'total_pages': 0
+ },
+ 'note': 'Financial audit trail table not yet created. Please run database migrations.'
+ },
+ message='Financial audit trail is not available (table not created)'
+ )
+ logger.error(f'Database error retrieving financial audit trail: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Database error occurred while retrieving audit trail')
except Exception as e:
logger.error(f'Error retrieving financial audit trail: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while retrieving audit trail')
@@ -158,6 +184,14 @@ async def get_audit_record(
)
except HTTPException:
raise
+ except (ProgrammingError, OperationalError) as e:
+ # Handle case where financial_audit_trail table doesn't exist
+ error_msg = str(e)
+ if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
+ logger.warning(f'Financial audit trail table not found: {error_msg}')
+ raise HTTPException(status_code=404, detail='Financial audit trail table not yet created. Please run database migrations.')
+ logger.error(f'Database error retrieving audit record: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Database error occurred while retrieving audit record')
except Exception as e:
logger.error(f'Error retrieving audit record: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while retrieving audit record')
diff --git a/Backend/src/payments/routes/invoice_routes.py b/Backend/src/payments/routes/invoice_routes.py
index b73e4c29..711f2ac1 100644
--- a/Backend/src/payments/routes/invoice_routes.py
+++ b/Backend/src/payments/routes/invoice_routes.py
@@ -24,6 +24,14 @@ router = APIRouter(prefix='/invoices', tags=['invoices'])
@router.get('/')
async def get_invoices(request: Request, booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
+ # SECURITY: Verify booking ownership when booking_id is provided
+ if booking_id and not can_access_all_invoices(current_user, db):
+ booking = db.query(Booking).filter(Booking.id == booking_id).first()
+ if not booking:
+ raise HTTPException(status_code=404, detail='Booking not found')
+ if booking.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access invoices for this booking')
+
user_id = None if can_access_all_invoices(current_user, db) else current_user.id
result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit)
return success_response(data=result)
@@ -38,8 +46,10 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user
invoice = InvoiceService.get_invoice(id, db)
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
- if not can_access_all_invoices(current_user, db) and invoice['user_id'] != current_user.id:
- raise HTTPException(status_code=403, detail='Forbidden')
+ # SECURITY: Verify invoice ownership for non-admin/accountant users
+ if not can_access_all_invoices(current_user, db):
+ if 'user_id' not in invoice or invoice['user_id'] != current_user.id:
+ raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access this invoice')
return success_response(data={'invoice': invoice})
except HTTPException:
raise
@@ -48,9 +58,10 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user
logger.error(f'Error fetching invoice by id: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
-@router.post('/')
-async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+@router.post('/', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))])
+async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try:
+ # Defense in depth: Additional business logic check (authorize_roles already verified)
if not can_create_invoices(current_user, db):
raise HTTPException(status_code=403, detail='Forbidden')
booking_id = invoice_data.booking_id
@@ -137,13 +148,46 @@ async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvo
raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}')
-async def delete_invoice(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def delete_invoice(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = get_request_id(request)
+
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found')
+
+ # Capture invoice info before deletion for audit
+ deleted_invoice_info = {
+ 'invoice_id': invoice.id,
+ 'invoice_number': invoice.invoice_number,
+ 'user_id': invoice.user_id,
+ 'booking_id': invoice.booking_id,
+ 'total_amount': float(invoice.total_amount) if invoice.total_amount else 0.0,
+ 'status': invoice.status.value if hasattr(invoice.status, 'value') else str(invoice.status),
+ }
+
db.delete(invoice)
db.commit()
+
+ # SECURITY: Log invoice deletion for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='invoice_deleted',
+ resource_type='invoice',
+ user_id=current_user.id,
+ resource_id=id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=deleted_invoice_info,
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log invoice deletion audit: {e}')
+
return success_response(message='Invoice deleted successfully')
except HTTPException:
raise
diff --git a/Backend/src/payments/routes/payment_routes.py b/Backend/src/payments/routes/payment_routes.py
index dc1753cf..f2bd2dfe 100644
--- a/Backend/src/payments/routes/payment_routes.py
+++ b/Backend/src/payments/routes/payment_routes.py
@@ -79,6 +79,14 @@ async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reaso
@router.get('/')
async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
+ # SECURITY: Verify booking ownership when booking_id is provided
+ if booking_id and not can_access_all_payments(current_user, db):
+ booking = db.query(Booking).filter(Booking.id == booking_id).first()
+ if not booking:
+ raise HTTPException(status_code=404, detail='Booking not found')
+ if booking.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access payments for this booking')
+
if booking_id:
query = db.query(Payment).filter(Payment.booking_id == booking_id)
else:
@@ -168,12 +176,16 @@ async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends
@router.get('/{id}')
async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
- payment = db.query(Payment).filter(Payment.id == id).first()
+ # SECURITY: Load booking relationship to verify ownership
+ payment = db.query(Payment).options(joinedload(Payment.booking)).filter(Payment.id == id).first()
if not payment:
raise HTTPException(status_code=404, detail='Payment not found')
+ # SECURITY: Verify payment ownership for non-admin/accountant users
if not can_access_all_payments(current_user, db):
- if payment.booking and payment.booking.user_id != current_user.id:
- raise HTTPException(status_code=403, detail='Forbidden')
+ if not payment.booking:
+ raise HTTPException(status_code=403, detail='Forbidden: Payment does not belong to any booking')
+ if payment.booking.user_id != current_user.id:
+ raise HTTPException(status_code=403, detail='Forbidden: You do not have permission to access this payment')
payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None}
if payment.booking:
payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number}
diff --git a/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc b/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc
index 1f9773eb..95c36610 100644
Binary files a/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc and b/Backend/src/payments/services/__pycache__/financial_audit_service.cpython-312.pyc differ
diff --git a/Backend/src/payments/services/financial_audit_service.py b/Backend/src/payments/services/financial_audit_service.py
index 44e9f36e..50194c10 100644
--- a/Backend/src/payments/services/financial_audit_service.py
+++ b/Backend/src/payments/services/financial_audit_service.py
@@ -2,6 +2,7 @@
Service for creating and managing financial audit trail records.
"""
from sqlalchemy.orm import Session
+from sqlalchemy.exc import ProgrammingError, OperationalError
from typing import Optional, Dict, Any
from datetime import datetime
from ..models.financial_audit_trail import FinancialAuditTrail, FinancialActionType
@@ -56,6 +57,15 @@ class FinancialAuditService:
logger.info(f"Financial audit trail created: {action_type.value} by user {performed_by}")
return audit_record
+ except (ProgrammingError, OperationalError) as e:
+ error_msg = str(e)
+ if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
+ logger.warning(f"Financial audit trail table not found, skipping audit logging: {error_msg}")
+ # Don't fail the main operation if audit table doesn't exist
+ return None
+ logger.error(f"Database error creating financial audit trail: {str(e)}", exc_info=True)
+ # Don't fail the main operation if audit logging fails
+ raise
except Exception as e:
logger.error(f"Error creating financial audit trail: {str(e)}", exc_info=True)
# Don't fail the main operation if audit logging fails
@@ -95,7 +105,14 @@ class FinancialAuditService:
query = query.order_by(FinancialAuditTrail.created_at.desc())
query = query.limit(limit).offset(offset)
- return query.all()
+ try:
+ return query.all()
+ except (ProgrammingError, OperationalError) as e:
+ error_msg = str(e)
+ if 'doesn\'t exist' in error_msg or 'Table' in error_msg:
+ logger.warning(f"Financial audit trail table not found: {error_msg}")
+ return []
+ raise
financial_audit_service = FinancialAuditService()
diff --git a/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc b/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc
index c2b682aa..4d4706a8 100644
Binary files a/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc and b/Backend/src/reviews/routes/__pycache__/favorite_routes.cpython-312.pyc differ
diff --git a/Backend/src/reviews/routes/__pycache__/review_routes.cpython-312.pyc b/Backend/src/reviews/routes/__pycache__/review_routes.cpython-312.pyc
index dfb10967..67fe5357 100644
Binary files a/Backend/src/reviews/routes/__pycache__/review_routes.cpython-312.pyc and b/Backend/src/reviews/routes/__pycache__/review_routes.cpython-312.pyc differ
diff --git a/Backend/src/reviews/routes/favorite_routes.py b/Backend/src/reviews/routes/favorite_routes.py
index 4775479a..3d615689 100644
--- a/Backend/src/reviews/routes/favorite_routes.py
+++ b/Backend/src/reviews/routes/favorite_routes.py
@@ -13,8 +13,15 @@ router = APIRouter(prefix='/favorites', tags=['favorites'])
@router.get('/')
async def get_favorites(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
- role = db.query(Role).filter(Role.id == current_user.role_id).first()
- if role and role.name in ['admin', 'staff', 'accountant']:
+ # PERFORMANCE: Use eager-loaded role relationship if available
+ if hasattr(current_user, 'role') and current_user.role is not None:
+ role_name = current_user.role.name
+ else:
+ # Fallback: query if relationship wasn't loaded
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ role_name = role.name if role else 'customer'
+
+ if role_name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot have favorites')
try:
favorites = db.query(Favorite).filter(Favorite.user_id == current_user.id).order_by(Favorite.created_at.desc()).all()
@@ -35,8 +42,15 @@ async def get_favorites(current_user: User=Depends(get_current_user), db: Sessio
@router.post('/{room_id}')
async def add_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
- role = db.query(Role).filter(Role.id == current_user.role_id).first()
- if role and role.name in ['admin', 'staff', 'accountant']:
+ # PERFORMANCE: Use eager-loaded role relationship if available
+ if hasattr(current_user, 'role') and current_user.role is not None:
+ role_name = current_user.role.name
+ else:
+ # Fallback: query if relationship wasn't loaded
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ role_name = role.name if role else 'customer'
+
+ if role_name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot add favorites')
try:
room = db.query(Room).filter(Room.id == room_id).first()
@@ -58,8 +72,15 @@ async def add_favorite(room_id: int, current_user: User=Depends(get_current_user
@router.delete('/{room_id}')
async def remove_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
- role = db.query(Role).filter(Role.id == current_user.role_id).first()
- if role and role.name in ['admin', 'staff', 'accountant']:
+ # PERFORMANCE: Use eager-loaded role relationship if available
+ if hasattr(current_user, 'role') and current_user.role is not None:
+ role_name = current_user.role.name
+ else:
+ # Fallback: query if relationship wasn't loaded
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ role_name = role.name if role else 'customer'
+
+ if role_name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot remove favorites')
try:
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
@@ -76,8 +97,15 @@ async def remove_favorite(room_id: int, current_user: User=Depends(get_current_u
@router.get('/check/{room_id}')
async def check_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
- role = db.query(Role).filter(Role.id == current_user.role_id).first()
- if role and role.name in ['admin', 'staff', 'accountant']:
+ # PERFORMANCE: Use eager-loaded role relationship if available
+ if hasattr(current_user, 'role') and current_user.role is not None:
+ role_name = current_user.role.name
+ else:
+ # Fallback: query if relationship wasn't loaded
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ role_name = role.name if role else 'customer'
+
+ if role_name in ['admin', 'staff', 'accountant']:
return {'status': 'success', 'data': {'isFavorited': False}}
try:
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
diff --git a/Backend/src/reviews/routes/review_routes.py b/Backend/src/reviews/routes/review_routes.py
index 417ba231..359aa48c 100644
--- a/Backend/src/reviews/routes/review_routes.py
+++ b/Backend/src/reviews/routes/review_routes.py
@@ -1,7 +1,7 @@
-from fastapi import APIRouter, Depends, HTTPException, status, Query
+from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from ...shared.utils.response_helpers import success_response, error_response
from sqlalchemy.orm import Session, joinedload
-from sqlalchemy import func
+from sqlalchemy import func, and_
from typing import Optional
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
@@ -9,7 +9,9 @@ from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..models.review import Review, ReviewStatus
from ...rooms.models.room import Room
+from ...bookings.models.booking import Booking, BookingStatus
from ..schemas.review import CreateReviewRequest
+from ...analytics.services.audit_service import audit_service
logger = get_logger(__name__)
router = APIRouter(prefix='/reviews', tags=['reviews'])
@@ -124,7 +126,11 @@ async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status
raise HTTPException(status_code=500, detail=str(e))
@router.post('/')
-async def create_review(review_data: CreateReviewRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+async def create_review(review_data: CreateReviewRequest, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
room_id = review_data.room_id
rating = review_data.rating
@@ -145,6 +151,25 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep
detail=error_response(message='You have already reviewed this room')
)
+ # BUSINESS RULE: Verify user has actually stayed in this room
+ # Users can only review rooms they've booked and checked out from
+ from ...shared.utils.role_helpers import is_admin, is_staff
+ if not (is_admin(current_user, db) or is_staff(current_user, db)):
+ # Check if user has a checked-out booking for this room
+ past_booking = db.query(Booking).filter(
+ and_(
+ Booking.user_id == current_user.id,
+ Booking.room_id == room_id,
+ Booking.status == BookingStatus.checked_out
+ )
+ ).first()
+
+ if not past_booking:
+ raise HTTPException(
+ status_code=403,
+ detail=error_response(message='You can only review rooms you have stayed in. Please complete a booking and check out before leaving a review.')
+ )
+
review = Review(
user_id=current_user.id,
room_id=room_id,
@@ -156,6 +181,28 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep
db.commit()
db.refresh(review)
+ # SECURITY: Log review creation for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='review_created',
+ resource_type='review',
+ user_id=current_user.id,
+ resource_id=review.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'room_id': review.room_id,
+ 'rating': review.rating,
+ 'status': 'pending',
+ 'comment_length': len(review.comment) if review.comment else 0
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log review creation audit: {e}')
+
review_dict = {
'id': review.id,
'user_id': review.user_id,
@@ -181,18 +228,47 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep
)
@router.patch('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))])
-async def approve_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def approve_review(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
- review = db.query(Review).filter(Review.id == id).first()
+ review = db.query(Review).options(joinedload(Review.room)).filter(Review.id == id).first()
if not review:
raise HTTPException(
status_code=404,
detail=error_response(message='Review not found')
)
+ old_status = review.status.value if hasattr(review.status, 'value') else str(review.status)
review.status = ReviewStatus.approved
db.commit()
db.refresh(review)
+ # SECURITY: Log review approval for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='review_approved',
+ resource_type='review',
+ user_id=current_user.id,
+ resource_id=review.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'review_user_id': review.user_id,
+ 'room_id': review.room_id,
+ 'room_number': review.room.room_number if review.room else None,
+ 'rating': review.rating,
+ 'old_status': old_status,
+ 'new_status': 'approved'
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log review approval audit: {e}')
+
review_dict = {
'id': review.id,
'user_id': review.user_id,
@@ -218,18 +294,47 @@ async def approve_review(id: int, current_user: User=Depends(authorize_roles('ad
)
@router.patch('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))])
-async def reject_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def reject_review(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
- review = db.query(Review).filter(Review.id == id).first()
+ review = db.query(Review).options(joinedload(Review.room)).filter(Review.id == id).first()
if not review:
raise HTTPException(
status_code=404,
detail=error_response(message='Review not found')
)
+ old_status = review.status.value if hasattr(review.status, 'value') else str(review.status)
review.status = ReviewStatus.rejected
db.commit()
db.refresh(review)
+ # SECURITY: Log review rejection for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='review_rejected',
+ resource_type='review',
+ user_id=current_user.id,
+ resource_id=review.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'review_user_id': review.user_id,
+ 'room_id': review.room_id,
+ 'room_number': review.room.room_number if review.room else None,
+ 'rating': review.rating,
+ 'old_status': old_status,
+ 'new_status': 'rejected'
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log review rejection audit: {e}')
+
review_dict = {
'id': review.id,
'user_id': review.user_id,
@@ -255,13 +360,47 @@ async def reject_review(id: int, current_user: User=Depends(authorize_roles('adm
)
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
-async def delete_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def delete_review(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
- review = db.query(Review).filter(Review.id == id).first()
+ review = db.query(Review).options(joinedload(Review.room)).filter(Review.id == id).first()
if not review:
raise HTTPException(status_code=404, detail='Review not found')
+
+ # Capture review details before deletion for audit
+ review_details = {
+ 'review_id': review.id,
+ 'review_user_id': review.user_id,
+ 'room_id': review.room_id,
+ 'room_number': review.room.room_number if review.room else None,
+ 'rating': review.rating,
+ 'status': review.status.value if hasattr(review.status, 'value') else str(review.status),
+ 'comment_length': len(review.comment) if review.comment else 0
+ }
+
db.delete(review)
db.commit()
+
+ # SECURITY: Log review deletion for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='review_deleted',
+ resource_type='review',
+ user_id=current_user.id,
+ resource_id=id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=review_details,
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log review deletion audit: {e}')
+
return {'status': 'success', 'message': 'Review deleted successfully'}
except HTTPException:
raise
diff --git a/Backend/src/rooms/routes/__pycache__/advanced_room_routes.cpython-312.pyc b/Backend/src/rooms/routes/__pycache__/advanced_room_routes.cpython-312.pyc
index 3e0eb023..2e2437a9 100644
Binary files a/Backend/src/rooms/routes/__pycache__/advanced_room_routes.cpython-312.pyc and b/Backend/src/rooms/routes/__pycache__/advanced_room_routes.cpython-312.pyc differ
diff --git a/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc b/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc
index 44877587..c235dbd1 100644
Binary files a/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc and b/Backend/src/rooms/routes/__pycache__/room_routes.cpython-312.pyc differ
diff --git a/Backend/src/rooms/routes/advanced_room_routes.py b/Backend/src/rooms/routes/advanced_room_routes.py
index d77973ad..d9ef7a33 100644
--- a/Backend/src/rooms/routes/advanced_room_routes.py
+++ b/Backend/src/rooms/routes/advanced_room_routes.py
@@ -1,8 +1,11 @@
-from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
+from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File
from sqlalchemy.orm import Session, joinedload, load_only
from sqlalchemy import and_, or_, func, desc
from typing import List, Optional
from datetime import datetime, timedelta
+from pathlib import Path
+import uuid
+import hashlib
from ...shared.config.database import get_db
from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
@@ -119,15 +122,23 @@ async def get_maintenance_records(
):
"""Get maintenance records with filtering"""
try:
- # Check if user is staff (not admin) - staff should only see their assigned records
+ # Check if user is staff (not admin) - staff should see their assigned records AND unassigned records
role = db.query(Role).filter(Role.id == current_user.role_id).first()
is_staff = role and role.name == 'staff'
- query = db.query(RoomMaintenance)
+ query = db.query(RoomMaintenance).options(
+ joinedload(RoomMaintenance.room),
+ joinedload(RoomMaintenance.assigned_staff)
+ )
- # Filter by assigned_to for staff users
+ # Filter by assigned_to for staff users - include unassigned records so they can pick them up
if is_staff:
- query = query.filter(RoomMaintenance.assigned_to == current_user.id)
+ query = query.filter(
+ or_(
+ RoomMaintenance.assigned_to == current_user.id,
+ RoomMaintenance.assigned_to.is_(None)
+ )
+ )
if room_id:
query = query.filter(RoomMaintenance.room_id == room_id)
@@ -144,6 +155,13 @@ async def get_maintenance_records(
result = []
for record in records:
+ # Get reported by user info
+ reported_by_name = None
+ if record.reported_by:
+ reported_by_user = db.query(User).filter(User.id == record.reported_by).first()
+ if reported_by_user:
+ reported_by_name = reported_by_user.full_name
+
result.append({
'id': record.id,
'room_id': record.room_id,
@@ -158,11 +176,16 @@ async def get_maintenance_records(
'actual_end': record.actual_end.isoformat() if record.actual_end else None,
'assigned_to': record.assigned_to,
'assigned_staff_name': record.assigned_staff.full_name if record.assigned_staff else None,
+ 'reported_by': record.reported_by,
+ 'reported_by_name': reported_by_name,
'priority': record.priority,
'blocks_room': record.blocks_room,
'estimated_cost': float(record.estimated_cost) if record.estimated_cost else None,
'actual_cost': float(record.actual_cost) if record.actual_cost else None,
- 'created_at': record.created_at.isoformat() if record.created_at else None
+ 'notes': record.notes,
+ 'completion_notes': record.completion_notes if hasattr(record, 'completion_notes') else None,
+ 'created_at': record.created_at.isoformat() if record.created_at else None,
+ 'updated_at': record.updated_at.isoformat() if record.updated_at else None,
})
return {
@@ -184,16 +207,34 @@ async def get_maintenance_records(
@router.post('/maintenance')
async def create_maintenance_record(
maintenance_data: dict,
- current_user: User = Depends(authorize_roles('admin', 'staff')),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Create a new maintenance record"""
try:
+ # Check user role - housekeeping users can only report issues, not create full maintenance records
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
room = db.query(Room).filter(Room.id == maintenance_data.get('room_id')).first()
if not room:
raise HTTPException(status_code=404, detail='Room not found')
- scheduled_start = datetime.fromisoformat(maintenance_data['scheduled_start'].replace('Z', '+00:00'))
+ # For housekeeping users, set defaults for quick issue reporting
+ if is_housekeeping:
+ # Housekeeping users can only create corrective/emergency maintenance
+ maintenance_type = maintenance_data.get('maintenance_type', 'corrective')
+ if maintenance_type not in ['corrective', 'emergency']:
+ maintenance_type = 'corrective'
+ maintenance_data['maintenance_type'] = maintenance_type
+ # Default to high priority for housekeeping-reported issues
+ if 'priority' not in maintenance_data:
+ maintenance_data['priority'] = 'high'
+ # Default to blocking room
+ if 'blocks_room' not in maintenance_data:
+ maintenance_data['blocks_room'] = True
+
+ scheduled_start = datetime.fromisoformat(maintenance_data.get('scheduled_start', datetime.utcnow().isoformat()).replace('Z', '+00:00'))
scheduled_end = None
if maintenance_data.get('scheduled_end'):
scheduled_end = datetime.fromisoformat(maintenance_data['scheduled_end'].replace('Z', '+00:00'))
@@ -357,12 +398,13 @@ async def get_housekeeping_tasks(
is_admin = role and role.name == 'admin'
is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
- query = db.query(HousekeepingTask)
+ # Build base query for filtering
+ base_query = db.query(HousekeepingTask)
# Filter by assigned_to for housekeeping and staff users (not admin)
# But also include unassigned tasks so they can pick them up
if is_housekeeping_or_staff:
- query = query.filter(
+ base_query = base_query.filter(
or_(
HousekeepingTask.assigned_to == current_user.id,
HousekeepingTask.assigned_to.is_(None)
@@ -370,17 +412,31 @@ async def get_housekeeping_tasks(
)
if room_id:
- query = query.filter(HousekeepingTask.room_id == room_id)
+ base_query = base_query.filter(HousekeepingTask.room_id == room_id)
if status:
- query = query.filter(HousekeepingTask.status == HousekeepingStatus(status))
+ base_query = base_query.filter(HousekeepingTask.status == HousekeepingStatus(status))
if task_type:
- query = query.filter(HousekeepingTask.task_type == HousekeepingType(task_type))
+ base_query = base_query.filter(HousekeepingTask.task_type == HousekeepingType(task_type))
if date:
- date_obj = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
- query = query.filter(func.date(HousekeepingTask.scheduled_time) == date_obj)
+ try:
+ # Handle different date formats
+ if 'T' in date:
+ date_obj = datetime.fromisoformat(date.replace('Z', '+00:00')).date()
+ else:
+ date_obj = datetime.strptime(date, '%Y-%m-%d').date()
+ base_query = base_query.filter(func.date(HousekeepingTask.scheduled_time) == date_obj)
+ except (ValueError, AttributeError) as date_error:
+ logger.error(f'Error parsing date {date}: {str(date_error)}')
+ raise HTTPException(status_code=400, detail=f'Invalid date format: {date}')
- total = query.count()
- query = query.order_by(HousekeepingTask.scheduled_time)
+ # Get count before adding joins (to avoid duplicate counting)
+ total = base_query.count()
+
+ # Add eager loading and ordering for the actual data query
+ query = base_query.options(
+ joinedload(HousekeepingTask.room),
+ joinedload(HousekeepingTask.assigned_staff)
+ ).order_by(HousekeepingTask.scheduled_time)
offset = (page - 1) * limit
tasks = query.offset(offset).limit(limit).all()
@@ -390,26 +446,43 @@ async def get_housekeeping_tasks(
# Process existing tasks
for task in tasks:
- task_room_ids.add(task.room_id)
- result.append({
- 'id': task.id,
- 'room_id': task.room_id,
- 'room_number': task.room.room_number if task.room else None,
- 'booking_id': task.booking_id,
- 'task_type': task.task_type.value,
- 'status': task.status.value,
- 'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None,
- 'started_at': task.started_at.isoformat() if task.started_at else None,
- 'completed_at': task.completed_at.isoformat() if task.completed_at else None,
- 'assigned_to': task.assigned_to,
- 'assigned_staff_name': task.assigned_staff.full_name if task.assigned_staff else None,
- 'checklist_items': task.checklist_items,
- 'notes': task.notes,
- 'quality_score': task.quality_score,
- 'estimated_duration_minutes': task.estimated_duration_minutes,
- 'actual_duration_minutes': task.actual_duration_minutes,
- 'room_status': task.room.status.value if task.room else None
- })
+ try:
+ task_room_ids.add(task.room_id)
+
+ # Safely get room status
+ room_status = None
+ if task.room and hasattr(task.room, 'status') and task.room.status:
+ room_status = task.room.status.value if hasattr(task.room.status, 'value') else str(task.room.status)
+
+ # Safely get assigned staff name
+ assigned_staff_name = None
+ if task.assigned_staff and hasattr(task.assigned_staff, 'full_name'):
+ assigned_staff_name = task.assigned_staff.full_name
+
+ result.append({
+ 'id': task.id,
+ 'room_id': task.room_id,
+ 'room_number': task.room.room_number if task.room and hasattr(task.room, 'room_number') else None,
+ 'booking_id': task.booking_id,
+ 'task_type': task.task_type.value if hasattr(task.task_type, 'value') else str(task.task_type),
+ 'status': task.status.value if hasattr(task.status, 'value') else str(task.status),
+ 'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None,
+ 'started_at': task.started_at.isoformat() if task.started_at else None,
+ 'completed_at': task.completed_at.isoformat() if task.completed_at else None,
+ 'assigned_to': task.assigned_to,
+ 'assigned_staff_name': assigned_staff_name,
+ 'checklist_items': task.checklist_items if task.checklist_items else [],
+ 'notes': task.notes,
+ 'quality_score': task.quality_score,
+ 'estimated_duration_minutes': task.estimated_duration_minutes,
+ 'actual_duration_minutes': task.actual_duration_minutes,
+ 'room_status': room_status,
+ 'photos': task.photos if task.photos else []
+ })
+ except Exception as task_error:
+ logger.error(f'Error processing task {task.id if task else "unknown"}: {str(task_error)}', exc_info=True)
+ # Continue with next task instead of failing completely
+ continue
# Include rooms in cleaning status that don't have tasks (or have unassigned tasks for housekeeping users)
if include_cleaning_rooms:
@@ -478,7 +551,8 @@ async def get_housekeeping_tasks(
'estimated_duration_minutes': None,
'actual_duration_minutes': None,
'room_status': room.status.value,
- 'is_room_status_only': True # Flag to indicate this is from room status, not a task
+ 'is_room_status_only': True, # Flag to indicate this is from room status, not a task
+ 'photos': []
})
# Update total count to include cleaning rooms
@@ -498,7 +572,8 @@ async def get_housekeeping_tasks(
}
}
except Exception as e:
- raise HTTPException(status_code=500, detail=str(e))
+ logger.error(f'Error fetching housekeeping tasks: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail='Failed to fetch housekeeping tasks')
@router.post('/housekeeping')
@@ -709,6 +784,8 @@ async def update_housekeeping_task(
task.inspected_at = datetime.utcnow()
if 'inspection_notes' in task_data:
task.inspection_notes = task_data['inspection_notes']
+ if 'photos' in task_data:
+ task.photos = task_data['photos']
db.commit()
db.refresh(task)
@@ -746,6 +823,177 @@ async def update_housekeeping_task(
raise HTTPException(status_code=500, detail=str(e))
+@router.post('/housekeeping/{task_id}/upload-photo')
+async def upload_housekeeping_task_photo(
+ task_id: int,
+ request: Request,
+ image: UploadFile = File(...),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Upload a photo for a housekeeping task"""
+ try:
+ task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first()
+ if not task:
+ raise HTTPException(status_code=404, detail='Housekeeping task not found')
+
+ # Check permissions - housekeeping users can only upload photos to their assigned tasks
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping_or_staff = role and role.name in ('housekeeping', 'staff')
+
+ if is_housekeeping_or_staff:
+ if task.assigned_to != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only upload photos to tasks assigned to you')
+
+ # Validate and process image
+ from ...shared.utils.file_validation import validate_uploaded_image
+ from ...shared.config.settings import settings
+ from PIL import Image
+ import io
+
+ max_size = 5 * 1024 * 1024 # 5MB
+ content = await validate_uploaded_image(image, max_size)
+
+ # Optimize image
+ img = Image.open(io.BytesIO(content))
+ if img.mode in ('RGBA', 'LA', 'P'):
+ img = img.convert('RGB')
+
+ # Generate unique filename
+ file_ext = Path(image.filename).suffix.lower() if image.filename else '.jpg'
+ if file_ext not in ['.jpg', '.jpeg', '.png', '.webp']:
+ file_ext = '.jpg'
+
+ filename = f"housekeeping_task_{task_id}_{uuid.uuid4().hex[:8]}{file_ext}"
+ upload_dir = Path(__file__).parent.parent.parent.parent / 'uploads' / 'housekeeping'
+ upload_dir.mkdir(parents=True, exist_ok=True)
+
+ file_path = upload_dir / filename
+
+ # Save optimized image
+ img.save(file_path, 'JPEG', quality=85, optimize=True)
+
+ image_url = f'/uploads/housekeeping/{filename}'
+
+ # Update task photos
+ if task.photos is None:
+ task.photos = []
+ if not isinstance(task.photos, list):
+ task.photos = []
+
+ task.photos.append(image_url)
+ db.commit()
+
+ # Get full URL
+ base_url = str(request.base_url).rstrip('/')
+ full_url = f"{base_url}{image_url}"
+
+ return {
+ 'status': 'success',
+ 'message': 'Photo uploaded successfully',
+ 'data': {
+ 'photo_url': image_url,
+ 'full_url': full_url,
+ 'photos': task.photos
+ }
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ db.rollback()
+ logger.error(f'Error uploading housekeeping task photo: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail=f'Error uploading photo: {str(e)}')
+
+
+@router.post('/housekeeping/{task_id}/report-maintenance-issue')
+async def report_maintenance_issue_from_task(
+ task_id: int,
+ issue_data: dict,
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
+ db: Session = Depends(get_db)
+):
+ """Quick maintenance issue reporting from housekeeping task"""
+ try:
+ task = db.query(HousekeepingTask).filter(HousekeepingTask.id == task_id).first()
+ if not task:
+ raise HTTPException(status_code=404, detail='Housekeeping task not found')
+
+ # Check permissions - housekeeping users can only report issues for their assigned tasks
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
+ if is_housekeeping:
+ if task.assigned_to != current_user.id:
+ raise HTTPException(status_code=403, detail='You can only report issues for tasks assigned to you')
+
+ room = db.query(Room).filter(Room.id == task.room_id).first()
+ if not room:
+ raise HTTPException(status_code=404, detail='Room not found')
+
+ # Create maintenance record
+ title = issue_data.get('title', f'Issue reported from Room {room.room_number}')
+ description = issue_data.get('description', '')
+ if task.notes:
+ description = f"Reported from housekeeping task.\n\nTask Notes: {task.notes}\n\nIssue Description: {description}".strip()
+ else:
+ description = f"Reported from housekeeping task.\n\nIssue Description: {description}".strip()
+
+ maintenance = RoomMaintenance(
+ room_id=task.room_id,
+ maintenance_type=MaintenanceType(issue_data.get('maintenance_type', 'corrective')),
+ status=MaintenanceStatus('scheduled'),
+ title=title,
+ description=description,
+ scheduled_start=datetime.utcnow(),
+ assigned_to=None, # Will be assigned by admin/staff
+ reported_by=current_user.id,
+ priority=issue_data.get('priority', 'high'),
+ blocks_room=issue_data.get('blocks_room', True),
+ notes=issue_data.get('notes', f'Reported from housekeeping task #{task_id}')
+ )
+
+ # Update room status if blocking
+ if maintenance.blocks_room and room.status == RoomStatus.available:
+ room.status = RoomStatus.maintenance
+
+ db.add(maintenance)
+ db.commit()
+ db.refresh(maintenance)
+
+ # Send notification to admin/staff
+ try:
+ from ...notifications.routes.notification_routes import notification_manager
+ notification_data = {
+ 'type': 'maintenance_request_created',
+ 'data': {
+ 'maintenance_id': maintenance.id,
+ 'room_id': room.id,
+ 'room_number': room.room_number,
+ 'title': maintenance.title,
+ 'priority': maintenance.priority,
+ 'reported_by': current_user.full_name,
+ 'reported_at': maintenance.created_at.isoformat() if maintenance.created_at else None
+ }
+ }
+ # Send to admin and staff roles
+ await notification_manager.send_to_role('admin', notification_data)
+ await notification_manager.send_to_role('staff', notification_data)
+ except Exception as e:
+ logger.error(f'Error sending maintenance notification: {str(e)}', exc_info=True)
+
+ return {
+ 'status': 'success',
+ 'message': 'Maintenance issue reported successfully',
+ 'data': {'maintenance_id': maintenance.id}
+ }
+ except HTTPException:
+ raise
+ except Exception as e:
+ db.rollback()
+ logger.error(f'Error reporting maintenance issue: {str(e)}', exc_info=True)
+ raise HTTPException(status_code=500, detail=f'Error reporting issue: {str(e)}')
+
+
# ==================== Room Inspections ====================
@router.get('/inspections')
@@ -755,19 +1003,19 @@ async def get_room_inspections(
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(20, ge=1, le=100),
- current_user: User = Depends(authorize_roles('admin', 'staff')),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Get room inspections with filtering"""
try:
- # Check if user is staff (not admin) - staff should only see their assigned inspections
+ # Check if user is staff or housekeeping (not admin) - they should only see their assigned inspections
role = db.query(Role).filter(Role.id == current_user.role_id).first()
- is_staff = role and role.name == 'staff'
+ is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping')
query = db.query(RoomInspection)
- # Filter by inspected_by for staff users
- if is_staff:
+ # Filter by inspected_by for staff and housekeeping users
+ if is_staff_or_housekeeping:
query = query.filter(RoomInspection.inspected_by == current_user.id)
if room_id:
@@ -824,16 +1072,27 @@ async def get_room_inspections(
@router.post('/inspections')
async def create_room_inspection(
inspection_data: dict,
- current_user: User = Depends(authorize_roles('admin', 'staff')),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Create a new room inspection"""
try:
+ # Check user role - housekeeping users can only create inspections for themselves
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ is_housekeeping = role and role.name == 'housekeeping'
+
room = db.query(Room).filter(Room.id == inspection_data.get('room_id')).first()
if not room:
raise HTTPException(status_code=404, detail='Room not found')
scheduled_at = datetime.fromisoformat(inspection_data['scheduled_at'].replace('Z', '+00:00'))
+ inspected_by = inspection_data.get('inspected_by')
+
+ # Housekeeping users can only assign inspections to themselves
+ if is_housekeeping:
+ if inspected_by and inspected_by != current_user.id:
+ raise HTTPException(status_code=403, detail='Housekeeping users can only create inspections for themselves')
+ inspected_by = current_user.id
inspection = RoomInspection(
room_id=inspection_data['room_id'],
@@ -841,7 +1100,7 @@ async def create_room_inspection(
inspection_type=InspectionType(inspection_data.get('inspection_type', 'routine')),
status=InspectionStatus(inspection_data.get('status', 'pending')),
scheduled_at=scheduled_at,
- inspected_by=inspection_data.get('inspected_by'),
+ inspected_by=inspected_by,
created_by=current_user.id,
checklist_items=inspection_data.get('checklist_items', []),
checklist_template_id=inspection_data.get('checklist_template_id')
@@ -865,7 +1124,7 @@ async def create_room_inspection(
async def update_room_inspection(
inspection_id: int,
inspection_data: dict,
- current_user: User = Depends(authorize_roles('admin', 'staff')),
+ current_user: User = Depends(authorize_roles('admin', 'staff', 'housekeeping')),
db: Session = Depends(get_db)
):
"""Update a room inspection"""
@@ -874,16 +1133,16 @@ async def update_room_inspection(
if not inspection:
raise HTTPException(status_code=404, detail='Room inspection not found')
- # Check if user is staff (not admin) - staff can only update their own assigned inspections
+ # Check if user is staff or housekeeping (not admin) - they can only update their own assigned inspections
role = db.query(Role).filter(Role.id == current_user.role_id).first()
- is_staff = role and role.name == 'staff'
+ is_staff_or_housekeeping = role and role.name in ('staff', 'housekeeping')
- if is_staff:
- # Staff can only update inspections assigned to them
+ if is_staff_or_housekeeping:
+ # Staff and housekeeping can only update inspections assigned to them
if inspection.inspected_by != current_user.id:
raise HTTPException(status_code=403, detail='You can only update inspections assigned to you')
- # Staff can only update status and inspection results
- allowed_fields = {'status', 'checklist_items', 'overall_score', 'overall_notes', 'issues_found', 'requires_followup', 'followup_notes'}
+ # Staff and housekeeping can only update status and inspection results
+ allowed_fields = {'status', 'checklist_items', 'overall_score', 'overall_notes', 'issues_found', 'requires_followup', 'followup_notes', 'photos'}
if any(key not in allowed_fields for key in inspection_data.keys()):
raise HTTPException(status_code=403, detail='You can only update status and inspection results')
diff --git a/Backend/src/rooms/routes/room_routes.py b/Backend/src/rooms/routes/room_routes.py
index c343c622..0343f983 100644
--- a/Backend/src/rooms/routes/room_routes.py
+++ b/Backend/src/rooms/routes/room_routes.py
@@ -9,6 +9,7 @@ from ...shared.config.logging_config import get_logger
from ...security.middleware.auth import get_current_user, authorize_roles
from ...auth.models.user import User
from ..models.room import Room, RoomStatus
+from ...analytics.services.audit_service import audit_service
from ..models.room_type import RoomType
from ..schemas.room import CreateRoomRequest, UpdateRoomRequest, BulkDeleteRoomsRequest, UpdateAmenityRequest
from ...shared.utils.response_helpers import success_response
@@ -522,13 +523,67 @@ async def update_room(id: int, room_data: UpdateRoomRequest, request: Request, c
raise HTTPException(status_code=500, detail='An error occurred while updating the room')
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
-async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def delete_room(id: int, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
room = db.query(Room).filter(Room.id == id).first()
if not room:
raise HTTPException(status_code=404, detail='Room not found')
+
+ # SECURITY: Check for active bookings before deletion
+ from ...bookings.models.booking import Booking, BookingStatus
+ active_bookings = db.query(Booking).filter(
+ Booking.room_id == id,
+ Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
+ ).count()
+
+ if active_bookings > 0:
+ raise HTTPException(
+ status_code=400,
+ detail=f'Cannot delete room with {active_bookings} active booking(s). Please cancel or complete bookings first.'
+ )
+
+ # Check for historical bookings - prevent deletion to maintain data integrity
+ historical_bookings = db.query(Booking).filter(Booking.room_id == id).count()
+ if historical_bookings > 0:
+ raise HTTPException(
+ status_code=400,
+ detail=f'Cannot delete room with {historical_bookings} historical booking(s). Consider marking the room as inactive or under maintenance instead.'
+ )
+
+ # Capture room info before deletion for audit
+ deleted_room_info = {
+ 'room_id': room.id,
+ 'room_number': room.room_number,
+ 'floor': room.floor,
+ 'room_type_id': room.room_type_id,
+ 'status': room.status.value if isinstance(room.status, RoomStatus) else str(room.status),
+ 'price': float(room.price) if room.price else None,
+ }
+
db.delete(room)
db.commit()
+
+ # SECURITY: Log room deletion for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='room_deleted',
+ resource_type='room',
+ user_id=current_user.id,
+ resource_id=id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details=deleted_room_info,
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log room deletion audit: {e}')
+
return {'status': 'success', 'message': 'Room deleted successfully'}
except HTTPException:
raise
@@ -537,8 +592,11 @@ async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin
raise HTTPException(status_code=500, detail=str(e))
@router.post('/bulk-delete', dependencies=[Depends(authorize_roles('admin'))])
-async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
+async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
"""Bulk delete rooms with validated input using Pydantic schema."""
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
try:
ids = room_ids.room_ids
@@ -550,11 +608,72 @@ async def bulk_delete_rooms(room_ids: BulkDeleteRoomsRequest, current_user: User
db.rollback()
raise HTTPException(status_code=404, detail=f'Rooms with IDs {not_found_ids} not found')
+ # SECURITY: Check for active bookings before deletion
+ from ...bookings.models.booking import Booking, BookingStatus
+ rooms_with_active_bookings = db.query(Booking.room_id).filter(
+ Booking.room_id.in_(ids),
+ Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
+ ).distinct().all()
+
+ if rooms_with_active_bookings:
+ room_ids_with_bookings = [r[0] for r in rooms_with_active_bookings]
+ db.rollback()
+ raise HTTPException(
+ status_code=400,
+ detail=f'Cannot delete rooms with IDs {room_ids_with_bookings} - they have active bookings. Please cancel or complete bookings first.'
+ )
+
+ # Check for historical bookings - prevent deletion to maintain data integrity
+ rooms_with_historical_bookings = db.query(Booking.room_id).filter(
+ Booking.room_id.in_(ids)
+ ).distinct().all()
+
+ if rooms_with_historical_bookings:
+ room_ids_with_bookings = [r[0] for r in rooms_with_historical_bookings]
+ db.rollback()
+ raise HTTPException(
+ status_code=400,
+ detail=f'Cannot delete rooms with IDs {room_ids_with_bookings} - they have historical bookings. Consider marking them as inactive or under maintenance instead.'
+ )
+
+ # Capture room info before deletion for audit
+ deleted_rooms_info = []
+ for room in rooms:
+ deleted_rooms_info.append({
+ 'room_id': room.id,
+ 'room_number': room.room_number,
+ 'floor': room.floor,
+ 'room_type_id': room.room_type_id,
+ 'status': room.status.value if isinstance(room.status, RoomStatus) else str(room.status),
+ })
+
# Delete rooms
deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False)
# Commit transaction
db.commit()
+
+ # SECURITY: Log bulk room deletion for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='rooms_bulk_deleted',
+ resource_type='room',
+ user_id=current_user.id,
+ resource_id=None, # Multiple resources
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'deleted_count': deleted_count,
+ 'deleted_room_ids': ids,
+ 'deleted_rooms': deleted_rooms_info
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log bulk room deletion audit: {e}')
+
return success_response(
data={'deleted_count': deleted_count, 'deleted_ids': ids},
message=f'Successfully deleted {deleted_count} room(s)'
diff --git a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc
index fe7f2ed7..5e9d9ded 100644
Binary files a/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc and b/Backend/src/security/middleware/__pycache__/auth.cpython-312.pyc differ
diff --git a/Backend/src/security/middleware/auth.py b/Backend/src/security/middleware/auth.py
index e585cd9a..b85703c0 100644
--- a/Backend/src/security/middleware/auth.py
+++ b/Backend/src/security/middleware/auth.py
@@ -1,7 +1,7 @@
from fastapi import Depends, HTTPException, status, Request, Cookie
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
-from sqlalchemy.orm import Session
+from sqlalchemy.orm import Session, joinedload
from typing import Optional
from datetime import datetime
import os
@@ -103,7 +103,8 @@ def get_current_user(
except ValueError as e:
# JWT secret configuration error - should not happen in production
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Server configuration error')
- user = db.query(User).filter(User.id == user_id).first()
+ # PERFORMANCE: Eager load role relationship to avoid N+1 queries in authorize_roles
+ user = db.query(User).options(joinedload(User.role)).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
@@ -127,10 +128,17 @@ def get_current_user(
def authorize_roles(*allowed_roles: str):
def role_checker(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)) -> User:
- role = db.query(Role).filter(Role.id == current_user.role_id).first()
- if not role:
- raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
- user_role_name = role.name
+ # PERFORMANCE: Use eager-loaded relationship if available, otherwise query
+ # This reduces database queries since get_current_user now eager loads the role
+ if hasattr(current_user, 'role') and current_user.role is not None:
+ user_role_name = current_user.role.name
+ else:
+ # Fallback: query role if relationship wasn't loaded (shouldn't happen, but safe)
+ role = db.query(Role).filter(Role.id == current_user.role_id).first()
+ if not role:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
+ user_role_name = role.name
+
if user_role_name not in allowed_roles:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='You do not have permission to access this resource')
return current_user
@@ -168,7 +176,8 @@ def get_current_user_optional(
except (JWTError, ValueError):
return None
- user = db.query(User).filter(User.id == user_id).first()
+ # PERFORMANCE: Eager load role relationship for consistency
+ user = db.query(User).options(joinedload(User.role)).filter(User.id == user_id).first()
if user is None:
return None
diff --git a/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc b/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc
index 3196c715..6cc9c814 100644
Binary files a/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc and b/Backend/src/shared/utils/__pycache__/role_helpers.cpython-312.pyc differ
diff --git a/Backend/src/shared/utils/role_helpers.py b/Backend/src/shared/utils/role_helpers.py
index 2cd8c438..d33fc45d 100644
--- a/Backend/src/shared/utils/role_helpers.py
+++ b/Backend/src/shared/utils/role_helpers.py
@@ -10,6 +10,10 @@ def get_user_role_name(user: User, db: Session) -> str:
if not user or not user.role_id:
return 'customer'
try:
+ # PERFORMANCE: Use eager-loaded relationship if available to avoid query
+ if hasattr(user, 'role') and user.role is not None:
+ return user.role.name
+ # Fallback: query if relationship not loaded
role = db.query(Role).filter(Role.id == user.role_id).first()
return role.name if role else 'customer'
except Exception:
@@ -31,6 +35,10 @@ def is_customer(user: User, db: Session) -> bool:
"""Check if user is customer"""
return get_user_role_name(user, db) == 'customer'
+def is_housekeeping(user: User, db: Session) -> bool:
+ """Check if user is housekeeping"""
+ return get_user_role_name(user, db) == 'housekeeping'
+
def can_access_all_payments(user: User, db: Session) -> bool:
"""Check if user can see all payments (admin or accountant)"""
role_name = get_user_role_name(user, db)
diff --git a/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc b/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc
index c09b0ceb..d91735ab 100644
Binary files a/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc and b/Backend/src/system/routes/__pycache__/system_settings_routes.cpython-312.pyc differ
diff --git a/Backend/src/system/routes/system_settings_routes.py b/Backend/src/system/routes/system_settings_routes.py
index bd5114a8..76cb4bcb 100644
--- a/Backend/src/system/routes/system_settings_routes.py
+++ b/Backend/src/system/routes/system_settings_routes.py
@@ -15,6 +15,7 @@ from ...auth.models.user import User
from ..models.system_settings import SystemSettings
from ...shared.utils.mailer import send_email
from ...rooms.services.room_service import get_base_url
+from ...analytics.services.audit_service import audit_service
def normalize_image_url(image_url: str, base_url: str) -> str:
if not image_url:
@@ -63,9 +64,14 @@ async def get_platform_currency(
@router.put("/currency")
async def update_platform_currency(
currency_data: dict,
+ request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
currency = currency_data.get("currency", "").upper()
@@ -81,6 +87,8 @@ async def update_platform_currency(
SystemSettings.key == "platform_currency"
).first()
+ old_value = setting.value if setting else None
+
if setting:
setting.value = currency
setting.updated_by_id = current_user.id
@@ -96,6 +104,27 @@ async def update_platform_currency(
db.commit()
db.refresh(setting)
+ # SECURITY: Log system setting change for audit trail
+ try:
+ await audit_service.log_action(
+ db=db,
+ action='system_setting_changed',
+ resource_type='system_settings',
+ user_id=current_user.id,
+ resource_id=setting.id,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'setting_key': 'platform_currency',
+ 'old_value': old_value,
+ 'new_value': currency
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log system setting change audit: {e}')
+
return {
"status": "success",
"message": "Platform currency updated successfully",
@@ -199,9 +228,13 @@ async def get_stripe_settings(
@router.put("/stripe")
async def update_stripe_settings(
stripe_data: dict,
+ request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
try:
secret_key = stripe_data.get("stripe_secret_key", "").strip()
publishable_key = stripe_data.get("stripe_publishable_key", "").strip()
@@ -229,11 +262,16 @@ async def update_stripe_settings(
)
+ settings_changed = []
+ old_values = {}
+
if secret_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
).first()
+ old_values['stripe_secret_key'] = setting.value if setting else None
+
if setting:
setting.value = secret_key
setting.updated_by_id = current_user.id
@@ -245,6 +283,7 @@ async def update_stripe_settings(
updated_by_id=current_user.id
)
db.add(setting)
+ settings_changed.append('stripe_secret_key')
if publishable_key:
@@ -252,6 +291,8 @@ async def update_stripe_settings(
SystemSettings.key == "stripe_publishable_key"
).first()
+ old_values['stripe_publishable_key'] = setting.value if setting else None
+
if setting:
setting.value = publishable_key
setting.updated_by_id = current_user.id
@@ -263,6 +304,7 @@ async def update_stripe_settings(
updated_by_id=current_user.id
)
db.add(setting)
+ settings_changed.append('stripe_publishable_key')
if webhook_secret:
@@ -270,6 +312,8 @@ async def update_stripe_settings(
SystemSettings.key == "stripe_webhook_secret"
).first()
+ old_values['stripe_webhook_secret'] = setting.value if setting else None
+
if setting:
setting.value = webhook_secret
setting.updated_by_id = current_user.id
@@ -281,9 +325,44 @@ async def update_stripe_settings(
updated_by_id=current_user.id
)
db.add(setting)
+ settings_changed.append('stripe_webhook_secret')
db.commit()
+ # SECURITY: Log payment gateway configuration change for audit trail
+ if settings_changed:
+ try:
+ def mask_key(key_value: str) -> str:
+ if not key_value or len(key_value) < 4:
+ return None
+ return "*" * (len(key_value) - 4) + key_value[-4:]
+
+ masked_old = {k: mask_key(v) if v else None for k, v in old_values.items() if k in settings_changed}
+ masked_new = {k: mask_key(v) if v else None for k, v in {
+ 'stripe_secret_key': secret_key if secret_key else None,
+ 'stripe_publishable_key': publishable_key if publishable_key else None,
+ 'stripe_webhook_secret': webhook_secret if webhook_secret else None
+ }.items() if k in settings_changed}
+
+ await audit_service.log_action(
+ db=db,
+ action='payment_gateway_config_changed',
+ resource_type='system_settings',
+ user_id=current_user.id,
+ resource_id=None,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'gateway': 'stripe',
+ 'settings_changed': settings_changed,
+ 'old_values': masked_old,
+ 'new_values': masked_new
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log payment gateway config change audit: {e}')
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
@@ -367,9 +446,14 @@ async def get_paypal_settings(
@router.put("/paypal")
async def update_paypal_settings(
paypal_data: dict,
+ request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
client_id = paypal_data.get("paypal_client_id", "").strip()
client_secret = paypal_data.get("paypal_client_secret", "").strip()
@@ -382,6 +466,21 @@ async def update_paypal_settings(
detail="Invalid PayPal mode. Must be 'sandbox' or 'live'"
)
+ settings_changed = []
+ old_values = {}
+
+ # Get old values before updating
+ if client_id:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == "paypal_client_id").first()
+ old_values['paypal_client_id'] = old_setting.value if old_setting else None
+
+ if client_secret:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == "paypal_client_secret").first()
+ old_values['paypal_client_secret'] = old_setting.value if old_setting else None
+
+ if mode:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == "paypal_mode").first()
+ old_values['paypal_mode'] = old_setting.value if old_setting else None
if client_id:
setting = db.query(SystemSettings).filter(
@@ -399,6 +498,7 @@ async def update_paypal_settings(
updated_by_id=current_user.id
)
db.add(setting)
+ settings_changed.append('paypal_client_id')
if client_secret:
@@ -417,6 +517,7 @@ async def update_paypal_settings(
updated_by_id=current_user.id
)
db.add(setting)
+ settings_changed.append('paypal_client_secret')
if mode:
@@ -435,9 +536,44 @@ async def update_paypal_settings(
updated_by_id=current_user.id
)
db.add(setting)
+ settings_changed.append('paypal_mode')
db.commit()
+ # SECURITY: Log payment gateway configuration change for audit trail
+ if settings_changed:
+ try:
+ def mask_key(key_value: str) -> str:
+ if not key_value or len(key_value) < 4:
+ return None
+ return "*" * (len(key_value) - 4) + key_value[-4:]
+
+ masked_old = {k: mask_key(v) if v else None for k, v in old_values.items() if k in settings_changed}
+ masked_new = {k: mask_key(v) if v else None for k, v in {
+ 'paypal_client_id': client_id if client_id else None,
+ 'paypal_client_secret': client_secret if client_secret else None,
+ 'paypal_mode': mode if mode else None
+ }.items() if k in settings_changed}
+
+ await audit_service.log_action(
+ db=db,
+ action='payment_gateway_config_changed',
+ resource_type='system_settings',
+ user_id=current_user.id,
+ resource_id=None,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'gateway': 'paypal',
+ 'settings_changed': settings_changed,
+ 'old_values': masked_old,
+ 'new_values': masked_new
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log payment gateway config change audit: {e}')
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
@@ -548,9 +684,14 @@ async def get_borica_settings(
@router.put("/borica")
async def update_borica_settings(
borica_data: dict,
+ request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
terminal_id = borica_data.get("borica_terminal_id", "").strip()
merchant_id = borica_data.get("borica_merchant_id", "").strip()
@@ -565,6 +706,20 @@ async def update_borica_settings(
detail="Invalid Borica mode. Must be 'test' or 'production'"
)
+ settings_changed = []
+ old_values = {}
+
+ # Get old values before updating
+ if terminal_id:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == "borica_terminal_id").first()
+ old_values['borica_terminal_id'] = old_setting.value if old_setting else None
+ if merchant_id:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == "borica_merchant_id").first()
+ old_values['borica_merchant_id'] = old_setting.value if old_setting else None
+ if mode:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == "borica_mode").first()
+ old_values['borica_mode'] = old_setting.value if old_setting else None
+
if terminal_id:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "borica_terminal_id"
@@ -897,9 +1052,14 @@ async def get_smtp_settings(
@router.put("/smtp")
async def update_smtp_settings(
smtp_data: dict,
+ request: Request,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
+ client_ip = request.client.host if request.client else None
+ user_agent = request.headers.get('User-Agent')
+ request_id = getattr(request.state, 'request_id', None)
+
try:
smtp_host = smtp_data.get("smtp_host", "").strip()
smtp_port = smtp_data.get("smtp_port", "").strip()
@@ -938,6 +1098,14 @@ async def update_smtp_settings(
detail="Invalid email address format for 'From Email'"
)
+ settings_changed = []
+ old_values = {}
+
+ # Get old values before updating
+ smtp_keys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_use_tls']
+ for key in smtp_keys:
+ old_setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
+ old_values[key] = old_setting.value if old_setting else None
def update_setting(key: str, value: str, description: str):
setting = db.query(SystemSettings).filter(
@@ -955,6 +1123,9 @@ async def update_smtp_settings(
updated_by_id=current_user.id
)
db.add(setting)
+
+ if key not in settings_changed:
+ settings_changed.append(key)
if smtp_host:
@@ -1009,6 +1180,45 @@ async def update_smtp_settings(
db.commit()
+ # SECURITY: Log SMTP configuration change for audit trail
+ if settings_changed:
+ try:
+ def mask_password(password_value: str) -> str:
+ if not password_value or len(password_value) < 4:
+ return None
+ return "*" * (len(password_value) - 4) + password_value[-4:]
+
+ masked_old = {k: (mask_password(v) if 'password' in k.lower() else v) if v else None
+ for k, v in old_values.items() if k in settings_changed}
+ masked_new = {k: (mask_password(v) if 'password' in k.lower() else v) if v else None
+ for k, v in {
+ 'smtp_host': smtp_host if smtp_host else None,
+ 'smtp_port': smtp_port if smtp_port else None,
+ 'smtp_user': smtp_user if smtp_user else None,
+ 'smtp_password': smtp_password if smtp_password else None,
+ 'smtp_from_email': smtp_from_email if smtp_from_email else None,
+ 'smtp_from_name': smtp_from_name if smtp_from_name else None,
+ 'smtp_use_tls': 'true' if smtp_use_tls else 'false' if smtp_use_tls is not None else None
+ }.items() if k in settings_changed}
+
+ await audit_service.log_action(
+ db=db,
+ action='smtp_config_changed',
+ resource_type='system_settings',
+ user_id=current_user.id,
+ resource_id=None,
+ ip_address=client_ip,
+ user_agent=user_agent,
+ request_id=request_id,
+ details={
+ 'settings_changed': settings_changed,
+ 'old_values': masked_old,
+ 'new_values': masked_new
+ },
+ status='success'
+ )
+ except Exception as e:
+ logger.warning(f'Failed to log SMTP config change audit: {e}')
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx
index 251e9381..46d5b982 100644
--- a/Frontend/src/App.tsx
+++ b/Frontend/src/App.tsx
@@ -63,6 +63,7 @@ const ProfilePage = lazy(() => import('./pages/customer/ProfilePage'));
const LoyaltyPage = lazy(() => import('./pages/customer/LoyaltyPage'));
const GroupBookingPage = lazy(() => import('./pages/customer/GroupBookingPage'));
const ComplaintPage = lazy(() => import('./pages/customer/ComplaintPage'));
+const GuestRequestsPage = lazy(() => import('./pages/customer/GuestRequestsPage'));
const GDPRPage = lazy(() => import('./pages/customer/GDPRPage'));
const GDPRDeletionConfirmPage = lazy(() => import('./pages/customer/GDPRDeletionConfirmPage'));
const AboutPage = lazy(() => import('./features/content/pages/AboutPage'));
@@ -118,6 +119,10 @@ const StaffLoyaltyManagementPage = lazy(() => import('./pages/staff/LoyaltyManag
const StaffGuestProfilePage = lazy(() => import('./pages/staff/GuestProfilePage'));
const StaffAdvancedRoomManagementPage = lazy(() => import('./pages/staff/AdvancedRoomManagementPage'));
const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage'));
+const GuestRequestManagementPage = lazy(() => import('./pages/staff/GuestRequestManagementPage'));
+const GuestCommunicationPage = lazy(() => import('./pages/staff/GuestCommunicationPage'));
+const IncidentComplaintManagementPage = lazy(() => import('./pages/staff/IncidentComplaintManagementPage'));
+const UpsellManagementPage = lazy(() => import('./pages/staff/UpsellManagementPage'));
const StaffLayout = lazy(() => import('./pages/StaffLayout'));
const AccountantDashboardPage = lazy(() => import('./pages/accountant/DashboardPage'));
@@ -452,6 +457,16 @@ function App() {
}
/>
+ {viewingRecord.title} {viewingRecord.description}
+ {viewingRecord.description || 'No description provided'}
+ {viewingRecord.reported_by_name} {viewingRecord.assigned_staff_name} {viewingRecord.assigned_staff_name} {viewingRecord.notes} {viewingRecord.completion_notes} Submit and track your service requests
+ You need to be checked in to submit service requests. Once you're in your room, you can request extra towels, room cleaning, maintenance, and more.
+
+ Type: {getRequestTypeLabel(request.request_type)}
+ {request.room_number && (
+ <>
+ {' • '}
+ Room: {request.room_number}
+ >
+ )}
+ {request.description} Staff Response: {request.staff_notes}
+ {formatRelativeTime(request.requested_at)}
+ Type
Status
Priority
+ Reported By
Scheduled
Assigned
Actions
@@ -317,6 +318,9 @@ const MaintenanceManagement: React.FC = () => {
{record.priority}
+
+ {record.reported_by_name || '-'}
+
{new Date(record.scheduled_start).toLocaleDateString()}
@@ -620,12 +624,12 @@ const MaintenanceManagement: React.FC = () => {
Guest Requests
+ Check-in Required
+ {request.title}
+ {getPriorityBadge(request.priority)}
+ Create New Request
+
+
No photos uploaded yet
+ {task.id && task.status !== 'completed' && ( +Click "Add Photo" to upload
+ )} +Uploading photo...
+Completed
+{performanceMetrics.tasksCompletedToday}
+tasks today
+Avg Time
+{performanceMetrics.averageCompletionTime || 'N/A'}
+minutes
+Per Hour
+{performanceMetrics.tasksPerHour.toFixed(1)}
+tasks/hour
+Variance
+0 ? 'text-orange-600' : 'text-green-600'}`}> + {performanceMetrics.averageTimeVariance > 0 ? '+' : ''}{performanceMetrics.averageTimeVariance.toFixed(0) || '0'}% +
+vs estimated
+Quality
++ {performanceMetrics.averageQualityScore > 0 ? performanceMetrics.averageQualityScore.toFixed(1) : 'N/A'} +
+avg score
+Total
+{performanceMetrics.totalTasksCompleted}
+all time
+You don't have any inspections assigned yet.
++ {inspection.inspection_type.replace('_', ' ')} +
+{selectedInspection.inspection_type.replace('_', ' ')}
++ + {selectedInspection.status.replace('_', ' ')} + +
+{item.category}
+{item.item}
+ {item.notes && ( +{item.notes}
+ )} +{selectedInspection.overall_score.toFixed(1)} / 5.0
++ Assign or change rooms for bookings with visual room selection +
+Searching bookings...
+Loading available rooms...
+No available rooms found for selected dates
++ Send messages, emails, and manage communication templates +
+| Guest | +Type | +Subject | +Direction | +Date | +Actions | +
|---|---|---|---|---|---|
|
+ {comm.guest_name || 'N/A'}
+ |
+
+
+ |
+
+ {comm.subject || 'No subject'}
+ |
+ + + {comm.direction} + + | ++ {formatRelativeTime(comm.created_at)} + | ++ + | +
{template.subject}
} +{template.content}
+{template.subject}
} +{template.content.substring(0, 100)}...
++ Manage and fulfill guest service requests +
+Pending
+{pendingCount}
+In Progress
+{inProgressCount}
+Urgent
+{urgentCount}
+Total
+{totalItems}
+| + Request + | ++ Guest / Room + | ++ Status + | ++ Priority + | ++ Assigned To + | ++ Time + | ++ Actions + | +
|---|---|---|---|---|---|---|
|
+
+
+
+
+
+
+ {request.title}
+ {typeInfo.label}
+ |
+
+ {request.guest_name || 'N/A'}
+
+
+ |
+ + + {request.status.replace('_', ' ')} + + | ++ + {request.priority} + + | ++ {request.assigned_staff_name || ( + Unassigned + )} + | +
+ {formatRelativeTime(request.requested_at)}
+ {request.response_time_minutes && (
+
+ Response: {request.response_time_minutes}m
+
+ )}
+ |
+
+
+
+ {request.status === 'pending' && !request.assigned_to && (
+
+ )}
+ {request.status === 'in_progress' && request.assigned_to === userInfo?.id && (
+
+ )}
+
+ |
+
{selectedRequest.title}
+{selectedRequest.description}
+{selectedRequest.guest_name || 'N/A'}
+Room {selectedRequest.room_number || selectedRequest.room_id}
+{selectedRequest.request_type.replace('_', ' ')}
+{formatDate(selectedRequest.requested_at)}
+{selectedRequest.assigned_staff_name}
+{selectedRequest.guest_notes}
+{selectedRequest.staff_notes}
+{selectedRequest.response_time_minutes} minutes
+{selectedRequest.fulfillment_time_minutes} minutes
++ Track, assign, and resolve guest complaints and incidents +
+Open
+{openCount}
+In Progress
+{inProgressCount}
+Urgent
+{urgentCount}
+Total
+{complaints.length}
+| + Complaint + | ++ Guest / Room + | ++ Status + | ++ Priority + | ++ Assigned To + | ++ Date + | ++ Actions + | +
|---|---|---|---|---|---|---|
|
+
+ {getCategoryIcon(complaint.category)}
+
+
+
+ {complaint.title}
+ {complaint.category.replace('_', ' ')}
+ |
+
+ {complaint.guest_name || 'N/A'}
+ {complaint.room_number && (
+
+
+ )}
+ |
+ + + {complaint.status.replace('_', ' ')} + + | ++ + {complaint.priority} + + | ++ {complaint.assigned_staff_name || ( + Unassigned + )} + | ++ {formatRelativeTime(complaint.created_at)} + | ++ + | +
{selectedComplaint.title}
+{selectedComplaint.description}
+{selectedComplaint.guest_name || 'N/A'}
+Room {selectedComplaint.room_number}
+{selectedComplaint.category.replace('_', ' ')}
+{formatDate(selectedComplaint.created_at)}
+{selectedComplaint.assigned_staff_name}
+{update.description}
+{selectedComplaint.resolution_notes}
++ Quick booking creation for walk-in guests +
+Search for available rooms to see options
++ Offer room upgrades and service upsells to increase revenue +
+| + Booking + | ++ Guest + | ++ Room + | ++ Stay Duration + | ++ Actions + | +
|---|---|---|---|---|
|
+
+ {booking.booking_number}
+
+
+ {formatDate(booking.check_in_date)}
+
+ |
+
+
+ {booking.guest_info?.full_name || booking.user?.full_name || 'N/A'}
+
+ |
+
+
+ Room {booking.room?.room_number || 'N/A'}
+
+
+ {booking.room?.room_type?.name || 'N/A'}
+
+ |
+ + {nights} night{nights !== 1 ? 's' : ''} + | +
+
+
+
+
+ |
+
+ Booking: {selectedBooking.booking_number} | Current Room:{' '} + {selectedBooking.room?.room_number} ({selectedBooking.room?.room_type?.name}) +
+{room.room_type?.name}
++ Floor: {room.floor} | Base Price: {formatCurrency(room.room_type?.base_price || 0)}/night +
++ Guest will be moved to Room {selectedUpgradeRoom.room_number} ( + {selectedUpgradeRoom.room_type?.name}) +
++ Booking: {selectedBooking.booking_number} | Guest:{' '} + {selectedBooking.guest_info?.full_name || selectedBooking.user?.full_name} +
+{service.description}
+ )} + +