updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
73
Backend/alembic/versions/add_guest_requests_table.py
Normal file
73
Backend/alembic/versions/add_guest_requests_table.py
Normal file
@@ -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')
|
||||
|
||||
171
Backend/alembic/versions/add_inventory_management_tables.py
Normal file
171
Backend/alembic/versions/add_inventory_management_tables.py
Normal file
@@ -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')
|
||||
|
||||
28
Backend/alembic/versions/add_photos_to_housekeeping_tasks.py
Normal file
28
Backend/alembic/versions/add_photos_to_housekeeping_tasks.py
Normal file
@@ -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')
|
||||
|
||||
97
Backend/alembic/versions/add_staff_shifts_tables.py
Normal file
97
Backend/alembic/versions/add_staff_shifts_tables.py
Normal file
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||
|
||||
93
Backend/src/bookings/routes/upsell_routes.py
Normal file
93
Backend/src/bookings/routes/upsell_routes.py
Normal file
@@ -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)}')
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
70
Backend/src/hotel_services/models/guest_request.py
Normal file
70
Backend/src/hotel_services/models/guest_request.py
Normal file
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
67
Backend/src/hotel_services/models/inventory_item.py
Normal file
67
Backend/src/hotel_services/models/inventory_item.py
Normal file
@@ -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')
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
|
||||
43
Backend/src/hotel_services/models/inventory_transaction.py
Normal file
43
Backend/src/hotel_services/models/inventory_transaction.py
Normal file
@@ -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')
|
||||
|
||||
122
Backend/src/hotel_services/models/staff_shift.py
Normal file
122
Backend/src/hotel_services/models/staff_shift.py
Normal file
@@ -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')
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import inventory_routes, guest_request_routes, staff_shift_routes
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
401
Backend/src/hotel_services/routes/guest_request_routes.py
Normal file
401
Backend/src/hotel_services/routes/guest_request_routes.py
Normal file
@@ -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')
|
||||
|
||||
565
Backend/src/hotel_services/routes/inventory_routes.py
Normal file
565
Backend/src/hotel_services/routes/inventory_routes.py
Normal file
@@ -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')
|
||||
|
||||
464
Backend/src/hotel_services/routes/staff_shift_routes.py
Normal file
464
Backend/src/hotel_services/routes/staff_shift_routes.py
Normal file
@@ -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)}')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
|
||||
|
||||
@@ -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)'
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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)
|
||||
|
||||
Binary file not shown.
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user