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() { } /> + + + + + + } + /> } /> + } + /> + } + /> + } + /> + } + /> } diff --git a/Frontend/src/features/complaints/services/complaintService.ts b/Frontend/src/features/complaints/services/complaintService.ts new file mode 100644 index 00000000..982649f9 --- /dev/null +++ b/Frontend/src/features/complaints/services/complaintService.ts @@ -0,0 +1,80 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface Complaint { + id: number; + guest_id: number; + guest_name?: string; + booking_id?: number; + room_id?: number; + room_number?: string; + category: string; + priority: string; + status: string; + title: string; + description: string; + attachments?: string[]; + assigned_to?: number; + assigned_staff_name?: string; + resolved_at?: string; + resolution_notes?: string; + created_at: string; + updated_at: string; +} + +export interface ComplaintUpdate { + id: number; + complaint_id: number; + update_type: string; + description: string; + updated_by: number; + updated_by_name?: string; + created_at: string; +} + +const complaintService = { + async getComplaints(params?: { + status?: string; + priority?: string; + category?: string; + assigned_to?: number; + page?: number; + limit?: number; + }) { + const response = await apiClient.get('/complaints', { params }); + return response.data; + }, + + async getComplaint(complaintId: number) { + const response = await apiClient.get(`/complaints/${complaintId}`); + return response.data; + }, + + async updateComplaint(complaintId: number, data: { + status?: string; + priority?: string; + assigned_to?: number; + resolution_notes?: string; + }) { + const response = await apiClient.put(`/complaints/${complaintId}`, data); + return response.data; + }, + + async resolveComplaint(complaintId: number, data: { + resolution_notes: string; + compensation_amount?: number; + }) { + const response = await apiClient.post(`/complaints/${complaintId}/resolve`, data); + return response.data; + }, + + async addComplaintUpdate(complaintId: number, data: { + description: string; + update_type?: string; + }) { + const response = await apiClient.post(`/complaints/${complaintId}/updates`, data); + return response.data; + }, +}; + +export default complaintService; + diff --git a/Frontend/src/features/guestRequests/services/guestRequestService.ts b/Frontend/src/features/guestRequests/services/guestRequestService.ts new file mode 100644 index 00000000..4432b3bb --- /dev/null +++ b/Frontend/src/features/guestRequests/services/guestRequestService.ts @@ -0,0 +1,82 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface GuestRequest { + id: number; + booking_id: number; + room_id: number; + room_number?: string; + user_id: number; + guest_name?: string; + guest_email?: string; + request_type: string; + status: string; + priority: string; + title: string; + description?: string; + guest_notes?: string; + staff_notes?: string; + assigned_to?: number; + assigned_staff_name?: string; + fulfilled_by?: number; + fulfilled_staff_name?: string; + requested_at: string; + started_at?: string; + fulfilled_at?: string; + response_time_minutes?: number; + fulfillment_time_minutes?: number; +} + +const guestRequestService = { + async getGuestRequests(params?: { + status?: string; + request_type?: string; + room_id?: number; + assigned_to?: number; + priority?: string; + page?: number; + limit?: number; + }) { + const response = await apiClient.get('/guest-requests', { params }); + return response.data; + }, + + async getGuestRequest(requestId: number) { + const response = await apiClient.get(`/guest-requests/${requestId}`); + return response.data; + }, + + async createGuestRequest(data: { + booking_id: number; + room_id: number; + request_type: string; + title: string; + description?: string; + priority?: string; + guest_notes?: string; + }) { + const response = await apiClient.post('/guest-requests', data); + return response.data; + }, + + async updateGuestRequest(requestId: number, data: { + status?: string; + assigned_to?: number; + staff_notes?: string; + }) { + const response = await apiClient.put(`/guest-requests/${requestId}`, data); + return response.data; + }, + + async assignRequest(requestId: number) { + const response = await apiClient.post(`/guest-requests/${requestId}/assign`); + return response.data; + }, + + async fulfillRequest(requestId: number, staff_notes?: string) { + const response = await apiClient.post(`/guest-requests/${requestId}/fulfill`, { staff_notes }); + return response.data; + }, +}; + +export default guestRequestService; + diff --git a/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx b/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx index 3a7d5da1..d38a0a8d 100644 --- a/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx +++ b/Frontend/src/features/hotel_services/components/HousekeepingManagement.tsx @@ -240,7 +240,7 @@ const HousekeepingManagement: React.FC = () => { const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); try { - if (editingTask) { + if (editingTask && editingTask.id) { // For staff, only allow updating status and checklist items if (!isAdmin) { const data = { diff --git a/Frontend/src/features/hotel_services/components/MaintenanceManagement.tsx b/Frontend/src/features/hotel_services/components/MaintenanceManagement.tsx index 08c38980..21dded60 100644 --- a/Frontend/src/features/hotel_services/components/MaintenanceManagement.tsx +++ b/Frontend/src/features/hotel_services/components/MaintenanceManagement.tsx @@ -290,6 +290,7 @@ const MaintenanceManagement: React.FC = () => { 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 = () => {

{viewingRecord.title}

- {viewingRecord.description && ( -
- -

{viewingRecord.description}

-
- )} +
+ +

+ {viewingRecord.description || 'No description provided'} +

+
@@ -642,6 +646,21 @@ const MaintenanceManagement: React.FC = () => {
+
+ {viewingRecord.reported_by_name && ( +
+ +

{viewingRecord.reported_by_name}

+
+ )} + {viewingRecord.assigned_staff_name && ( +
+ +

{viewingRecord.assigned_staff_name}

+
+ )} +
+
@@ -655,10 +674,17 @@ const MaintenanceManagement: React.FC = () => { )}
- {viewingRecord.assigned_staff_name && ( + {viewingRecord.notes && (
- -

{viewingRecord.assigned_staff_name}

+ +

{viewingRecord.notes}

+
+ )} + + {viewingRecord.completion_notes && ( +
+ +

{viewingRecord.completion_notes}

)} diff --git a/Frontend/src/features/inventory/services/inventoryService.ts b/Frontend/src/features/inventory/services/inventoryService.ts new file mode 100644 index 00000000..800a0004 --- /dev/null +++ b/Frontend/src/features/inventory/services/inventoryService.ts @@ -0,0 +1,140 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface InventoryItem { + id: number; + name: string; + description?: string; + category: string; + unit: string; + current_quantity: number; + minimum_quantity: number; + maximum_quantity?: number; + reorder_quantity?: number; + unit_cost?: number; + supplier?: string; + storage_location?: string; + is_active: boolean; + is_tracked: boolean; + barcode?: string; + sku?: string; + is_low_stock: boolean; + created_at?: string; +} + +export interface InventoryTransaction { + id: number; + transaction_type: string; + quantity: number; + quantity_before: number; + quantity_after: number; + notes?: string; + cost?: number; + transaction_date: string; +} + +export interface ReorderRequest { + id: number; + item_id: number; + item_name: string; + requested_quantity: number; + current_quantity: number; + minimum_quantity: number; + status: string; + priority: string; + notes?: string; + requested_at: string; +} + +const inventoryService = { + async getInventoryItems(params?: { + category?: string; + low_stock?: boolean; + is_active?: boolean; + page?: number; + limit?: number; + }) { + const response = await apiClient.get('/inventory/items', { params }); + return response.data; + }, + + async getInventoryItem(itemId: number) { + const response = await apiClient.get(`/inventory/items/${itemId}`); + return response.data; + }, + + async createInventoryItem(data: { + name: string; + description?: string; + category: string; + unit: string; + minimum_quantity: number; + maximum_quantity?: number; + reorder_quantity?: number; + unit_cost?: number; + supplier?: string; + supplier_contact?: string; + storage_location?: string; + barcode?: string; + sku?: string; + notes?: string; + }) { + const response = await apiClient.post('/inventory/items', data); + return response.data; + }, + + async updateInventoryItem(itemId: number, data: Partial) { + const response = await apiClient.put(`/inventory/items/${itemId}`, data); + return response.data; + }, + + async createTransaction(data: { + item_id: number; + transaction_type: string; + quantity: number; + notes?: string; + cost?: number; + reference_type?: string; + reference_id?: number; + }) { + const response = await apiClient.post('/inventory/transactions', data); + return response.data; + }, + + async getItemTransactions(itemId: number, params?: { page?: number; limit?: number }) { + const response = await apiClient.get(`/inventory/items/${itemId}/transactions`, { params }); + return response.data; + }, + + async createReorderRequest(data: { + item_id: number; + requested_quantity: number; + priority?: string; + notes?: string; + }) { + const response = await apiClient.post('/inventory/reorder-requests', data); + return response.data; + }, + + async getReorderRequests(params?: { status?: string; page?: number; limit?: number }) { + const response = await apiClient.get('/inventory/reorder-requests', { params }); + return response.data; + }, + + async recordTaskConsumption(data: { + task_id: number; + item_id: number; + quantity: number; + notes?: string; + }) { + const response = await apiClient.post('/inventory/task-consumption', data); + return response.data; + }, + + async getLowStockItems() { + const response = await apiClient.get('/inventory/low-stock'); + return response.data; + }, +}; + +export default inventoryService; + diff --git a/Frontend/src/features/rooms/services/advancedRoomService.ts b/Frontend/src/features/rooms/services/advancedRoomService.ts index 1a370b60..9708d7ae 100644 --- a/Frontend/src/features/rooms/services/advancedRoomService.ts +++ b/Frontend/src/features/rooms/services/advancedRoomService.ts @@ -15,6 +15,8 @@ export interface MaintenanceRecord { actual_end?: string; assigned_to?: number; assigned_staff_name?: string; + reported_by?: number; + reported_by_name?: string; priority: 'low' | 'medium' | 'high' | 'urgent'; blocks_room: boolean; block_start?: string; @@ -22,7 +24,9 @@ export interface MaintenanceRecord { estimated_cost?: number; actual_cost?: number; notes?: string; + completion_notes?: string; created_at: string; + updated_at?: string; } export interface HousekeepingTask { @@ -44,6 +48,7 @@ export interface HousekeepingTask { actual_duration_minutes?: number; room_status?: 'available' | 'occupied' | 'maintenance' | 'cleaning'; is_room_status_only?: boolean; // Flag to indicate this is from room status, not a task + photos?: string[]; // Array of photo URLs } export interface ChecklistItem { @@ -185,6 +190,7 @@ const advancedRoomService = { date?: string; page?: number; limit?: number; + include_cleaning_rooms?: boolean; }) { const response = await apiClient.get('/advanced-rooms/housekeeping', { params }); return response.data; @@ -212,11 +218,36 @@ const advancedRoomService = { quality_score?: number; inspected_by?: number; inspection_notes?: string; + photos?: string[]; + assigned_to?: number; }) { const response = await apiClient.put(`/advanced-rooms/housekeeping/${taskId}`, data); return response.data; }, + async uploadHousekeepingTaskPhoto(taskId: number, file: File) { + const formData = new FormData(); + formData.append('image', file); + const response = await apiClient.post(`/advanced-rooms/housekeeping/${taskId}/upload-photo`, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; + }, + + async reportMaintenanceIssue(taskId: number, data: { + title: string; + description: string; + maintenance_type?: 'corrective' | 'emergency'; + priority?: 'low' | 'medium' | 'high' | 'urgent'; + blocks_room?: boolean; + notes?: string; + }) { + const response = await apiClient.post(`/advanced-rooms/housekeeping/${taskId}/report-maintenance-issue`, data); + return response.data; + }, + // Room Inspections async getRoomInspections(params?: { room_id?: number; diff --git a/Frontend/src/features/staffShifts/services/staffShiftService.ts b/Frontend/src/features/staffShifts/services/staffShiftService.ts new file mode 100644 index 00000000..a73fa878 --- /dev/null +++ b/Frontend/src/features/staffShifts/services/staffShiftService.ts @@ -0,0 +1,150 @@ +import apiClient from '../../../shared/services/apiClient'; + +export interface StaffShift { + id: number; + staff_id: number; + staff_name?: string; + shift_date: string; + shift_type: 'morning' | 'afternoon' | 'night' | 'full_day' | 'custom'; + start_time: string; + end_time: string; + status: 'scheduled' | 'in_progress' | 'completed' | 'cancelled' | 'no_show'; + actual_start_time?: string; + actual_end_time?: string; + break_duration_minutes?: number; + department?: string; + notes?: string; + handover_notes?: string; + tasks_completed?: number; + tasks_assigned?: number; + assigned_by_name?: string; +} + +export interface StaffTask { + id: number; + shift_id?: number; + staff_id: number; + staff_name?: string; + title: string; + description?: string; + task_type: string; + priority: 'low' | 'normal' | 'high' | 'urgent'; + status: 'pending' | 'assigned' | 'in_progress' | 'completed' | 'cancelled' | 'on_hold'; + scheduled_start?: string; + scheduled_end?: string; + actual_start?: string; + actual_end?: string; + estimated_duration_minutes?: number; + actual_duration_minutes?: number; + due_date?: string; + related_booking_id?: number; + related_room_id?: number; + related_guest_request_id?: number; + related_maintenance_id?: number; + notes?: string; + completion_notes?: string; + assigned_by_name?: string; +} + +const staffShiftService = { + async getShifts(params?: { + staff_id?: number; + shift_date?: string; + status?: string; + department?: string; + page?: number; + limit?: number; + }) { + const response = await apiClient.get('/staff-shifts', { params }); + return response.data; + }, + + async getShift(shiftId: number) { + const response = await apiClient.get(`/staff-shifts/${shiftId}`); + return response.data; + }, + + async createShift(data: { + staff_id?: number; + shift_date: string; + shift_type: string; + start_time: string; + end_time: string; + status?: string; + break_duration_minutes?: number; + department?: string; + notes?: string; + }) { + const response = await apiClient.post('/staff-shifts', data); + return response.data; + }, + + async updateShift(shiftId: number, data: { + shift_date?: string; + shift_type?: string; + start_time?: string; + end_time?: string; + status?: string; + break_duration_minutes?: number; + department?: string; + notes?: string; + handover_notes?: string; + }) { + const response = await apiClient.put(`/staff-shifts/${shiftId}`, data); + return response.data; + }, + + async getTasks(params?: { + staff_id?: number; + shift_id?: number; + status?: string; + priority?: string; + page?: number; + limit?: number; + }) { + const response = await apiClient.get('/staff-shifts/tasks', { params }); + return response.data; + }, + + async createTask(data: { + shift_id?: number; + staff_id?: number; + title: string; + description?: string; + task_type?: string; + priority?: string; + status?: string; + scheduled_start?: string; + scheduled_end?: string; + estimated_duration_minutes?: number; + due_date?: string; + related_booking_id?: number; + related_room_id?: number; + related_guest_request_id?: number; + related_maintenance_id?: number; + notes?: string; + }) { + const response = await apiClient.post('/staff-shifts/tasks', data); + return response.data; + }, + + async updateTask(taskId: number, data: { + title?: string; + description?: string; + priority?: string; + status?: string; + completion_notes?: string; + notes?: string; + }) { + const response = await apiClient.put(`/staff-shifts/tasks/${taskId}`, data); + return response.data; + }, + + async getWorkloadSummary(date?: string) { + const response = await apiClient.get('/staff-shifts/workload', { params: { date } }); + return response.data; + }, +}; + +export default staffShiftService; + diff --git a/Frontend/src/pages/accountant/DashboardPage.tsx b/Frontend/src/pages/accountant/DashboardPage.tsx index b39b4be4..5b898d16 100644 --- a/Frontend/src/pages/accountant/DashboardPage.tsx +++ b/Frontend/src/pages/accountant/DashboardPage.tsx @@ -83,7 +83,7 @@ const AccountantDashboardPage: React.FC = () => { if (response.success && response.data?.payments) { setRecentPayments(response.data.payments); // Calculate financial summary - const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed' || p.payment_status === 'paid'); + const completedPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'completed'); const pendingPayments = response.data.payments.filter((p: Payment) => p.payment_status === 'pending'); const totalRevenue = completedPayments.reduce((sum: number, p: Payment) => sum + (p.amount || 0), 0); diff --git a/Frontend/src/pages/accountant/InvoiceManagementPage.tsx b/Frontend/src/pages/accountant/InvoiceManagementPage.tsx index 49c159af..ddfaf78f 100644 --- a/Frontend/src/pages/accountant/InvoiceManagementPage.tsx +++ b/Frontend/src/pages/accountant/InvoiceManagementPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { Search, Plus, Edit, Trash2, Eye, FileText, Filter } from 'lucide-react'; import invoiceService, { Invoice } from '../../features/payments/services/invoiceService'; import { toast } from 'react-toastify'; diff --git a/Frontend/src/pages/admin/BannerManagementPage.tsx b/Frontend/src/pages/admin/BannerManagementPage.tsx index 23b47275..384a0d94 100644 --- a/Frontend/src/pages/admin/BannerManagementPage.tsx +++ b/Frontend/src/pages/admin/BannerManagementPage.tsx @@ -680,8 +680,7 @@ const BannerManagementPage: React.FC = () => {
- - + )} diff --git a/Frontend/src/pages/admin/PromotionManagementPage.tsx b/Frontend/src/pages/admin/PromotionManagementPage.tsx index 14b259f6..4b44bf54 100644 --- a/Frontend/src/pages/admin/PromotionManagementPage.tsx +++ b/Frontend/src/pages/admin/PromotionManagementPage.tsx @@ -513,8 +513,7 @@ const PromotionManagementPage: React.FC = () => { - - + )} diff --git a/Frontend/src/pages/customer/GuestRequestsPage.tsx b/Frontend/src/pages/customer/GuestRequestsPage.tsx new file mode 100644 index 00000000..311ff6d5 --- /dev/null +++ b/Frontend/src/pages/customer/GuestRequestsPage.tsx @@ -0,0 +1,457 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Plus, + Clock, + CheckCircle, + XCircle, + AlertCircle, + Bell, + MessageSquare, + Package, + Wrench, + Sparkles, + RefreshCw, + Filter, +} from 'lucide-react'; +import { toast } from 'react-toastify'; +import guestRequestService, { GuestRequest } from '../../features/guestRequests/services/guestRequestService'; +import { getMyBookings, Booking } from '../../features/bookings/services/bookingService'; +import { formatDate, formatRelativeTime } from '../../shared/utils/format'; +import { logger } from '../../shared/utils/logger'; +import Loading from '../../shared/components/Loading'; +import EmptyState from '../../shared/components/EmptyState'; +import useAuthStore from '../../store/useAuthStore'; + +const GuestRequestsPage: React.FC = () => { + const { userInfo } = useAuthStore(); + const navigate = useNavigate(); + const [loading, setLoading] = useState(true); + const [requests, setRequests] = useState([]); + const [bookings, setBookings] = useState([]); + const [showCreateModal, setShowCreateModal] = useState(false); + const [selectedBooking, setSelectedBooking] = useState(null); + const [filterStatus, setFilterStatus] = useState(''); + const [requestForm, setRequestForm] = useState({ + booking_id: '', + room_id: '', + request_type: 'other', + title: '', + description: '', + priority: 'normal', + guest_notes: '', + }); + + const requestTypes = [ + { value: 'extra_towels', label: 'Extra Towels', icon: Package }, + { value: 'extra_pillows', label: 'Extra Pillows', icon: Package }, + { value: 'room_cleaning', label: 'Room Cleaning', icon: Sparkles }, + { value: 'turndown_service', label: 'Turndown Service', icon: Sparkles }, + { value: 'amenities', label: 'Amenities', icon: Package }, + { value: 'maintenance', label: 'Maintenance', icon: Wrench }, + { value: 'room_service', label: 'Room Service', icon: Bell }, + { value: 'other', label: 'Other', icon: MessageSquare }, + ]; + + useEffect(() => { + fetchData(); + }, [filterStatus]); + + const fetchData = async () => { + try { + setLoading(true); + + // Fetch active bookings - only checked-in bookings can create requests + const bookingsResponse = await getMyBookings(); + + if (bookingsResponse.success && bookingsResponse.data?.bookings) { + // Only allow requests for checked-in bookings (guests must be in the room) + const checkedInBookings = bookingsResponse.data.bookings.filter( + (b: Booking) => b.status === 'checked_in' + ); + setBookings(checkedInBookings); + } + + // Fetch guest requests + const params: any = {}; + if (filterStatus) { + params.status = filterStatus; + } + + const requestsResponse = await guestRequestService.getGuestRequests(params); + + if (requestsResponse.status === 'success' && requestsResponse.data?.requests) { + setRequests(requestsResponse.data.requests); + } + } catch (error: any) { + logger.error('Error fetching data', error); + toast.error('Failed to load requests'); + } finally { + setLoading(false); + } + }; + + const handleCreateRequest = async () => { + if (!requestForm.booking_id || !requestForm.room_id || !requestForm.title.trim()) { + toast.error('Please fill in all required fields'); + return; + } + + try { + await guestRequestService.createGuestRequest({ + booking_id: parseInt(requestForm.booking_id), + room_id: parseInt(requestForm.room_id), + request_type: requestForm.request_type, + title: requestForm.title, + description: requestForm.description, + priority: requestForm.priority, + guest_notes: requestForm.guest_notes, + }); + + toast.success('Request submitted successfully! Our staff will attend to it shortly.'); + setShowCreateModal(false); + setRequestForm({ + booking_id: '', + room_id: '', + request_type: 'other', + title: '', + description: '', + priority: 'normal', + guest_notes: '', + }); + await fetchData(); + } catch (error: any) { + logger.error('Error creating request', error); + toast.error(error.response?.data?.detail || 'Failed to submit request'); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'fulfilled': + return ; + case 'in_progress': + return ; + case 'cancelled': + return ; + default: + return ; + } + }; + + const getStatusBadge = (status: string) => { + const styles = { + pending: 'bg-amber-100 text-amber-800 border-amber-200', + in_progress: 'bg-blue-100 text-blue-800 border-blue-200', + fulfilled: 'bg-green-100 text-green-800 border-green-200', + cancelled: 'bg-red-100 text-red-800 border-red-200', + }; + + return ( + + {status.replace('_', ' ').toUpperCase()} + + ); + }; + + const getPriorityBadge = (priority: string) => { + const styles = { + low: 'bg-gray-100 text-gray-800', + normal: 'bg-blue-100 text-blue-800', + high: 'bg-orange-100 text-orange-800', + urgent: 'bg-red-100 text-red-800', + }; + + return ( + + {priority.toUpperCase()} + + ); + }; + + const getRequestTypeLabel = (type: string) => { + return requestTypes.find(t => t.value === type)?.label || type.replace('_', ' '); + }; + + if (loading) { + return ; + } + + return ( +
+
+ {/* Header */} +
+
+
+

Guest Requests

+

Submit and track your service requests

+
+ +
+ + {/* Filters */} +
+
+ + +
+ +
+
+ + {/* Info Banner */} + {bookings.length === 0 && ( +
+
+ +
+

Check-in Required

+

+ 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. +

+
+
+
+ )} + + {/* Requests List */} + {requests.length === 0 ? ( + 0 ? "Create Request" : undefined} + onAction={bookings.length > 0 ? () => setShowCreateModal(true) : undefined} + /> + ) : ( +
+ {requests.map((request) => ( +
+
+
+
+ {getStatusIcon(request.status)} +

{request.title}

+ {getPriorityBadge(request.priority)} +
+

+ Type: {getRequestTypeLabel(request.request_type)} + {request.room_number && ( + <> + {' • '} + Room: {request.room_number} + + )} +

+ {request.description && ( +

{request.description}

+ )} + {request.staff_notes && ( +
+

Staff Response:

+

{request.staff_notes}

+
+ )} +
+
+ {getStatusBadge(request.status)} +

+ {formatRelativeTime(request.requested_at)} +

+
+
+ +
+
+ {request.assigned_staff_name && ( + Assigned to: {request.assigned_staff_name} + )} +
+ {request.fulfilled_at && ( +
+ Fulfilled: {formatDate(request.fulfilled_at)} +
+ )} +
+
+ ))} +
+ )} + + {/* Create Request Modal */} + {showCreateModal && ( +
+
+
+
+

Create New Request

+ +
+
+
+
+ + +
+ +
+ + +
+ +
+ + setRequestForm({ ...requestForm, title: e.target.value })} + placeholder="Brief description of your request" + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#d4af37]" + required + /> +
+ +
+ +