This commit is contained in:
Iliyan Angelov
2025-12-04 01:07:34 +02:00
parent 5fb50983a9
commit 3d634b4fce
92 changed files with 9678 additions and 221 deletions

View 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')

View 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')

View 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')

View 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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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:

View 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)}')

View File

@@ -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(

View File

@@ -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(

View File

@@ -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

View 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])

View File

@@ -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)

View 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')

View File

@@ -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])

View File

@@ -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')

View 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')

View 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')

View File

@@ -0,0 +1,2 @@
from . import inventory_routes, guest_request_routes, staff_shift_routes

View 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')

View 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')

View 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)}')

View File

@@ -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)

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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

View File

@@ -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')

View File

@@ -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)'

View File

@@ -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

View File

@@ -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)

View File

@@ -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: