updates
This commit is contained in:
@@ -35,6 +35,14 @@ JWT_ALGORITHM=HS256
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
|
||||
# ============================================
|
||||
# Encryption Configuration
|
||||
# ============================================
|
||||
# Base64-encoded encryption key for data encryption at rest
|
||||
# Generate a new key using: python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# If not set, a temporary key will be generated (not recommended for production)
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# ============================================
|
||||
# CORS & Client Configuration
|
||||
# ============================================
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
20
Backend/alembic/versions/add_borica_payment_method.py
Normal file
20
Backend/alembic/versions/add_borica_payment_method.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
revision = 'add_borica_payment_method'
|
||||
down_revision = 'd9aff6c5f0d4'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == 'mysql':
|
||||
op.execute("ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe', 'paypal', 'borica') NOT NULL")
|
||||
else:
|
||||
pass
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
if bind.dialect.name == 'mysql':
|
||||
op.execute("ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe', 'paypal') NOT NULL")
|
||||
|
||||
193
Backend/alembic/versions/add_group_booking_tables.py
Normal file
193
Backend/alembic/versions/add_group_booking_tables.py
Normal file
@@ -0,0 +1,193 @@
|
||||
"""add group booking tables
|
||||
|
||||
Revision ID: add_group_booking_001
|
||||
Revises: add_guest_profile_crm
|
||||
Create Date: 2024-01-15 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_group_booking_001'
|
||||
down_revision = 'add_guest_profile_crm'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Create group_bookings table
|
||||
op.create_table(
|
||||
'group_bookings',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('group_booking_number', sa.String(length=50), nullable=False),
|
||||
sa.Column('coordinator_id', sa.Integer(), nullable=False),
|
||||
sa.Column('coordinator_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('coordinator_email', sa.String(length=100), nullable=False),
|
||||
sa.Column('coordinator_phone', sa.String(length=20), nullable=True),
|
||||
sa.Column('group_name', sa.String(length=200), nullable=True),
|
||||
sa.Column('group_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('total_rooms', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('total_guests', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('check_in_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('check_out_date', sa.DateTime(), nullable=False),
|
||||
sa.Column('base_rate_per_room', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('group_discount_percentage', sa.Numeric(precision=5, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('group_discount_amount', sa.Numeric(precision=10, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('original_total_price', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('discount_amount', sa.Numeric(precision=10, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('total_price', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('payment_option', sa.Enum('coordinator_pays_all', 'individual_payments', 'split_payment', name='paymentoption'), nullable=False, server_default='coordinator_pays_all'),
|
||||
sa.Column('deposit_required', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('deposit_percentage', sa.Integer(), nullable=True),
|
||||
sa.Column('deposit_amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('amount_paid', sa.Numeric(precision=10, scale=2), nullable=False, server_default='0'),
|
||||
sa.Column('balance_due', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('status', sa.Enum('draft', 'pending', 'confirmed', 'partially_confirmed', 'checked_in', 'checked_out', 'cancelled', name='groupbookingstatus'), nullable=False, server_default='draft'),
|
||||
sa.Column('cancellation_policy', sa.Text(), nullable=True),
|
||||
sa.Column('cancellation_deadline', sa.DateTime(), nullable=True),
|
||||
sa.Column('cancellation_penalty_percentage', sa.Numeric(precision=5, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('special_requests', sa.Text(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('contract_terms', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('cancelled_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['coordinator_id'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('group_booking_number')
|
||||
)
|
||||
op.create_index(op.f('ix_group_bookings_id'), 'group_bookings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_group_bookings_group_booking_number'), 'group_bookings', ['group_booking_number'], unique=True)
|
||||
op.create_index(op.f('ix_group_bookings_coordinator_id'), 'group_bookings', ['coordinator_id'], unique=False)
|
||||
op.create_index(op.f('ix_group_bookings_status'), 'group_bookings', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_group_bookings_check_in_date'), 'group_bookings', ['check_in_date'], unique=False)
|
||||
op.create_index(op.f('ix_group_bookings_check_out_date'), 'group_bookings', ['check_out_date'], unique=False)
|
||||
|
||||
# Create group_room_blocks table
|
||||
op.create_table(
|
||||
'group_room_blocks',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('group_booking_id', sa.Integer(), nullable=False),
|
||||
sa.Column('room_type_id', sa.Integer(), nullable=False),
|
||||
sa.Column('rooms_blocked', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('rooms_confirmed', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('rooms_available', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('rate_per_room', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('total_block_price', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('block_released_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_booking_id'], ['group_bookings.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['room_type_id'], ['room_types.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_group_room_blocks_id'), 'group_room_blocks', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_group_room_blocks_group_booking_id'), 'group_room_blocks', ['group_booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_group_room_blocks_room_type_id'), 'group_room_blocks', ['room_type_id'], unique=False)
|
||||
|
||||
# Create group_booking_members table
|
||||
op.create_table(
|
||||
'group_booking_members',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('group_booking_id', sa.Integer(), nullable=False),
|
||||
sa.Column('full_name', sa.String(length=100), nullable=False),
|
||||
sa.Column('email', sa.String(length=100), nullable=True),
|
||||
sa.Column('phone', sa.String(length=20), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('room_block_id', sa.Integer(), nullable=True),
|
||||
sa.Column('assigned_room_id', sa.Integer(), nullable=True),
|
||||
sa.Column('individual_booking_id', sa.Integer(), nullable=True),
|
||||
sa.Column('special_requests', sa.Text(), nullable=True),
|
||||
sa.Column('preferences', sa.JSON(), nullable=True),
|
||||
sa.Column('individual_amount', sa.Numeric(precision=10, scale=2), nullable=True),
|
||||
sa.Column('individual_paid', sa.Numeric(precision=10, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('individual_balance', sa.Numeric(precision=10, scale=2), nullable=True, server_default='0'),
|
||||
sa.Column('is_checked_in', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('checked_in_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_checked_out', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('checked_out_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_booking_id'], ['group_bookings.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['room_block_id'], ['group_room_blocks.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['assigned_room_id'], ['rooms.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['individual_booking_id'], ['bookings.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_group_booking_members_id'), 'group_booking_members', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_group_booking_members_group_booking_id'), 'group_booking_members', ['group_booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_group_booking_members_user_id'), 'group_booking_members', ['user_id'], unique=False)
|
||||
|
||||
# Create group_payments table
|
||||
op.create_table(
|
||||
'group_payments',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('group_booking_id', sa.Integer(), nullable=False),
|
||||
sa.Column('amount', sa.Numeric(precision=10, scale=2), nullable=False),
|
||||
sa.Column('payment_method', sa.String(length=50), nullable=False),
|
||||
sa.Column('payment_type', sa.String(length=50), nullable=False, server_default='deposit'),
|
||||
sa.Column('payment_status', sa.String(length=50), nullable=False, server_default='pending'),
|
||||
sa.Column('transaction_id', sa.String(length=100), nullable=True),
|
||||
sa.Column('payment_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('paid_by_member_id', sa.Integer(), nullable=True),
|
||||
sa.Column('paid_by_user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['group_booking_id'], ['group_bookings.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['paid_by_member_id'], ['group_booking_members.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['paid_by_user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_group_payments_id'), 'group_payments', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_group_payments_group_booking_id'), 'group_payments', ['group_booking_id'], unique=False)
|
||||
op.create_index(op.f('ix_group_payments_payment_status'), 'group_payments', ['payment_status'], unique=False)
|
||||
|
||||
# Add group_booking_id to bookings table
|
||||
op.add_column('bookings', sa.Column('group_booking_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key('fk_bookings_group_booking', 'bookings', 'group_bookings', ['group_booking_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index(op.f('ix_bookings_group_booking_id'), 'bookings', ['group_booking_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop foreign key and column from bookings table
|
||||
op.drop_index(op.f('ix_bookings_group_booking_id'), table_name='bookings')
|
||||
op.drop_constraint('fk_bookings_group_booking', 'bookings', type_='foreignkey')
|
||||
op.drop_column('bookings', 'group_booking_id')
|
||||
|
||||
# Drop group_payments table
|
||||
op.drop_index(op.f('ix_group_payments_payment_status'), table_name='group_payments')
|
||||
op.drop_index(op.f('ix_group_payments_group_booking_id'), table_name='group_payments')
|
||||
op.drop_index(op.f('ix_group_payments_id'), table_name='group_payments')
|
||||
op.drop_table('group_payments')
|
||||
|
||||
# Drop group_booking_members table
|
||||
op.drop_index(op.f('ix_group_booking_members_user_id'), table_name='group_booking_members')
|
||||
op.drop_index(op.f('ix_group_booking_members_group_booking_id'), table_name='group_booking_members')
|
||||
op.drop_index(op.f('ix_group_booking_members_id'), table_name='group_booking_members')
|
||||
op.drop_table('group_booking_members')
|
||||
|
||||
# Drop group_room_blocks table
|
||||
op.drop_index(op.f('ix_group_room_blocks_room_type_id'), table_name='group_room_blocks')
|
||||
op.drop_index(op.f('ix_group_room_blocks_group_booking_id'), table_name='group_room_blocks')
|
||||
op.drop_index(op.f('ix_group_room_blocks_id'), table_name='group_room_blocks')
|
||||
op.drop_table('group_room_blocks')
|
||||
|
||||
# Drop group_bookings table
|
||||
op.drop_index(op.f('ix_group_bookings_check_out_date'), table_name='group_bookings')
|
||||
op.drop_index(op.f('ix_group_bookings_check_in_date'), table_name='group_bookings')
|
||||
op.drop_index(op.f('ix_group_bookings_status'), table_name='group_bookings')
|
||||
op.drop_index(op.f('ix_group_bookings_coordinator_id'), table_name='group_bookings')
|
||||
op.drop_index(op.f('ix_group_bookings_group_booking_number'), table_name='group_bookings')
|
||||
op.drop_index(op.f('ix_group_bookings_id'), table_name='group_bookings')
|
||||
op.drop_table('group_bookings')
|
||||
|
||||
# Drop enums
|
||||
op.execute("DROP TYPE IF EXISTS paymentoption")
|
||||
op.execute("DROP TYPE IF EXISTS groupbookingstatus")
|
||||
|
||||
30
Backend/alembic/versions/add_rate_plan_id_to_bookings.py
Normal file
30
Backend/alembic/versions/add_rate_plan_id_to_bookings.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""add rate_plan_id to bookings
|
||||
|
||||
Revision ID: add_rate_plan_id_001
|
||||
Revises: add_group_booking_001
|
||||
Create Date: 2024-01-20 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'add_rate_plan_id_001'
|
||||
down_revision = ('add_group_booking_001', 'add_loyalty_tables_001')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add rate_plan_id column to bookings table
|
||||
op.add_column('bookings', sa.Column('rate_plan_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key('fk_bookings_rate_plan', 'bookings', 'rate_plans', ['rate_plan_id'], ['id'], ondelete='SET NULL')
|
||||
op.create_index(op.f('ix_bookings_rate_plan_id'), 'bookings', ['rate_plan_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop foreign key, index, and column from bookings table
|
||||
op.drop_index(op.f('ix_bookings_rate_plan_id'), table_name='bookings')
|
||||
op.drop_constraint('fk_bookings_rate_plan', 'bookings', type_='foreignkey')
|
||||
op.drop_column('bookings', 'rate_plan_id')
|
||||
|
||||
18
Backend/pytest.ini
Normal file
18
Backend/pytest.ini
Normal file
@@ -0,0 +1,18 @@
|
||||
[pytest]
|
||||
# Pytest configuration file
|
||||
testpaths = src/tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--disable-warnings
|
||||
--color=yes
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
unit: marks tests as unit tests
|
||||
asyncio_mode = auto
|
||||
|
||||
@@ -21,6 +21,13 @@ paypal-checkout-serversdk>=1.0.3
|
||||
pyotp==2.9.0
|
||||
qrcode[pil]==7.4.2
|
||||
httpx==0.25.2
|
||||
cryptography>=41.0.7
|
||||
|
||||
# Testing dependencies
|
||||
pytest==7.4.3
|
||||
pytest-asyncio==0.21.1
|
||||
pytest-cov==4.1.0
|
||||
pytest-mock==3.12.0
|
||||
|
||||
# Enterprise features (optional but recommended)
|
||||
# redis==5.0.1 # Uncomment if using Redis caching
|
||||
|
||||
@@ -10,4 +10,4 @@ if __name__ == '__main__':
|
||||
base_dir = Path(__file__).parent
|
||||
src_dir = str(base_dir / 'src')
|
||||
use_reload = False
|
||||
uvicorn.run('src.main:app', host=settings.HOST, port=8000, reload=use_reload, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if use_reload else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=1.0)
|
||||
uvicorn.run('src.main:app', host=settings.HOST, port=settings.PORT, reload=use_reload, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if use_reload else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=1.0)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
||||
JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm')
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes')
|
||||
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description='JWT refresh token expiration in days')
|
||||
ENCRYPTION_KEY: str = Field(default='', description='Base64-encoded encryption key for data encryption at rest')
|
||||
CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL')
|
||||
CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins')
|
||||
RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting')
|
||||
@@ -51,6 +52,12 @@ class Settings(BaseSettings):
|
||||
PAYPAL_CLIENT_ID: str = Field(default='', description='PayPal client ID')
|
||||
PAYPAL_CLIENT_SECRET: str = Field(default='', description='PayPal client secret')
|
||||
PAYPAL_MODE: str = Field(default='sandbox', description='PayPal mode: sandbox or live')
|
||||
BORICA_TERMINAL_ID: str = Field(default='', description='Borica Terminal ID')
|
||||
BORICA_MERCHANT_ID: str = Field(default='', description='Borica Merchant ID')
|
||||
BORICA_PRIVATE_KEY_PATH: str = Field(default='', description='Borica private key file path')
|
||||
BORICA_CERTIFICATE_PATH: str = Field(default='', description='Borica certificate file path')
|
||||
BORICA_GATEWAY_URL: str = Field(default='https://3dsgate-dev.borica.bg/cgi-bin/cgi_link', description='Borica gateway URL (test or production)')
|
||||
BORICA_MODE: str = Field(default='test', description='Borica mode: test or production')
|
||||
|
||||
@property
|
||||
def database_url(self) -> str:
|
||||
|
||||
@@ -95,9 +95,10 @@ async def metrics():
|
||||
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
|
||||
app.include_router(auth_routes.router, prefix='/api')
|
||||
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes
|
||||
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes, analytics_routes, workflow_routes, task_routes, notification_routes, group_booking_routes, advanced_room_routes, rate_plan_routes, package_routes, security_routes, email_campaign_routes
|
||||
app.include_router(room_routes.router, prefix='/api')
|
||||
app.include_router(booking_routes.router, prefix='/api')
|
||||
app.include_router(group_booking_routes.router, prefix='/api')
|
||||
app.include_router(payment_routes.router, prefix='/api')
|
||||
app.include_router(invoice_routes.router, prefix='/api')
|
||||
app.include_router(banner_routes.router, prefix='/api')
|
||||
@@ -125,6 +126,15 @@ app.include_router(faq_routes.router, prefix='/api')
|
||||
app.include_router(chat_routes.router, prefix='/api')
|
||||
app.include_router(loyalty_routes.router, prefix='/api')
|
||||
app.include_router(guest_profile_routes.router, prefix='/api')
|
||||
app.include_router(analytics_routes.router, prefix='/api')
|
||||
app.include_router(workflow_routes.router, prefix='/api')
|
||||
app.include_router(task_routes.router, prefix='/api')
|
||||
app.include_router(notification_routes.router, prefix='/api')
|
||||
app.include_router(advanced_room_routes.router, prefix='/api')
|
||||
app.include_router(rate_plan_routes.router, prefix='/api')
|
||||
app.include_router(package_routes.router, prefix='/api')
|
||||
app.include_router(security_routes.router, prefix='/api')
|
||||
app.include_router(email_campaign_routes.router, prefix='/api')
|
||||
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
@@ -154,6 +164,13 @@ app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(analytics_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(workflow_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(task_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(notification_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(advanced_room_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(rate_plan_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(package_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
app.include_router(page_content_routes.router, prefix='/api')
|
||||
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
|
||||
logger.info('All routes registered successfully')
|
||||
|
||||
195
Backend/src/middleware/ip_whitelist.py
Normal file
195
Backend/src/middleware/ip_whitelist.py
Normal file
@@ -0,0 +1,195 @@
|
||||
from fastapi import Request, HTTPException, status
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
from ..config.database import get_db
|
||||
from ..config.logging_config import get_logger
|
||||
from ..config.settings import settings
|
||||
from ..models.security_event import IPWhitelist, IPBlacklist, SecurityEvent, SecurityEventType, SecurityEventSeverity
|
||||
from datetime import datetime
|
||||
import ipaddress
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class IPWhitelistMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware to enforce IP whitelisting and blacklisting"""
|
||||
|
||||
def __init__(self, app, enabled: bool = True, whitelist_only: bool = False):
|
||||
super().__init__(app)
|
||||
self.enabled = enabled
|
||||
self.whitelist_only = whitelist_only # If True, only whitelisted IPs allowed
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not self.enabled:
|
||||
return await call_next(request)
|
||||
|
||||
# Skip IP check for health checks and public endpoints
|
||||
if request.url.path in ['/health', '/api/health', '/metrics']:
|
||||
return await call_next(request)
|
||||
|
||||
client_ip = self._get_client_ip(request)
|
||||
|
||||
if not client_ip:
|
||||
logger.warning("Could not determine client IP address")
|
||||
return await call_next(request)
|
||||
|
||||
# Check blacklist first
|
||||
if await self._is_blacklisted(client_ip):
|
||||
await self._log_security_event(
|
||||
request,
|
||||
SecurityEventType.ip_blocked,
|
||||
SecurityEventSeverity.high,
|
||||
f"Blocked request from blacklisted IP: {client_ip}"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"status": "error", "message": "Access denied"}
|
||||
)
|
||||
|
||||
# Check whitelist if whitelist_only mode is enabled
|
||||
if self.whitelist_only:
|
||||
if not await self._is_whitelisted(client_ip):
|
||||
await self._log_security_event(
|
||||
request,
|
||||
SecurityEventType.permission_denied,
|
||||
SecurityEventSeverity.medium,
|
||||
f"Blocked request from non-whitelisted IP: {client_ip}"
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
content={"status": "error", "message": "Access denied. IP not whitelisted."}
|
||||
)
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
def _get_client_ip(self, request: Request) -> str:
|
||||
"""Extract client IP address from request"""
|
||||
# Check for forwarded IP (when behind proxy/load balancer)
|
||||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||||
if forwarded_for:
|
||||
# X-Forwarded-For can contain multiple IPs, take the first one
|
||||
return forwarded_for.split(",")[0].strip()
|
||||
|
||||
real_ip = request.headers.get("X-Real-IP")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
|
||||
# Fallback to direct client IP
|
||||
if request.client:
|
||||
return request.client.host
|
||||
|
||||
return None
|
||||
|
||||
async def _is_blacklisted(self, ip_address: str) -> bool:
|
||||
"""Check if IP address is blacklisted"""
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
# Check exact match
|
||||
blacklist_entry = db.query(IPBlacklist).filter(
|
||||
IPBlacklist.ip_address == ip_address,
|
||||
IPBlacklist.is_active == True
|
||||
).first()
|
||||
|
||||
if blacklist_entry:
|
||||
# Check if temporary block has expired
|
||||
if blacklist_entry.blocked_until and blacklist_entry.blocked_until < datetime.utcnow():
|
||||
# Block expired, deactivate it
|
||||
blacklist_entry.is_active = False
|
||||
db.commit()
|
||||
return False
|
||||
return True
|
||||
|
||||
# Check CIDR ranges (if needed)
|
||||
# This is a simplified version - you might want to cache this
|
||||
all_blacklist = db.query(IPBlacklist).filter(
|
||||
IPBlacklist.is_active == True
|
||||
).all()
|
||||
|
||||
for entry in all_blacklist:
|
||||
try:
|
||||
if '/' in entry.ip_address: # CIDR notation
|
||||
network = ipaddress.ip_network(entry.ip_address, strict=False)
|
||||
if ipaddress.ip_address(ip_address) in network:
|
||||
return True
|
||||
except (ValueError, ipaddress.AddressValueError):
|
||||
continue
|
||||
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking IP blacklist: {str(e)}")
|
||||
return False
|
||||
|
||||
async def _is_whitelisted(self, ip_address: str) -> bool:
|
||||
"""Check if IP address is whitelisted"""
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
# Check exact match
|
||||
whitelist_entry = db.query(IPWhitelist).filter(
|
||||
IPWhitelist.ip_address == ip_address,
|
||||
IPWhitelist.is_active == True
|
||||
).first()
|
||||
|
||||
if whitelist_entry:
|
||||
return True
|
||||
|
||||
# Check CIDR ranges
|
||||
all_whitelist = db.query(IPWhitelist).filter(
|
||||
IPWhitelist.is_active == True
|
||||
).all()
|
||||
|
||||
for entry in all_whitelist:
|
||||
try:
|
||||
if '/' in entry.ip_address: # CIDR notation
|
||||
network = ipaddress.ip_network(entry.ip_address, strict=False)
|
||||
if ipaddress.ip_address(ip_address) in network:
|
||||
return True
|
||||
except (ValueError, ipaddress.AddressValueError):
|
||||
continue
|
||||
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking IP whitelist: {str(e)}")
|
||||
return False
|
||||
|
||||
async def _log_security_event(
|
||||
self,
|
||||
request: Request,
|
||||
event_type: SecurityEventType,
|
||||
severity: SecurityEventSeverity,
|
||||
description: str
|
||||
):
|
||||
"""Log security event"""
|
||||
try:
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
client_ip = self._get_client_ip(request)
|
||||
event = SecurityEvent(
|
||||
event_type=event_type,
|
||||
severity=severity,
|
||||
ip_address=client_ip,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
request_path=str(request.url.path),
|
||||
request_method=request.method,
|
||||
description=description,
|
||||
details={
|
||||
"url": str(request.url),
|
||||
"headers": dict(request.headers)
|
||||
}
|
||||
)
|
||||
db.add(event)
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging security event: {str(e)}")
|
||||
|
||||
@@ -32,4 +32,16 @@ from .guest_note import GuestNote
|
||||
from .guest_tag import GuestTag, guest_tag_association
|
||||
from .guest_communication import GuestCommunication, CommunicationType, CommunicationDirection
|
||||
from .guest_segment import GuestSegment, guest_segment_association
|
||||
__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus', 'LoyaltyTier', 'TierLevel', 'UserLoyalty', 'LoyaltyPointTransaction', 'TransactionType', 'TransactionSource', 'LoyaltyReward', 'RewardType', 'RewardStatus', 'RewardRedemption', 'RedemptionStatus', 'Referral', 'ReferralStatus', 'GuestPreference', 'GuestNote', 'GuestTag', 'guest_tag_association', 'GuestCommunication', 'CommunicationType', 'CommunicationDirection', 'GuestSegment', 'guest_segment_association']
|
||||
from .workflow import Workflow, WorkflowInstance, Task, TaskComment, WorkflowType, WorkflowStatus, WorkflowTrigger, TaskStatus, TaskPriority
|
||||
from .notification import Notification, NotificationTemplate, NotificationPreference, NotificationDeliveryLog, NotificationChannel, NotificationStatus, NotificationType
|
||||
from .group_booking import GroupBooking, GroupBookingMember, GroupRoomBlock, GroupPayment, GroupBookingStatus, PaymentOption
|
||||
from .room_maintenance import RoomMaintenance, MaintenanceType, MaintenanceStatus
|
||||
from .housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
|
||||
from .room_inspection import RoomInspection, InspectionType, InspectionStatus
|
||||
from .room_attribute import RoomAttribute
|
||||
from .rate_plan import RatePlan, RatePlanRule, RatePlanType, RatePlanStatus
|
||||
from .package import Package, PackageItem, PackageStatus, PackageItemType
|
||||
from .security_event import SecurityEvent, SecurityEventType, SecurityEventSeverity, IPWhitelist, IPBlacklist, OAuthProvider, OAuthToken
|
||||
from .gdpr_compliance import DataSubjectRequest, DataSubjectRequestType, DataSubjectRequestStatus, DataRetentionPolicy, ConsentRecord
|
||||
from .email_campaign import Campaign, CampaignStatus, CampaignType, CampaignSegment, EmailTemplate, CampaignEmail, EmailStatus, EmailClick, DripSequence, DripSequenceStep, DripSequenceEnrollment, Unsubscribe
|
||||
__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus', 'LoyaltyTier', 'TierLevel', 'UserLoyalty', 'LoyaltyPointTransaction', 'TransactionType', 'TransactionSource', 'LoyaltyReward', 'RewardType', 'RewardStatus', 'RewardRedemption', 'RedemptionStatus', 'Referral', 'ReferralStatus', 'GuestPreference', 'GuestNote', 'GuestTag', 'guest_tag_association', 'GuestCommunication', 'CommunicationType', 'CommunicationDirection', 'GuestSegment', 'guest_segment_association', 'Workflow', 'WorkflowInstance', 'Task', 'TaskComment', 'WorkflowType', 'WorkflowStatus', 'WorkflowTrigger', 'TaskStatus', 'TaskPriority', 'Notification', 'NotificationTemplate', 'NotificationPreference', 'NotificationDeliveryLog', 'NotificationChannel', 'NotificationStatus', 'NotificationType', 'GroupBooking', 'GroupBookingMember', 'GroupRoomBlock', 'GroupPayment', 'GroupBookingStatus', 'PaymentOption', 'RoomMaintenance', 'MaintenanceType', 'MaintenanceStatus', 'HousekeepingTask', 'HousekeepingStatus', 'HousekeepingType', 'RoomInspection', 'InspectionType', 'InspectionStatus', 'RoomAttribute', 'RatePlan', 'RatePlanRule', 'RatePlanType', 'RatePlanStatus', 'Package', 'PackageItem', 'PackageStatus', 'PackageItemType', 'SecurityEvent', 'SecurityEventType', 'SecurityEventSeverity', 'IPWhitelist', 'IPBlacklist', 'OAuthProvider', 'OAuthToken', 'DataSubjectRequest', 'DataSubjectRequestType', 'DataSubjectRequestStatus', 'DataRetentionPolicy', 'ConsentRecord', 'Campaign', 'CampaignStatus', 'CampaignType', 'CampaignSegment', 'EmailTemplate', 'CampaignEmail', 'EmailStatus', 'EmailClick', 'DripSequence', 'DripSequenceStep', 'DripSequenceEnrollment', 'Unsubscribe']
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/email_campaign.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/email_campaign.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/gdpr_compliance.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/gdpr_compliance.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/group_booking.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/group_booking.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/housekeeping_task.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/housekeeping_task.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/notification.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/notification.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/package.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/package.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/rate_plan.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/rate_plan.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/room_attribute.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/room_attribute.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/room_inspection.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/room_inspection.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/room_maintenance.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/room_maintenance.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/models/__pycache__/security_event.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/security_event.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/models/__pycache__/workflow.cpython-312.pyc
Normal file
BIN
Backend/src/models/__pycache__/workflow.cpython-312.pyc
Normal file
Binary file not shown.
@@ -36,3 +36,7 @@ class Booking(Base):
|
||||
invoices = relationship('Invoice', back_populates='booking', cascade='all, delete-orphan')
|
||||
service_usages = relationship('ServiceUsage', back_populates='booking', cascade='all, delete-orphan')
|
||||
checkin_checkout = relationship('CheckInCheckOut', back_populates='booking', uselist=False)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=True)
|
||||
group_booking = relationship('GroupBooking', back_populates='individual_bookings')
|
||||
rate_plan_id = Column(Integer, ForeignKey('rate_plans.id'), nullable=True)
|
||||
rate_plan = relationship('RatePlan', back_populates='bookings')
|
||||
285
Backend/src/models/email_campaign.py
Normal file
285
Backend/src/models/email_campaign.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Enum, Boolean, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class CampaignStatus(str, enum.Enum):
|
||||
draft = 'draft'
|
||||
scheduled = 'scheduled'
|
||||
sending = 'sending'
|
||||
sent = 'sent'
|
||||
paused = 'paused'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class CampaignType(str, enum.Enum):
|
||||
newsletter = 'newsletter'
|
||||
promotional = 'promotional'
|
||||
transactional = 'transactional'
|
||||
abandoned_booking = 'abandoned_booking'
|
||||
welcome = 'welcome'
|
||||
drip = 'drip'
|
||||
custom = 'custom'
|
||||
|
||||
class EmailStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
sent = 'sent'
|
||||
delivered = 'delivered'
|
||||
opened = 'opened'
|
||||
clicked = 'clicked'
|
||||
bounced = 'bounced'
|
||||
failed = 'failed'
|
||||
unsubscribed = 'unsubscribed'
|
||||
|
||||
class Campaign(Base):
|
||||
__tablename__ = 'email_campaigns'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
subject = Column(String(500), nullable=False)
|
||||
campaign_type = Column(Enum(CampaignType), nullable=False, default=CampaignType.newsletter)
|
||||
status = Column(Enum(CampaignStatus), nullable=False, default=CampaignStatus.draft, index=True)
|
||||
|
||||
# Content
|
||||
html_content = Column(Text, nullable=True)
|
||||
text_content = Column(Text, nullable=True)
|
||||
template_id = Column(Integer, ForeignKey('email_templates.id'), nullable=True)
|
||||
|
||||
# Scheduling
|
||||
scheduled_at = Column(DateTime, nullable=True, index=True)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Segmentation
|
||||
segment_id = Column(Integer, ForeignKey('campaign_segments.id'), nullable=True)
|
||||
segment_criteria = Column(JSON, nullable=True) # Store segment criteria as JSON
|
||||
|
||||
# A/B Testing
|
||||
is_ab_test = Column(Boolean, nullable=False, default=False)
|
||||
ab_test_variant_a_id = Column(Integer, ForeignKey('email_campaigns.id'), nullable=True)
|
||||
ab_test_variant_b_id = Column(Integer, ForeignKey('email_campaigns.id'), nullable=True)
|
||||
ab_test_split_percentage = Column(Integer, nullable=True, default=50) # Percentage for variant A
|
||||
ab_test_winner = Column(String(1), nullable=True) # 'A' or 'B'
|
||||
|
||||
# Drip Campaign
|
||||
is_drip = Column(Boolean, nullable=False, default=False)
|
||||
drip_sequence_id = Column(Integer, ForeignKey('drip_sequences.id'), nullable=True)
|
||||
drip_delay_days = Column(Integer, nullable=True) # Days to wait before sending
|
||||
|
||||
# Analytics
|
||||
total_recipients = Column(Integer, nullable=False, default=0)
|
||||
total_sent = Column(Integer, nullable=False, default=0)
|
||||
total_delivered = Column(Integer, nullable=False, default=0)
|
||||
total_opened = Column(Integer, nullable=False, default=0)
|
||||
total_clicked = Column(Integer, nullable=False, default=0)
|
||||
total_bounced = Column(Integer, nullable=False, default=0)
|
||||
total_unsubscribed = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Metrics (calculated)
|
||||
open_rate = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
click_rate = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
bounce_rate = Column(Numeric(5, 2), nullable=True) # Percentage
|
||||
|
||||
# Settings
|
||||
from_name = Column(String(200), nullable=True)
|
||||
from_email = Column(String(255), nullable=True)
|
||||
reply_to_email = Column(String(255), nullable=True)
|
||||
track_opens = Column(Boolean, nullable=False, default=True)
|
||||
track_clicks = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Metadata
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
template = relationship('EmailTemplate', foreign_keys=[template_id])
|
||||
segment = relationship('CampaignSegment', foreign_keys=[segment_id])
|
||||
variant_a = relationship('Campaign', foreign_keys=[ab_test_variant_a_id], remote_side=[id])
|
||||
variant_b = relationship('Campaign', foreign_keys=[ab_test_variant_b_id], remote_side=[id])
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
emails = relationship('CampaignEmail', back_populates='campaign', cascade='all, delete-orphan')
|
||||
drip_sequence = relationship('DripSequence', foreign_keys=[drip_sequence_id])
|
||||
|
||||
class CampaignSegment(Base):
|
||||
__tablename__ = 'campaign_segments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Segment criteria (stored as JSON for flexibility)
|
||||
criteria = Column(JSON, nullable=False) # e.g., {"role": "customer", "last_booking_days": 30}
|
||||
|
||||
# Estimated count
|
||||
estimated_count = Column(Integer, nullable=True)
|
||||
last_calculated_at = Column(DateTime, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
class EmailTemplate(Base):
|
||||
__tablename__ = 'email_templates'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
subject = Column(String(500), nullable=False)
|
||||
html_content = Column(Text, nullable=False)
|
||||
text_content = Column(Text, nullable=True)
|
||||
|
||||
# Template variables (e.g., {{name}}, {{booking_number}})
|
||||
variables = Column(JSON, nullable=True) # List of available variables
|
||||
|
||||
# Category
|
||||
category = Column(String(100), nullable=True, index=True) # 'newsletter', 'transactional', etc.
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
is_system = Column(Boolean, nullable=False, default=False) # System templates can't be deleted
|
||||
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
class CampaignEmail(Base):
|
||||
__tablename__ = 'campaign_emails'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
campaign_id = Column(Integer, ForeignKey('email_campaigns.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
email = Column(String(255), nullable=False, index=True)
|
||||
|
||||
# Status tracking
|
||||
status = Column(Enum(EmailStatus), nullable=False, default=EmailStatus.pending, index=True)
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
opened_at = Column(DateTime, nullable=True)
|
||||
clicked_at = Column(DateTime, nullable=True)
|
||||
bounced_at = Column(DateTime, nullable=True)
|
||||
unsubscribed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Tracking
|
||||
open_count = Column(Integer, nullable=False, default=0)
|
||||
click_count = Column(Integer, nullable=False, default=0)
|
||||
last_opened_at = Column(DateTime, nullable=True)
|
||||
last_clicked_at = Column(DateTime, nullable=True)
|
||||
|
||||
# A/B Test tracking
|
||||
ab_test_variant = Column(String(1), nullable=True) # 'A' or 'B'
|
||||
|
||||
# Error tracking
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
campaign = relationship('Campaign', back_populates='emails')
|
||||
user = relationship('User')
|
||||
|
||||
class EmailClick(Base):
|
||||
__tablename__ = 'email_clicks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
campaign_email_id = Column(Integer, ForeignKey('campaign_emails.id'), nullable=False, index=True)
|
||||
url = Column(String(1000), nullable=False)
|
||||
clicked_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
|
||||
# Relationships
|
||||
campaign_email = relationship('CampaignEmail')
|
||||
|
||||
class DripSequence(Base):
|
||||
__tablename__ = 'drip_sequences'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(200), nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
trigger_event = Column(String(100), nullable=True) # 'booking_created', 'checkout_abandoned', etc.
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
steps = relationship('DripSequenceStep', back_populates='sequence', cascade='all, delete-orphan', order_by='DripSequenceStep.step_order')
|
||||
campaigns = relationship('Campaign', foreign_keys=[Campaign.drip_sequence_id], overlaps="drip_sequence")
|
||||
|
||||
class DripSequenceStep(Base):
|
||||
__tablename__ = 'drip_sequence_steps'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
sequence_id = Column(Integer, ForeignKey('drip_sequences.id'), nullable=False, index=True)
|
||||
step_order = Column(Integer, nullable=False) # Order in the sequence
|
||||
|
||||
# Email content
|
||||
subject = Column(String(500), nullable=False)
|
||||
html_content = Column(Text, nullable=False)
|
||||
text_content = Column(Text, nullable=True)
|
||||
template_id = Column(Integer, ForeignKey('email_templates.id'), nullable=True)
|
||||
|
||||
# Timing
|
||||
delay_days = Column(Integer, nullable=False, default=0) # Days to wait after previous step
|
||||
delay_hours = Column(Integer, nullable=False, default=0) # Additional hours
|
||||
|
||||
# Conditions (optional - skip this step if conditions not met)
|
||||
conditions = Column(JSON, nullable=True)
|
||||
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
sequence = relationship('DripSequence', back_populates='steps')
|
||||
template = relationship('EmailTemplate', foreign_keys=[template_id])
|
||||
|
||||
class DripSequenceEnrollment(Base):
|
||||
__tablename__ = 'drip_sequence_enrollments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
sequence_id = Column(Integer, ForeignKey('drip_sequences.id'), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
|
||||
# Progress tracking
|
||||
current_step = Column(Integer, nullable=False, default=0)
|
||||
next_send_at = Column(DateTime, nullable=True, index=True)
|
||||
completed = Column(Boolean, nullable=False, default=False)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Trigger context
|
||||
trigger_data = Column(JSON, nullable=True) # Store context about what triggered enrollment
|
||||
|
||||
enrolled_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
sequence = relationship('DripSequence')
|
||||
user = relationship('User')
|
||||
|
||||
class Unsubscribe(Base):
|
||||
__tablename__ = 'email_unsubscribes'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
email = Column(String(255), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
campaign_id = Column(Integer, ForeignKey('email_campaigns.id'), nullable=True)
|
||||
|
||||
# Unsubscribe type
|
||||
unsubscribe_all = Column(Boolean, nullable=False, default=False) # True = all emails, False = specific campaign
|
||||
unsubscribe_type = Column(String(50), nullable=True) # 'newsletter', 'promotional', etc.
|
||||
|
||||
reason = Column(Text, nullable=True)
|
||||
unsubscribed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User')
|
||||
campaign = relationship('Campaign')
|
||||
|
||||
87
Backend/src/models/gdpr_compliance.py
Normal file
87
Backend/src/models/gdpr_compliance.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class DataSubjectRequestType(str, enum.Enum):
|
||||
access = 'access' # Right to access
|
||||
rectification = 'rectification' # Right to rectification
|
||||
erasure = 'erasure' # Right to erasure (right to be forgotten)
|
||||
portability = 'portability' # Right to data portability
|
||||
restriction = 'restriction' # Right to restriction of processing
|
||||
objection = 'objection' # Right to object
|
||||
|
||||
class DataSubjectRequestStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
rejected = 'rejected'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class DataSubjectRequest(Base):
|
||||
__tablename__ = 'data_subject_requests'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
email = Column(String(255), nullable=False, index=True)
|
||||
request_type = Column(Enum(DataSubjectRequestType), nullable=False, index=True)
|
||||
status = Column(Enum(DataSubjectRequestStatus), nullable=False, default=DataSubjectRequestStatus.pending, index=True)
|
||||
|
||||
# Request details
|
||||
description = Column(Text, nullable=True)
|
||||
verification_token = Column(String(100), nullable=True, unique=True, index=True)
|
||||
verified = Column(Boolean, nullable=False, default=False)
|
||||
verified_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Processing
|
||||
assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
response_data = Column(JSON, nullable=True) # For access requests, store the data
|
||||
|
||||
# Completion
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
completed_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Metadata
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
assignee = relationship('User', foreign_keys=[assigned_to])
|
||||
completer = relationship('User', foreign_keys=[completed_by])
|
||||
|
||||
class DataRetentionPolicy(Base):
|
||||
__tablename__ = 'data_retention_policies'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
data_type = Column(String(100), nullable=False, unique=True) # e.g., 'user_data', 'booking_data', 'payment_data'
|
||||
retention_days = Column(Integer, nullable=False) # Days to retain data
|
||||
auto_delete = Column(Boolean, nullable=False, default=False)
|
||||
description = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
class ConsentRecord(Base):
|
||||
__tablename__ = 'consent_records'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
consent_type = Column(String(100), nullable=False, index=True) # 'marketing', 'analytics', 'cookies', etc.
|
||||
granted = Column(Boolean, nullable=False, default=False)
|
||||
granted_at = Column(DateTime, nullable=True)
|
||||
revoked_at = Column(DateTime, nullable=True)
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
version = Column(String(50), nullable=True) # Policy version when consent was given
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User')
|
||||
|
||||
183
Backend/src/models/group_booking.py
Normal file
183
Backend/src/models/group_booking.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class GroupBookingStatus(str, enum.Enum):
|
||||
draft = 'draft'
|
||||
pending = 'pending'
|
||||
confirmed = 'confirmed'
|
||||
partially_confirmed = 'partially_confirmed'
|
||||
checked_in = 'checked_in'
|
||||
checked_out = 'checked_out'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class PaymentOption(str, enum.Enum):
|
||||
coordinator_pays_all = 'coordinator_pays_all'
|
||||
individual_payments = 'individual_payments'
|
||||
split_payment = 'split_payment'
|
||||
|
||||
class GroupBooking(Base):
|
||||
__tablename__ = 'group_bookings'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_number = Column(String(50), unique=True, nullable=False, index=True)
|
||||
|
||||
# Coordinator information
|
||||
coordinator_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
coordinator_name = Column(String(100), nullable=False)
|
||||
coordinator_email = Column(String(100), nullable=False)
|
||||
coordinator_phone = Column(String(20), nullable=True)
|
||||
|
||||
# Group details
|
||||
group_name = Column(String(200), nullable=True)
|
||||
group_type = Column(String(50), nullable=True) # corporate, wedding, conference, etc.
|
||||
total_rooms = Column(Integer, nullable=False, default=0)
|
||||
total_guests = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Dates
|
||||
check_in_date = Column(DateTime, nullable=False)
|
||||
check_out_date = Column(DateTime, nullable=False)
|
||||
|
||||
# Pricing
|
||||
base_rate_per_room = Column(Numeric(10, 2), nullable=False)
|
||||
group_discount_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
group_discount_amount = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
original_total_price = Column(Numeric(10, 2), nullable=False)
|
||||
discount_amount = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
total_price = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Payment
|
||||
payment_option = Column(Enum(PaymentOption), nullable=False, default=PaymentOption.coordinator_pays_all)
|
||||
deposit_required = Column(Boolean, nullable=False, default=False)
|
||||
deposit_percentage = Column(Integer, nullable=True)
|
||||
deposit_amount = Column(Numeric(10, 2), nullable=True)
|
||||
amount_paid = Column(Numeric(10, 2), nullable=False, default=0)
|
||||
balance_due = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Status and policies
|
||||
status = Column(Enum(GroupBookingStatus), nullable=False, default=GroupBookingStatus.draft)
|
||||
cancellation_policy = Column(Text, nullable=True)
|
||||
cancellation_deadline = Column(DateTime, nullable=True)
|
||||
cancellation_penalty_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
|
||||
# Additional information
|
||||
special_requests = Column(Text, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
contract_terms = Column(Text, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
confirmed_at = Column(DateTime, nullable=True)
|
||||
cancelled_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
coordinator = relationship('User', foreign_keys=[coordinator_id])
|
||||
room_blocks = relationship('GroupRoomBlock', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
members = relationship('GroupBookingMember', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
individual_bookings = relationship('Booking', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
payments = relationship('GroupPayment', back_populates='group_booking', cascade='all, delete-orphan')
|
||||
|
||||
class GroupRoomBlock(Base):
|
||||
__tablename__ = 'group_room_blocks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=False)
|
||||
room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=False)
|
||||
|
||||
# Blocking details
|
||||
rooms_blocked = Column(Integer, nullable=False, default=0)
|
||||
rooms_confirmed = Column(Integer, nullable=False, default=0)
|
||||
rooms_available = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Pricing
|
||||
rate_per_room = Column(Numeric(10, 2), nullable=False)
|
||||
total_block_price = Column(Numeric(10, 2), nullable=False)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
block_released_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group_booking = relationship('GroupBooking', back_populates='room_blocks')
|
||||
room_type = relationship('RoomType')
|
||||
|
||||
class GroupBookingMember(Base):
|
||||
__tablename__ = 'group_booking_members'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=False)
|
||||
|
||||
# Guest information
|
||||
full_name = Column(String(100), nullable=False)
|
||||
email = Column(String(100), nullable=True)
|
||||
phone = Column(String(20), nullable=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # If member is a registered user
|
||||
|
||||
# Room assignment
|
||||
room_block_id = Column(Integer, ForeignKey('group_room_blocks.id'), nullable=True)
|
||||
assigned_room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
|
||||
individual_booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
|
||||
|
||||
# Guest preferences
|
||||
special_requests = Column(Text, nullable=True)
|
||||
preferences = Column(JSON, nullable=True) # Store preferences as JSON
|
||||
|
||||
# Payment (if individual payment option)
|
||||
individual_amount = Column(Numeric(10, 2), nullable=True)
|
||||
individual_paid = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
individual_balance = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
|
||||
# Status
|
||||
is_checked_in = Column(Boolean, nullable=False, default=False)
|
||||
checked_in_at = Column(DateTime, nullable=True)
|
||||
is_checked_out = Column(Boolean, nullable=False, default=False)
|
||||
checked_out_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group_booking = relationship('GroupBooking', back_populates='members')
|
||||
user = relationship('User', foreign_keys=[user_id])
|
||||
room_block = relationship('GroupRoomBlock')
|
||||
assigned_room = relationship('Room', foreign_keys=[assigned_room_id])
|
||||
individual_booking = relationship('Booking', foreign_keys=[individual_booking_id])
|
||||
|
||||
class GroupPayment(Base):
|
||||
__tablename__ = 'group_payments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
group_booking_id = Column(Integer, ForeignKey('group_bookings.id'), nullable=False)
|
||||
|
||||
# Payment details
|
||||
amount = Column(Numeric(10, 2), nullable=False)
|
||||
payment_method = Column(String(50), nullable=False) # Using same PaymentMethod enum values
|
||||
payment_type = Column(String(50), nullable=False, default='deposit') # deposit, full, remaining
|
||||
payment_status = Column(String(50), nullable=False, default='pending') # pending, completed, failed, refunded
|
||||
|
||||
# Transaction details
|
||||
transaction_id = Column(String(100), nullable=True)
|
||||
payment_date = Column(DateTime, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Payer information (if individual payment)
|
||||
paid_by_member_id = Column(Integer, ForeignKey('group_booking_members.id'), nullable=True)
|
||||
paid_by_user_id = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Metadata
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
group_booking = relationship('GroupBooking', back_populates='payments')
|
||||
paid_by_member = relationship('GroupBookingMember', foreign_keys=[paid_by_member_id])
|
||||
paid_by_user = relationship('User', foreign_keys=[paid_by_user_id])
|
||||
|
||||
64
Backend/src/models/housekeeping_task.py
Normal file
64
Backend/src/models/housekeeping_task.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class HousekeepingStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
skipped = 'skipped'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class HousekeepingType(str, enum.Enum):
|
||||
checkout = 'checkout' # Deep cleaning after checkout
|
||||
stayover = 'stayover' # Daily cleaning for occupied rooms
|
||||
vacant = 'vacant' # Cleaning for vacant rooms
|
||||
inspection = 'inspection' # Pre-check-in inspection
|
||||
turndown = 'turndown' # Evening turndown service
|
||||
|
||||
class HousekeepingTask(Base):
|
||||
__tablename__ = 'housekeeping_tasks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||
|
||||
task_type = Column(Enum(HousekeepingType), nullable=False)
|
||||
status = Column(Enum(HousekeepingStatus), nullable=False, default=HousekeepingStatus.pending)
|
||||
|
||||
# Scheduling
|
||||
scheduled_time = Column(DateTime, nullable=False, index=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Assignment
|
||||
assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Task details
|
||||
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)
|
||||
|
||||
# Quality control
|
||||
inspected_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
inspected_at = Column(DateTime, nullable=True)
|
||||
inspection_notes = Column(Text, nullable=True)
|
||||
quality_score = Column(Integer, nullable=True) # 1-5 rating
|
||||
|
||||
# Duration tracking
|
||||
estimated_duration_minutes = Column(Integer, nullable=True)
|
||||
actual_duration_minutes = Column(Integer, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
room = relationship('Room', back_populates='housekeeping_tasks')
|
||||
booking = relationship('Booking')
|
||||
assigned_staff = relationship('User', foreign_keys=[assigned_to])
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
inspector = relationship('User', foreign_keys=[inspected_by])
|
||||
|
||||
125
Backend/src/models/notification.py
Normal file
125
Backend/src/models/notification.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Enum, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class NotificationChannel(str, enum.Enum):
|
||||
email = 'email'
|
||||
sms = 'sms'
|
||||
push = 'push'
|
||||
whatsapp = 'whatsapp'
|
||||
in_app = 'in_app'
|
||||
|
||||
class NotificationStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
sent = 'sent'
|
||||
delivered = 'delivered'
|
||||
failed = 'failed'
|
||||
read = 'read'
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
booking_confirmation = 'booking_confirmation'
|
||||
payment_receipt = 'payment_receipt'
|
||||
pre_arrival_reminder = 'pre_arrival_reminder'
|
||||
check_in_reminder = 'check_in_reminder'
|
||||
check_out_reminder = 'check_out_reminder'
|
||||
marketing_campaign = 'marketing_campaign'
|
||||
loyalty_update = 'loyalty_update'
|
||||
system_alert = 'system_alert'
|
||||
custom = 'custom'
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = 'notifications'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Nullable for system-wide notifications
|
||||
notification_type = Column(Enum(NotificationType), nullable=False)
|
||||
channel = Column(Enum(NotificationChannel), nullable=False)
|
||||
subject = Column(String(255), nullable=True) # For email/push
|
||||
content = Column(Text, nullable=False)
|
||||
template_id = Column(Integer, ForeignKey('notification_templates.id'), nullable=True)
|
||||
status = Column(Enum(NotificationStatus), nullable=False, default=NotificationStatus.pending)
|
||||
priority = Column(String(20), nullable=False, default='normal') # low, normal, high, urgent
|
||||
scheduled_at = Column(DateTime, nullable=True) # For scheduled notifications
|
||||
sent_at = Column(DateTime, nullable=True)
|
||||
delivered_at = Column(DateTime, nullable=True)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
external_id = Column(String(255), nullable=True) # ID from external service (e.g., Twilio, SendGrid)
|
||||
meta_data = Column(JSON, nullable=True) # Additional data (recipient info, attachments, etc.)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
|
||||
payment_id = Column(Integer, ForeignKey('payments.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship('User')
|
||||
template = relationship('NotificationTemplate')
|
||||
booking = relationship('Booking')
|
||||
payment = relationship('Payment')
|
||||
delivery_logs = relationship('NotificationDeliveryLog', back_populates='notification', cascade='all, delete-orphan')
|
||||
|
||||
class NotificationTemplate(Base):
|
||||
__tablename__ = 'notification_templates'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
notification_type = Column(Enum(NotificationType), nullable=False)
|
||||
channel = Column(Enum(NotificationChannel), nullable=False)
|
||||
subject = Column(String(255), nullable=True)
|
||||
content = Column(Text, nullable=False)
|
||||
variables = Column(JSON, nullable=True) # Available template variables
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
notifications = relationship('Notification', back_populates='template')
|
||||
|
||||
class NotificationPreference(Base):
|
||||
__tablename__ = 'notification_preferences'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, unique=True)
|
||||
email_enabled = Column(Boolean, nullable=False, default=True)
|
||||
sms_enabled = Column(Boolean, nullable=False, default=True)
|
||||
push_enabled = Column(Boolean, nullable=False, default=True)
|
||||
whatsapp_enabled = Column(Boolean, nullable=False, default=False)
|
||||
in_app_enabled = Column(Boolean, nullable=False, default=True)
|
||||
# Per-type preferences
|
||||
booking_confirmation_email = Column(Boolean, nullable=False, default=True)
|
||||
booking_confirmation_sms = Column(Boolean, nullable=False, default=False)
|
||||
payment_receipt_email = Column(Boolean, nullable=False, default=True)
|
||||
payment_receipt_sms = Column(Boolean, nullable=False, default=False)
|
||||
pre_arrival_reminder_email = Column(Boolean, nullable=False, default=True)
|
||||
pre_arrival_reminder_sms = Column(Boolean, nullable=False, default=True)
|
||||
check_in_reminder_email = Column(Boolean, nullable=False, default=True)
|
||||
check_in_reminder_sms = Column(Boolean, nullable=False, default=True)
|
||||
check_out_reminder_email = Column(Boolean, nullable=False, default=True)
|
||||
check_out_reminder_sms = Column(Boolean, nullable=False, default=True)
|
||||
marketing_campaign_email = Column(Boolean, nullable=False, default=True)
|
||||
marketing_campaign_sms = Column(Boolean, nullable=False, default=False)
|
||||
loyalty_update_email = Column(Boolean, nullable=False, default=True)
|
||||
loyalty_update_sms = Column(Boolean, nullable=False, default=False)
|
||||
system_alert_email = Column(Boolean, nullable=False, default=True)
|
||||
system_alert_push = Column(Boolean, nullable=False, default=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship('User')
|
||||
|
||||
class NotificationDeliveryLog(Base):
|
||||
__tablename__ = 'notification_delivery_logs'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
notification_id = Column(Integer, ForeignKey('notifications.id'), nullable=False)
|
||||
channel = Column(Enum(NotificationChannel), nullable=False)
|
||||
status = Column(Enum(NotificationStatus), nullable=False)
|
||||
external_id = Column(String(255), nullable=True)
|
||||
error_message = Column(Text, nullable=True)
|
||||
response_data = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
notification = relationship('Notification', back_populates='delivery_logs')
|
||||
|
||||
90
Backend/src/models/package.py
Normal file
90
Backend/src/models/package.py
Normal file
@@ -0,0 +1,90 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, JSON, Enum, ForeignKey, DateTime, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class PackageStatus(str, enum.Enum):
|
||||
active = 'active'
|
||||
inactive = 'inactive'
|
||||
scheduled = 'scheduled'
|
||||
expired = 'expired'
|
||||
|
||||
class PackageItemType(str, enum.Enum):
|
||||
room = 'room'
|
||||
service = 'service'
|
||||
breakfast = 'breakfast'
|
||||
activity = 'activity'
|
||||
amenity = 'amenity'
|
||||
discount = 'discount'
|
||||
|
||||
class Package(Base):
|
||||
__tablename__ = 'packages'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, index=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(Enum(PackageStatus), nullable=False, default=PackageStatus.active)
|
||||
|
||||
# Pricing
|
||||
base_price = Column(Numeric(10, 2), nullable=True) # Fixed package price (if set, overrides item prices)
|
||||
price_modifier = Column(Numeric(5, 2), nullable=False, default=1.0) # Multiplier for total package price
|
||||
discount_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
|
||||
# Applicability
|
||||
room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=True) # None = all room types
|
||||
min_nights = Column(Integer, nullable=True)
|
||||
max_nights = Column(Integer, nullable=True)
|
||||
|
||||
# Date range
|
||||
valid_from = Column(Date, nullable=True)
|
||||
valid_to = Column(Date, nullable=True)
|
||||
|
||||
# Package details
|
||||
image_url = Column(String(500), nullable=True)
|
||||
highlights = Column(JSON, nullable=True) # Array of highlight strings
|
||||
terms_conditions = Column(Text, nullable=True)
|
||||
|
||||
# Additional data (metadata is reserved by SQLAlchemy)
|
||||
extra_data = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
room_type = relationship('RoomType', back_populates='packages')
|
||||
items = relationship('PackageItem', back_populates='package', cascade='all, delete-orphan')
|
||||
rate_plans = relationship('RatePlan', back_populates='package')
|
||||
|
||||
class PackageItem(Base):
|
||||
__tablename__ = 'package_items'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
package_id = Column(Integer, ForeignKey('packages.id'), nullable=False, index=True)
|
||||
|
||||
# Item details
|
||||
item_type = Column(Enum(PackageItemType), nullable=False)
|
||||
item_id = Column(Integer, nullable=True) # ID of room_type, service, etc.
|
||||
item_name = Column(String(200), nullable=False) # Name for display
|
||||
item_description = Column(Text, nullable=True)
|
||||
|
||||
# Quantity
|
||||
quantity = Column(Integer, nullable=False, default=1)
|
||||
unit = Column(String(50), nullable=True) # 'per_night', 'per_stay', 'per_person', etc.
|
||||
|
||||
# Pricing
|
||||
price = Column(Numeric(10, 2), nullable=True) # Item price (if set)
|
||||
included = Column(Boolean, nullable=False, default=True) # If True, included in package price
|
||||
price_modifier = Column(Numeric(5, 2), nullable=True, default=1.0) # Price multiplier for this item
|
||||
|
||||
# Display order
|
||||
display_order = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Additional data
|
||||
extra_data = Column(JSON, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
package = relationship('Package', back_populates='items')
|
||||
|
||||
@@ -12,6 +12,7 @@ class PaymentMethod(str, enum.Enum):
|
||||
e_wallet = 'e_wallet'
|
||||
stripe = 'stripe'
|
||||
paypal = 'paypal'
|
||||
borica = 'borica'
|
||||
|
||||
class PaymentType(str, enum.Enum):
|
||||
full = 'full'
|
||||
|
||||
107
Backend/src/models/rate_plan.py
Normal file
107
Backend/src/models/rate_plan.py
Normal file
@@ -0,0 +1,107 @@
|
||||
from sqlalchemy import Column, Integer, String, Numeric, Boolean, Text, JSON, Enum, ForeignKey, DateTime, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime, date
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class RatePlanType(str, enum.Enum):
|
||||
BAR = 'BAR' # Best Available Rate
|
||||
non_refundable = 'non_refundable'
|
||||
advance_purchase = 'advance_purchase'
|
||||
corporate = 'corporate'
|
||||
government = 'government'
|
||||
military = 'military'
|
||||
long_stay = 'long_stay'
|
||||
package = 'package'
|
||||
|
||||
class RatePlanStatus(str, enum.Enum):
|
||||
active = 'active'
|
||||
inactive = 'inactive'
|
||||
scheduled = 'scheduled'
|
||||
expired = 'expired'
|
||||
|
||||
class RatePlan(Base):
|
||||
__tablename__ = 'rate_plans'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, index=True)
|
||||
code = Column(String(50), unique=True, nullable=False, index=True)
|
||||
description = Column(Text, nullable=True)
|
||||
plan_type = Column(Enum(RatePlanType), nullable=False, default=RatePlanType.BAR)
|
||||
status = Column(Enum(RatePlanStatus), nullable=False, default=RatePlanStatus.active)
|
||||
|
||||
# Pricing
|
||||
base_price_modifier = Column(Numeric(5, 2), nullable=False, default=1.0) # Multiplier (1.0 = 100%, 0.9 = 90%)
|
||||
discount_percentage = Column(Numeric(5, 2), nullable=True, default=0) # Percentage discount
|
||||
fixed_discount = Column(Numeric(10, 2), nullable=True, default=0) # Fixed amount discount
|
||||
|
||||
# Applicability
|
||||
room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=True) # None = all room types
|
||||
min_nights = Column(Integer, nullable=True) # Minimum nights required
|
||||
max_nights = Column(Integer, nullable=True) # Maximum nights allowed
|
||||
advance_days_required = Column(Integer, nullable=True) # Days in advance required for booking
|
||||
|
||||
# Date range
|
||||
valid_from = Column(Date, nullable=True)
|
||||
valid_to = Column(Date, nullable=True)
|
||||
|
||||
# Restrictions
|
||||
is_refundable = Column(Boolean, nullable=False, default=True)
|
||||
requires_deposit = Column(Boolean, nullable=False, default=False)
|
||||
deposit_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
cancellation_hours = Column(Integer, nullable=True) # Hours before check-in for free cancellation
|
||||
|
||||
# Corporate/Government specific
|
||||
corporate_code = Column(String(50), nullable=True, index=True)
|
||||
requires_verification = Column(Boolean, nullable=False, default=False)
|
||||
verification_type = Column(String(50), nullable=True) # 'corporate_id', 'government_id', 'military_id'
|
||||
|
||||
# Long-stay specific
|
||||
long_stay_nights = Column(Integer, nullable=True) # Nights required for long-stay discount
|
||||
|
||||
# Package specific
|
||||
is_package = Column(Boolean, nullable=False, default=False)
|
||||
package_id = Column(Integer, ForeignKey('packages.id'), nullable=True)
|
||||
|
||||
# Priority (lower number = higher priority)
|
||||
priority = Column(Integer, nullable=False, default=100)
|
||||
|
||||
# Additional data (metadata is reserved by SQLAlchemy)
|
||||
extra_data = Column(JSON, nullable=True) # Additional flexible data
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
room_type = relationship('RoomType', back_populates='rate_plans')
|
||||
package = relationship('Package', back_populates='rate_plans')
|
||||
rules = relationship('RatePlanRule', back_populates='rate_plan', cascade='all, delete-orphan')
|
||||
bookings = relationship('Booking', back_populates='rate_plan')
|
||||
|
||||
class RatePlanRule(Base):
|
||||
__tablename__ = 'rate_plan_rules'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
rate_plan_id = Column(Integer, ForeignKey('rate_plans.id'), nullable=False, index=True)
|
||||
|
||||
# Rule type
|
||||
rule_type = Column(String(50), nullable=False) # 'day_of_week', 'season', 'occupancy', 'date_range', etc.
|
||||
rule_key = Column(String(100), nullable=False) # e.g., 'monday', 'summer', '2_guests', '2024-12-01_to_2024-12-31'
|
||||
rule_value = Column(JSON, nullable=True) # Flexible value storage
|
||||
|
||||
# Price adjustment
|
||||
price_modifier = Column(Numeric(5, 2), nullable=True, default=1.0)
|
||||
discount_percentage = Column(Numeric(5, 2), nullable=True, default=0)
|
||||
fixed_adjustment = Column(Numeric(10, 2), nullable=True, default=0)
|
||||
|
||||
# Priority within the rate plan
|
||||
priority = Column(Integer, nullable=False, default=100)
|
||||
|
||||
# Additional data
|
||||
extra_data = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
rate_plan = relationship('RatePlan', back_populates='rules')
|
||||
|
||||
@@ -31,3 +31,7 @@ class Room(Base):
|
||||
bookings = relationship('Booking', back_populates='room')
|
||||
reviews = relationship('Review', back_populates='room')
|
||||
favorites = relationship('Favorite', back_populates='room', cascade='all, delete-orphan')
|
||||
maintenance_records = relationship('RoomMaintenance', back_populates='room', cascade='all, delete-orphan')
|
||||
housekeeping_tasks = relationship('HousekeepingTask', back_populates='room', cascade='all, delete-orphan')
|
||||
inspections = relationship('RoomInspection', back_populates='room', cascade='all, delete-orphan')
|
||||
attributes = relationship('RoomAttribute', back_populates='room', cascade='all, delete-orphan')
|
||||
30
Backend/src/models/room_attribute.py
Normal file
30
Backend/src/models/room_attribute.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Boolean, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
from ..config.database import Base
|
||||
|
||||
class RoomAttribute(Base):
|
||||
__tablename__ = 'room_attributes'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
|
||||
|
||||
# Attribute details
|
||||
attribute_name = Column(String(100), nullable=False) # e.g., 'view_quality', 'noise_level', 'accessibility'
|
||||
attribute_value = Column(String(255), nullable=True) # e.g., 'ocean_view', 'quiet', 'wheelchair_accessible'
|
||||
attribute_data = Column(JSON, nullable=True) # Additional structured data
|
||||
|
||||
# Tracking
|
||||
last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
updated_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Status
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
room = relationship('Room', back_populates='attributes')
|
||||
updater = relationship('User')
|
||||
|
||||
68
Backend/src/models/room_inspection.py
Normal file
68
Backend/src/models/room_inspection.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean, JSON, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class InspectionType(str, enum.Enum):
|
||||
pre_checkin = 'pre_checkin'
|
||||
post_checkout = 'post_checkout'
|
||||
routine = 'routine'
|
||||
maintenance = 'maintenance'
|
||||
damage = 'damage'
|
||||
|
||||
class InspectionStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
failed = 'failed'
|
||||
cancelled = 'cancelled'
|
||||
|
||||
class RoomInspection(Base):
|
||||
__tablename__ = 'room_inspections'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||
|
||||
inspection_type = Column(Enum(InspectionType), nullable=False)
|
||||
status = Column(Enum(InspectionStatus), nullable=False, default=InspectionStatus.pending)
|
||||
|
||||
# Scheduling
|
||||
scheduled_at = Column(DateTime, nullable=False, index=True)
|
||||
started_at = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Assignment
|
||||
inspected_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Checklist
|
||||
checklist_template_id = Column(Integer, nullable=True) # Reference to checklist template
|
||||
checklist_items = Column(JSON, nullable=False) # Array of {category: string, item: string, status: string, notes: string, photos: string[]}
|
||||
# status can be: 'pass', 'fail', 'needs_attention', 'not_applicable'
|
||||
|
||||
# Overall assessment
|
||||
overall_score = Column(Numeric(3, 2), nullable=True) # 0-5 rating
|
||||
overall_notes = Column(Text, nullable=True)
|
||||
issues_found = Column(JSON, nullable=True) # Array of {severity: string, description: string, photo: string}
|
||||
# severity can be: 'critical', 'major', 'minor', 'cosmetic'
|
||||
|
||||
# Photos
|
||||
photos = Column(JSON, nullable=True) # Array of photo URLs
|
||||
|
||||
# Follow-up
|
||||
requires_followup = Column(Boolean, nullable=False, default=False)
|
||||
followup_notes = Column(Text, nullable=True)
|
||||
maintenance_request_id = Column(Integer, ForeignKey('room_maintenance.id'), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
room = relationship('Room', back_populates='inspections')
|
||||
booking = relationship('Booking')
|
||||
inspector = relationship('User', foreign_keys=[inspected_by])
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
maintenance_request = relationship('RoomMaintenance', foreign_keys=[maintenance_request_id])
|
||||
|
||||
62
Backend/src/models/room_maintenance.py
Normal file
62
Backend/src/models/room_maintenance.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean, Numeric
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class MaintenanceType(str, enum.Enum):
|
||||
preventive = 'preventive'
|
||||
corrective = 'corrective'
|
||||
emergency = 'emergency'
|
||||
upgrade = 'upgrade'
|
||||
inspection = 'inspection'
|
||||
|
||||
class MaintenanceStatus(str, enum.Enum):
|
||||
scheduled = 'scheduled'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
cancelled = 'cancelled'
|
||||
on_hold = 'on_hold'
|
||||
|
||||
class RoomMaintenance(Base):
|
||||
__tablename__ = 'room_maintenance'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
|
||||
maintenance_type = Column(Enum(MaintenanceType), nullable=False)
|
||||
status = Column(Enum(MaintenanceStatus), nullable=False, default=MaintenanceStatus.scheduled)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Scheduling
|
||||
scheduled_start = Column(DateTime, nullable=False)
|
||||
scheduled_end = Column(DateTime, nullable=True)
|
||||
actual_start = Column(DateTime, nullable=True)
|
||||
actual_end = Column(DateTime, nullable=True)
|
||||
|
||||
# Assignment
|
||||
assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
reported_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Cost tracking
|
||||
estimated_cost = Column(Numeric(10, 2), nullable=True)
|
||||
actual_cost = Column(Numeric(10, 2), nullable=True)
|
||||
|
||||
# Room blocking
|
||||
blocks_room = Column(Boolean, nullable=False, default=True)
|
||||
block_start = Column(DateTime, nullable=True)
|
||||
block_end = Column(DateTime, nullable=True)
|
||||
|
||||
# Additional info
|
||||
priority = Column(String(20), nullable=False, default='medium') # low, medium, high, urgent
|
||||
notes = Column(Text, nullable=True)
|
||||
completion_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)
|
||||
|
||||
# Relationships
|
||||
room = relationship('Room', back_populates='maintenance_records')
|
||||
assigned_staff = relationship('User', foreign_keys=[assigned_to])
|
||||
reporter = relationship('User', foreign_keys=[reported_by])
|
||||
|
||||
@@ -14,3 +14,5 @@ class RoomType(Base):
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
rooms = relationship('Room', back_populates='room_type')
|
||||
rate_plans = relationship('RatePlan', back_populates='room_type')
|
||||
packages = relationship('Package', back_populates='room_type')
|
||||
135
Backend/src/models/security_event.py
Normal file
135
Backend/src/models/security_event.py
Normal file
@@ -0,0 +1,135 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Enum, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class SecurityEventType(str, enum.Enum):
|
||||
login_attempt = 'login_attempt'
|
||||
login_success = 'login_success'
|
||||
login_failure = 'login_failure'
|
||||
logout = 'logout'
|
||||
password_change = 'password_change'
|
||||
password_reset = 'password_reset'
|
||||
account_locked = 'account_locked'
|
||||
account_unlocked = 'account_unlocked'
|
||||
permission_denied = 'permission_denied'
|
||||
suspicious_activity = 'suspicious_activity'
|
||||
data_access = 'data_access'
|
||||
data_modification = 'data_modification'
|
||||
data_deletion = 'data_deletion'
|
||||
api_access = 'api_access'
|
||||
ip_blocked = 'ip_blocked'
|
||||
rate_limit_exceeded = 'rate_limit_exceeded'
|
||||
oauth_login = 'oauth_login'
|
||||
sso_login = 'sso_login'
|
||||
|
||||
class SecurityEventSeverity(str, enum.Enum):
|
||||
low = 'low'
|
||||
medium = 'medium'
|
||||
high = 'high'
|
||||
critical = 'critical'
|
||||
|
||||
class SecurityEvent(Base):
|
||||
__tablename__ = 'security_events'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
event_type = Column(Enum(SecurityEventType), nullable=False, index=True)
|
||||
severity = Column(Enum(SecurityEventSeverity), nullable=False, default=SecurityEventSeverity.medium, index=True)
|
||||
|
||||
# Request details
|
||||
ip_address = Column(String(45), nullable=True, index=True)
|
||||
user_agent = Column(String(500), nullable=True)
|
||||
request_path = Column(String(500), nullable=True)
|
||||
request_method = Column(String(10), nullable=True)
|
||||
request_id = Column(String(36), nullable=True, index=True)
|
||||
|
||||
# Event details
|
||||
description = Column(Text, nullable=True)
|
||||
details = Column(JSON, nullable=True)
|
||||
extra_data = Column(JSON, nullable=True) # Additional metadata (metadata is reserved by SQLAlchemy)
|
||||
|
||||
# Status
|
||||
resolved = Column(Boolean, nullable=False, default=False)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
resolved_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
resolution_notes = Column(Text, nullable=True)
|
||||
|
||||
# Location (if available)
|
||||
country = Column(String(100), nullable=True)
|
||||
city = Column(String(100), nullable=True)
|
||||
latitude = Column(String(20), nullable=True)
|
||||
longitude = Column(String(20), nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', foreign_keys=[user_id])
|
||||
resolver = relationship('User', foreign_keys=[resolved_by])
|
||||
|
||||
class IPWhitelist(Base):
|
||||
__tablename__ = 'ip_whitelist'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
ip_address = Column(String(45), nullable=False, unique=True, index=True)
|
||||
description = Column(String(255), nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
class IPBlacklist(Base):
|
||||
__tablename__ = 'ip_blacklist'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
ip_address = Column(String(45), nullable=False, unique=True, index=True)
|
||||
reason = Column(Text, nullable=True)
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
blocked_until = Column(DateTime, nullable=True) # Temporary block
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
|
||||
class OAuthProvider(Base):
|
||||
__tablename__ = 'oauth_providers'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(50), nullable=False, unique=True) # google, microsoft, github, etc.
|
||||
display_name = Column(String(100), nullable=False)
|
||||
client_id = Column(String(500), nullable=False)
|
||||
client_secret = Column(String(500), nullable=False) # Should be encrypted
|
||||
authorization_url = Column(String(500), nullable=False)
|
||||
token_url = Column(String(500), nullable=False)
|
||||
userinfo_url = Column(String(500), nullable=False)
|
||||
scopes = Column(String(500), nullable=True) # space-separated scopes
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
is_sso_enabled = Column(Boolean, nullable=False, default=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
class OAuthToken(Base):
|
||||
__tablename__ = 'oauth_tokens'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
provider_id = Column(Integer, ForeignKey('oauth_providers.id'), nullable=False)
|
||||
provider_user_id = Column(String(255), nullable=False) # User ID from OAuth provider
|
||||
access_token = Column(Text, nullable=False) # Should be encrypted
|
||||
refresh_token = Column(Text, nullable=True) # Should be encrypted
|
||||
token_type = Column(String(50), nullable=True, default='Bearer')
|
||||
expires_at = Column(DateTime, nullable=True)
|
||||
scopes = Column(String(500), nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User')
|
||||
provider = relationship('OAuthProvider')
|
||||
|
||||
127
Backend/src/models/workflow.py
Normal file
127
Backend/src/models/workflow.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Boolean, Text, Enum, ForeignKey, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ..config.database import Base
|
||||
|
||||
class WorkflowType(str, enum.Enum):
|
||||
pre_arrival = 'pre_arrival'
|
||||
room_preparation = 'room_preparation'
|
||||
maintenance = 'maintenance'
|
||||
guest_communication = 'guest_communication'
|
||||
follow_up = 'follow_up'
|
||||
custom = 'custom'
|
||||
|
||||
class WorkflowStatus(str, enum.Enum):
|
||||
active = 'active'
|
||||
inactive = 'inactive'
|
||||
archived = 'archived'
|
||||
|
||||
class WorkflowTrigger(str, enum.Enum):
|
||||
booking_created = 'booking_created'
|
||||
booking_confirmed = 'booking_confirmed'
|
||||
check_in = 'check_in'
|
||||
check_out = 'check_out'
|
||||
maintenance_request = 'maintenance_request'
|
||||
guest_message = 'guest_message'
|
||||
manual = 'manual'
|
||||
scheduled = 'scheduled'
|
||||
|
||||
class Workflow(Base):
|
||||
__tablename__ = 'workflows'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
workflow_type = Column(Enum(WorkflowType), nullable=False)
|
||||
status = Column(Enum(WorkflowStatus), nullable=False, default=WorkflowStatus.active)
|
||||
trigger = Column(Enum(WorkflowTrigger), nullable=False)
|
||||
trigger_config = Column(JSON, nullable=True) # Configuration for trigger (e.g., time before check-in)
|
||||
steps = Column(JSON, nullable=False) # Array of workflow steps
|
||||
sla_hours = Column(Integer, nullable=True) # SLA in hours for workflow completion
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
creator = relationship('User', foreign_keys=[created_by])
|
||||
workflow_instances = relationship('WorkflowInstance', back_populates='workflow', cascade='all, delete-orphan')
|
||||
|
||||
class WorkflowInstance(Base):
|
||||
__tablename__ = 'workflow_instances'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
workflow_id = Column(Integer, ForeignKey('workflows.id'), nullable=False)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=True) # Guest user
|
||||
status = Column(String(50), nullable=False, default='pending') # pending, in_progress, completed, cancelled
|
||||
started_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
meta_data = Column(JSON, nullable=True) # Additional context data
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
workflow = relationship('Workflow', back_populates='workflow_instances')
|
||||
booking = relationship('Booking')
|
||||
room = relationship('Room')
|
||||
user = relationship('User', foreign_keys=[user_id])
|
||||
tasks = relationship('Task', back_populates='workflow_instance', cascade='all, delete-orphan')
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
pending = 'pending'
|
||||
assigned = 'assigned'
|
||||
in_progress = 'in_progress'
|
||||
completed = 'completed'
|
||||
cancelled = 'cancelled'
|
||||
overdue = 'overdue'
|
||||
|
||||
class TaskPriority(str, enum.Enum):
|
||||
low = 'low'
|
||||
medium = 'medium'
|
||||
high = 'high'
|
||||
urgent = 'urgent'
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = 'tasks'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
task_type = Column(String(100), nullable=False) # e.g., 'room_cleaning', 'maintenance', 'guest_communication'
|
||||
status = Column(Enum(TaskStatus), nullable=False, default=TaskStatus.pending)
|
||||
priority = Column(Enum(TaskPriority), nullable=False, default=TaskPriority.medium)
|
||||
workflow_instance_id = Column(Integer, ForeignKey('workflow_instances.id'), nullable=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
|
||||
assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
created_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
completed_at = Column(DateTime, nullable=True)
|
||||
estimated_duration_minutes = Column(Integer, nullable=True)
|
||||
actual_duration_minutes = Column(Integer, nullable=True)
|
||||
notes = Column(Text, nullable=True)
|
||||
meta_data = Column(JSON, nullable=True) # Additional task-specific data
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
|
||||
workflow_instance = relationship('WorkflowInstance', back_populates='tasks')
|
||||
booking = relationship('Booking')
|
||||
room = relationship('Room')
|
||||
assignee = relationship('User', foreign_keys=[assigned_to])
|
||||
creator_user = relationship('User', foreign_keys=[created_by])
|
||||
task_comments = relationship('TaskComment', back_populates='task', cascade='all, delete-orphan')
|
||||
|
||||
class TaskComment(Base):
|
||||
__tablename__ = 'task_comments'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
task_id = Column(Integer, ForeignKey('tasks.id'), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
comment = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
task = relationship('Task', back_populates='task_comments')
|
||||
user = relationship('User')
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/routes/__pycache__/analytics_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/analytics_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/package_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/package_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/rate_plan_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/rate_plan_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/security_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/security_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/task_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/task_routes.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/routes/__pycache__/workflow_routes.cpython-312.pyc
Normal file
BIN
Backend/src/routes/__pycache__/workflow_routes.cpython-312.pyc
Normal file
Binary file not shown.
859
Backend/src/routes/advanced_room_routes.py
Normal file
859
Backend/src/routes/advanced_room_routes.py
Normal file
@@ -0,0 +1,859 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
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 ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.role import Role
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceType, MaintenanceStatus
|
||||
from ..models.housekeeping_task import HousekeepingTask, HousekeepingStatus, HousekeepingType
|
||||
from ..models.room_inspection import RoomInspection, InspectionType, InspectionStatus
|
||||
from ..models.room_attribute import RoomAttribute
|
||||
from ..services.room_assignment_service import RoomAssignmentService
|
||||
from pydantic import BaseModel
|
||||
from typing import Dict, Any
|
||||
|
||||
router = APIRouter(prefix='/advanced-rooms', tags=['advanced-room-management'])
|
||||
|
||||
|
||||
# ==================== Room Assignment Optimization ====================
|
||||
|
||||
@router.post('/assign-optimal-room')
|
||||
async def assign_optimal_room(
|
||||
request_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Find the best available room for a booking based on preferences"""
|
||||
try:
|
||||
room_type_id = request_data.get('room_type_id')
|
||||
check_in_str = request_data.get('check_in')
|
||||
check_out_str = request_data.get('check_out')
|
||||
num_guests = request_data.get('num_guests', 1)
|
||||
guest_preferences = request_data.get('guest_preferences', {})
|
||||
exclude_room_ids = request_data.get('exclude_room_ids', [])
|
||||
|
||||
if not room_type_id or not check_in_str or not check_out_str:
|
||||
raise HTTPException(status_code=400, detail='Missing required fields')
|
||||
|
||||
check_in = datetime.fromisoformat(check_in_str.replace('Z', '+00:00'))
|
||||
check_out = datetime.fromisoformat(check_out_str.replace('Z', '+00:00'))
|
||||
|
||||
best_room = RoomAssignmentService.find_best_room(
|
||||
db=db,
|
||||
room_type_id=room_type_id,
|
||||
check_in=check_in,
|
||||
check_out=check_out,
|
||||
num_guests=num_guests,
|
||||
guest_preferences=guest_preferences,
|
||||
exclude_room_ids=exclude_room_ids
|
||||
)
|
||||
|
||||
if not best_room:
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {'room': None, 'message': 'No suitable room available'}
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'room': {
|
||||
'id': best_room.id,
|
||||
'room_number': best_room.room_number,
|
||||
'floor': best_room.floor,
|
||||
'view': best_room.view,
|
||||
'status': best_room.status.value
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/{room_id}/availability-calendar')
|
||||
async def get_room_availability_calendar(
|
||||
room_id: int,
|
||||
start_date: str = Query(...),
|
||||
end_date: str = Query(...),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get detailed availability calendar for a room"""
|
||||
try:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
|
||||
calendar = RoomAssignmentService.get_room_availability_calendar(
|
||||
db=db,
|
||||
room_id=room_id,
|
||||
start_date=start,
|
||||
end_date=end
|
||||
)
|
||||
|
||||
if not calendar:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
|
||||
return {'status': 'success', 'data': calendar}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Room Maintenance ====================
|
||||
|
||||
@router.get('/maintenance')
|
||||
async def get_maintenance_records(
|
||||
room_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
maintenance_type: 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 maintenance records with filtering"""
|
||||
try:
|
||||
# Check if user is staff (not admin) - staff should only see their assigned records
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
query = db.query(RoomMaintenance)
|
||||
|
||||
# Filter by assigned_to for staff users
|
||||
if is_staff:
|
||||
query = query.filter(RoomMaintenance.assigned_to == current_user.id)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(RoomMaintenance.room_id == room_id)
|
||||
if status:
|
||||
query = query.filter(RoomMaintenance.status == MaintenanceStatus(status))
|
||||
if maintenance_type:
|
||||
query = query.filter(RoomMaintenance.maintenance_type == MaintenanceType(maintenance_type))
|
||||
|
||||
total = query.count()
|
||||
query = query.order_by(desc(RoomMaintenance.scheduled_start))
|
||||
|
||||
offset = (page - 1) * limit
|
||||
records = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for record in records:
|
||||
result.append({
|
||||
'id': record.id,
|
||||
'room_id': record.room_id,
|
||||
'room_number': record.room.room_number if record.room else None,
|
||||
'maintenance_type': record.maintenance_type.value,
|
||||
'status': record.status.value,
|
||||
'title': record.title,
|
||||
'description': record.description,
|
||||
'scheduled_start': record.scheduled_start.isoformat() if record.scheduled_start else None,
|
||||
'scheduled_end': record.scheduled_end.isoformat() if record.scheduled_end else None,
|
||||
'actual_start': record.actual_start.isoformat() if record.actual_start else None,
|
||||
'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,
|
||||
'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
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'maintenance_records': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/maintenance')
|
||||
async def create_maintenance_record(
|
||||
maintenance_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new maintenance record"""
|
||||
try:
|
||||
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'))
|
||||
scheduled_end = None
|
||||
if maintenance_data.get('scheduled_end'):
|
||||
scheduled_end = datetime.fromisoformat(maintenance_data['scheduled_end'].replace('Z', '+00:00'))
|
||||
|
||||
block_start = None
|
||||
block_end = None
|
||||
if maintenance_data.get('block_start'):
|
||||
block_start = datetime.fromisoformat(maintenance_data['block_start'].replace('Z', '+00:00'))
|
||||
if maintenance_data.get('block_end'):
|
||||
block_end = datetime.fromisoformat(maintenance_data['block_end'].replace('Z', '+00:00'))
|
||||
|
||||
maintenance = RoomMaintenance(
|
||||
room_id=maintenance_data['room_id'],
|
||||
maintenance_type=MaintenanceType(maintenance_data.get('maintenance_type', 'preventive')),
|
||||
status=MaintenanceStatus(maintenance_data.get('status', 'scheduled')),
|
||||
title=maintenance_data.get('title', 'Maintenance'),
|
||||
description=maintenance_data.get('description'),
|
||||
scheduled_start=scheduled_start,
|
||||
scheduled_end=scheduled_end,
|
||||
assigned_to=maintenance_data.get('assigned_to'),
|
||||
reported_by=current_user.id,
|
||||
estimated_cost=maintenance_data.get('estimated_cost'),
|
||||
blocks_room=maintenance_data.get('blocks_room', True),
|
||||
block_start=block_start,
|
||||
block_end=block_end,
|
||||
priority=maintenance_data.get('priority', 'medium'),
|
||||
notes=maintenance_data.get('notes')
|
||||
)
|
||||
|
||||
# Update room status if blocking and maintenance is active
|
||||
if maintenance.blocks_room and maintenance.status in [MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]:
|
||||
# Only update if room is currently available
|
||||
if room.status == RoomStatus.available:
|
||||
room.status = RoomStatus.maintenance
|
||||
|
||||
db.add(maintenance)
|
||||
db.commit()
|
||||
db.refresh(maintenance)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Maintenance record created successfully',
|
||||
'data': {'maintenance_id': maintenance.id}
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put('/maintenance/{maintenance_id}')
|
||||
async def update_maintenance_record(
|
||||
maintenance_id: int,
|
||||
maintenance_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a maintenance record"""
|
||||
try:
|
||||
maintenance = db.query(RoomMaintenance).filter(RoomMaintenance.id == maintenance_id).first()
|
||||
if not maintenance:
|
||||
raise HTTPException(status_code=404, detail='Maintenance record not found')
|
||||
|
||||
# Check if user is staff (not admin) - staff can only update their own assigned records
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
if is_staff:
|
||||
# Staff can only update records assigned to them
|
||||
if maintenance.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only update maintenance assigned to you')
|
||||
# Staff can only update status and completion fields
|
||||
allowed_fields = {'status', 'actual_start', 'actual_end', 'completion_notes', 'actual_cost'}
|
||||
if any(key not in allowed_fields for key in maintenance_data.keys()):
|
||||
raise HTTPException(status_code=403, detail='You can only update status and completion information')
|
||||
|
||||
# Update fields
|
||||
if 'status' in maintenance_data:
|
||||
new_status = MaintenanceStatus(maintenance_data['status'])
|
||||
|
||||
# Only the assigned user can mark the maintenance as completed
|
||||
if new_status == MaintenanceStatus.completed:
|
||||
if not maintenance.assigned_to:
|
||||
raise HTTPException(status_code=400, detail='Maintenance must be assigned before it can be marked as completed')
|
||||
if maintenance.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Only the assigned staff member can mark this maintenance as completed')
|
||||
|
||||
old_status = maintenance.status
|
||||
maintenance.status = new_status
|
||||
|
||||
# Update room status based on maintenance status
|
||||
if maintenance.status == MaintenanceStatus.completed and maintenance.blocks_room:
|
||||
# Check if room has other active maintenance
|
||||
other_maintenance = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == maintenance.room_id,
|
||||
RoomMaintenance.id != maintenance_id,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
|
||||
)
|
||||
).first()
|
||||
|
||||
if not other_maintenance:
|
||||
# Check if room has active bookings
|
||||
from datetime import datetime
|
||||
active_booking = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == maintenance.room_id,
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
|
||||
Booking.check_in_date <= datetime.utcnow(),
|
||||
Booking.check_out_date > datetime.utcnow()
|
||||
)
|
||||
).first()
|
||||
|
||||
if active_booking:
|
||||
maintenance.room.status = RoomStatus.occupied
|
||||
else:
|
||||
maintenance.room.status = RoomStatus.available
|
||||
elif maintenance.status in [MaintenanceStatus.scheduled, MaintenanceStatus.in_progress] and maintenance.blocks_room:
|
||||
# Set room to maintenance if it's not occupied
|
||||
if maintenance.room.status == RoomStatus.available:
|
||||
maintenance.room.status = RoomStatus.maintenance
|
||||
|
||||
if 'actual_start' in maintenance_data:
|
||||
maintenance.actual_start = datetime.fromisoformat(maintenance_data['actual_start'].replace('Z', '+00:00'))
|
||||
if 'actual_end' in maintenance_data:
|
||||
maintenance.actual_end = datetime.fromisoformat(maintenance_data['actual_end'].replace('Z', '+00:00'))
|
||||
if 'completion_notes' in maintenance_data:
|
||||
maintenance.completion_notes = maintenance_data['completion_notes']
|
||||
if 'actual_cost' in maintenance_data:
|
||||
maintenance.actual_cost = maintenance_data['actual_cost']
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Maintenance record updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Housekeeping Tasks ====================
|
||||
|
||||
@router.get('/housekeeping')
|
||||
async def get_housekeeping_tasks(
|
||||
room_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
task_type: Optional[str] = Query(None),
|
||||
date: 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 housekeeping tasks with filtering"""
|
||||
try:
|
||||
# Check if user is staff (not admin) - staff should only see their assigned tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
query = db.query(HousekeepingTask)
|
||||
|
||||
# Filter by assigned_to for staff users
|
||||
if is_staff:
|
||||
query = query.filter(HousekeepingTask.assigned_to == current_user.id)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(HousekeepingTask.room_id == room_id)
|
||||
if status:
|
||||
query = query.filter(HousekeepingTask.status == HousekeepingStatus(status))
|
||||
if task_type:
|
||||
query = 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)
|
||||
|
||||
total = query.count()
|
||||
query = query.order_by(HousekeepingTask.scheduled_time)
|
||||
|
||||
offset = (page - 1) * limit
|
||||
tasks = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for task in tasks:
|
||||
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
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'tasks': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/housekeeping')
|
||||
async def create_housekeeping_task(
|
||||
task_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new housekeeping task"""
|
||||
try:
|
||||
room = db.query(Room).filter(Room.id == task_data.get('room_id')).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=404, detail='Room not found')
|
||||
|
||||
scheduled_time = datetime.fromisoformat(task_data['scheduled_time'].replace('Z', '+00:00'))
|
||||
assigned_to = task_data.get('assigned_to')
|
||||
|
||||
task = HousekeepingTask(
|
||||
room_id=task_data['room_id'],
|
||||
booking_id=task_data.get('booking_id'),
|
||||
task_type=HousekeepingType(task_data.get('task_type', 'vacant')),
|
||||
status=HousekeepingStatus(task_data.get('status', 'pending')),
|
||||
scheduled_time=scheduled_time,
|
||||
assigned_to=assigned_to,
|
||||
created_by=current_user.id,
|
||||
checklist_items=task_data.get('checklist_items', []),
|
||||
notes=task_data.get('notes'),
|
||||
estimated_duration_minutes=task_data.get('estimated_duration_minutes')
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
# Send notification to assigned staff member if task is assigned
|
||||
if assigned_to:
|
||||
try:
|
||||
from ..routes.chat_routes import manager
|
||||
assigned_staff = db.query(User).filter(User.id == assigned_to).first()
|
||||
task_data_notification = {
|
||||
'id': task.id,
|
||||
'room_id': task.room_id,
|
||||
'room_number': room.room_number,
|
||||
'task_type': task.task_type.value,
|
||||
'status': task.status.value,
|
||||
'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None,
|
||||
'assigned_to': task.assigned_to,
|
||||
'created_at': task.created_at.isoformat() if task.created_at else None
|
||||
}
|
||||
notification_data = {
|
||||
'type': 'housekeeping_task_assigned',
|
||||
'data': task_data_notification
|
||||
}
|
||||
# Send notification to the specific staff member
|
||||
if assigned_to in manager.staff_connections:
|
||||
try:
|
||||
await manager.staff_connections[assigned_to].send_json(notification_data)
|
||||
except Exception as e:
|
||||
print(f'Error sending housekeeping task notification to staff {assigned_to}: {e}')
|
||||
except Exception as e:
|
||||
print(f'Error setting up housekeeping task notification: {e}')
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Housekeeping task created successfully',
|
||||
'data': {'task_id': task.id}
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put('/housekeeping/{task_id}')
|
||||
async def update_housekeeping_task(
|
||||
task_id: int,
|
||||
task_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update 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 if user is staff (not admin) - staff can only update their own assigned tasks
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
if is_staff:
|
||||
# Staff can only update tasks assigned to them
|
||||
if task.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='You can only update tasks assigned to you')
|
||||
# Staff cannot change assignment
|
||||
if 'assigned_to' in task_data and task_data.get('assigned_to') != task.assigned_to:
|
||||
raise HTTPException(status_code=403, detail='You cannot change task assignment')
|
||||
|
||||
old_assigned_to = task.assigned_to
|
||||
assigned_to_changed = False
|
||||
|
||||
if 'assigned_to' in task_data and not is_staff:
|
||||
new_assigned_to = task_data.get('assigned_to')
|
||||
if new_assigned_to != old_assigned_to:
|
||||
task.assigned_to = new_assigned_to
|
||||
assigned_to_changed = True
|
||||
|
||||
if 'status' in task_data:
|
||||
new_status = HousekeepingStatus(task_data['status'])
|
||||
|
||||
# Only the assigned user can mark the task as completed
|
||||
if new_status == HousekeepingStatus.completed:
|
||||
if not task.assigned_to:
|
||||
raise HTTPException(status_code=400, detail='Task must be assigned before it can be marked as completed')
|
||||
if task.assigned_to != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Only the assigned staff member can mark this task as completed')
|
||||
|
||||
task.status = new_status
|
||||
|
||||
if new_status == HousekeepingStatus.in_progress and not task.started_at:
|
||||
task.started_at = datetime.utcnow()
|
||||
elif new_status == HousekeepingStatus.completed and not task.completed_at:
|
||||
task.completed_at = datetime.utcnow()
|
||||
if task.started_at:
|
||||
duration = (task.completed_at - task.started_at).total_seconds() / 60
|
||||
task.actual_duration_minutes = int(duration)
|
||||
|
||||
if 'checklist_items' in task_data:
|
||||
task.checklist_items = task_data['checklist_items']
|
||||
if 'notes' in task_data:
|
||||
task.notes = task_data['notes']
|
||||
if 'issues_found' in task_data:
|
||||
task.issues_found = task_data['issues_found']
|
||||
if 'quality_score' in task_data:
|
||||
task.quality_score = task_data['quality_score']
|
||||
if 'inspected_by' in task_data:
|
||||
task.inspected_by = task_data['inspected_by']
|
||||
task.inspected_at = datetime.utcnow()
|
||||
if 'inspection_notes' in task_data:
|
||||
task.inspection_notes = task_data['inspection_notes']
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
# Send notification if assignment changed
|
||||
if assigned_to_changed and task.assigned_to:
|
||||
try:
|
||||
from ..routes.chat_routes import manager
|
||||
room = db.query(Room).filter(Room.id == task.room_id).first()
|
||||
task_data_notification = {
|
||||
'id': task.id,
|
||||
'room_id': task.room_id,
|
||||
'room_number': room.room_number if room else None,
|
||||
'task_type': task.task_type.value,
|
||||
'status': task.status.value,
|
||||
'scheduled_time': task.scheduled_time.isoformat() if task.scheduled_time else None,
|
||||
'assigned_to': task.assigned_to,
|
||||
'updated_at': task.updated_at.isoformat() if task.updated_at else None
|
||||
}
|
||||
notification_data = {
|
||||
'type': 'housekeeping_task_assigned',
|
||||
'data': task_data_notification
|
||||
}
|
||||
# Send notification to the newly assigned staff member
|
||||
if task.assigned_to in manager.staff_connections:
|
||||
try:
|
||||
await manager.staff_connections[task.assigned_to].send_json(notification_data)
|
||||
except Exception as e:
|
||||
print(f'Error sending housekeeping task notification to staff {task.assigned_to}: {e}')
|
||||
except Exception as e:
|
||||
print(f'Error setting up housekeeping task notification: {e}')
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Housekeeping task updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Room Inspections ====================
|
||||
|
||||
@router.get('/inspections')
|
||||
async def get_room_inspections(
|
||||
room_id: Optional[int] = Query(None),
|
||||
inspection_type: Optional[str] = Query(None),
|
||||
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')),
|
||||
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
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
query = db.query(RoomInspection)
|
||||
|
||||
# Filter by inspected_by for staff users
|
||||
if is_staff:
|
||||
query = query.filter(RoomInspection.inspected_by == current_user.id)
|
||||
|
||||
if room_id:
|
||||
query = query.filter(RoomInspection.room_id == room_id)
|
||||
if inspection_type:
|
||||
query = query.filter(RoomInspection.inspection_type == InspectionType(inspection_type))
|
||||
if status:
|
||||
query = query.filter(RoomInspection.status == InspectionStatus(status))
|
||||
|
||||
total = query.count()
|
||||
query = query.order_by(desc(RoomInspection.scheduled_at))
|
||||
|
||||
offset = (page - 1) * limit
|
||||
inspections = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for inspection in inspections:
|
||||
result.append({
|
||||
'id': inspection.id,
|
||||
'room_id': inspection.room_id,
|
||||
'room_number': inspection.room.room_number if inspection.room else None,
|
||||
'booking_id': inspection.booking_id,
|
||||
'inspection_type': inspection.inspection_type.value,
|
||||
'status': inspection.status.value,
|
||||
'scheduled_at': inspection.scheduled_at.isoformat() if inspection.scheduled_at else None,
|
||||
'started_at': inspection.started_at.isoformat() if inspection.started_at else None,
|
||||
'completed_at': inspection.completed_at.isoformat() if inspection.completed_at else None,
|
||||
'inspected_by': inspection.inspected_by,
|
||||
'inspector_name': inspection.inspector.full_name if inspection.inspector else None,
|
||||
'checklist_items': inspection.checklist_items,
|
||||
'overall_score': float(inspection.overall_score) if inspection.overall_score else None,
|
||||
'overall_notes': inspection.overall_notes,
|
||||
'issues_found': inspection.issues_found,
|
||||
'requires_followup': inspection.requires_followup,
|
||||
'created_at': inspection.created_at.isoformat() if inspection.created_at else None
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'inspections': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/inspections')
|
||||
async def create_room_inspection(
|
||||
inspection_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new room inspection"""
|
||||
try:
|
||||
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'))
|
||||
|
||||
inspection = RoomInspection(
|
||||
room_id=inspection_data['room_id'],
|
||||
booking_id=inspection_data.get('booking_id'),
|
||||
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'),
|
||||
created_by=current_user.id,
|
||||
checklist_items=inspection_data.get('checklist_items', []),
|
||||
checklist_template_id=inspection_data.get('checklist_template_id')
|
||||
)
|
||||
|
||||
db.add(inspection)
|
||||
db.commit()
|
||||
db.refresh(inspection)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Room inspection created successfully',
|
||||
'data': {'inspection_id': inspection.id}
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put('/inspections/{inspection_id}')
|
||||
async def update_room_inspection(
|
||||
inspection_id: int,
|
||||
inspection_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a room inspection"""
|
||||
try:
|
||||
inspection = db.query(RoomInspection).filter(RoomInspection.id == inspection_id).first()
|
||||
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
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
is_staff = role and role.name == 'staff'
|
||||
|
||||
if is_staff:
|
||||
# Staff 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'}
|
||||
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')
|
||||
|
||||
if 'status' in inspection_data:
|
||||
new_status = InspectionStatus(inspection_data['status'])
|
||||
|
||||
# Only the assigned user can mark the inspection as completed
|
||||
if new_status == InspectionStatus.completed:
|
||||
if not inspection.inspected_by:
|
||||
raise HTTPException(status_code=400, detail='Inspection must be assigned before it can be marked as completed')
|
||||
if inspection.inspected_by != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Only the assigned inspector can mark this inspection as completed')
|
||||
|
||||
inspection.status = new_status
|
||||
|
||||
if new_status == InspectionStatus.in_progress and not inspection.started_at:
|
||||
inspection.started_at = datetime.utcnow()
|
||||
elif new_status == InspectionStatus.completed and not inspection.completed_at:
|
||||
inspection.completed_at = datetime.utcnow()
|
||||
|
||||
if 'checklist_items' in inspection_data:
|
||||
inspection.checklist_items = inspection_data['checklist_items']
|
||||
if 'overall_score' in inspection_data:
|
||||
inspection.overall_score = inspection_data['overall_score']
|
||||
if 'overall_notes' in inspection_data:
|
||||
inspection.overall_notes = inspection_data['overall_notes']
|
||||
if 'issues_found' in inspection_data:
|
||||
inspection.issues_found = inspection_data['issues_found']
|
||||
if 'photos' in inspection_data:
|
||||
inspection.photos = inspection_data['photos']
|
||||
if 'requires_followup' in inspection_data:
|
||||
inspection.requires_followup = inspection_data['requires_followup']
|
||||
if 'followup_notes' in inspection_data:
|
||||
inspection.followup_notes = inspection_data['followup_notes']
|
||||
if 'maintenance_request_id' in inspection_data:
|
||||
inspection.maintenance_request_id = inspection_data['maintenance_request_id']
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Room inspection updated successfully'
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Room Status Board ====================
|
||||
|
||||
@router.get('/status-board')
|
||||
async def get_room_status_board(
|
||||
floor: Optional[int] = Query(None),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get visual room status board with all rooms and their current status"""
|
||||
try:
|
||||
query = db.query(Room).options(joinedload(Room.room_type))
|
||||
if floor:
|
||||
query = query.filter(Room.floor == floor)
|
||||
|
||||
rooms = query.order_by(Room.floor, Room.room_number).all()
|
||||
|
||||
result = []
|
||||
for room in rooms:
|
||||
# Get current booking if any
|
||||
# Use load_only to avoid querying columns that don't exist in the database (rate_plan_id, group_booking_id)
|
||||
current_booking = db.query(Booking).options(
|
||||
joinedload(Booking.user),
|
||||
load_only(Booking.id, Booking.user_id, Booking.room_id, Booking.check_in_date, Booking.check_out_date, Booking.status)
|
||||
).filter(
|
||||
and_(
|
||||
Booking.room_id == room.id,
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
|
||||
Booking.check_in_date <= datetime.utcnow(),
|
||||
Booking.check_out_date > datetime.utcnow()
|
||||
)
|
||||
).first()
|
||||
|
||||
# Get active maintenance
|
||||
active_maintenance = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == room.id,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
|
||||
)
|
||||
).first()
|
||||
|
||||
# Get pending housekeeping tasks
|
||||
pending_housekeeping = db.query(HousekeepingTask).filter(
|
||||
and_(
|
||||
HousekeepingTask.room_id == room.id,
|
||||
HousekeepingTask.status == HousekeepingStatus.pending,
|
||||
func.date(HousekeepingTask.scheduled_time) == datetime.utcnow().date()
|
||||
)
|
||||
).count()
|
||||
|
||||
result.append({
|
||||
'id': room.id,
|
||||
'room_number': room.room_number,
|
||||
'floor': room.floor,
|
||||
'status': room.status.value,
|
||||
'room_type': room.room_type.name if room.room_type else None,
|
||||
'current_booking': {
|
||||
'id': current_booking.id,
|
||||
'guest_name': current_booking.user.full_name if current_booking.user else 'Unknown',
|
||||
'check_out': current_booking.check_out_date.isoformat()
|
||||
} if current_booking else None,
|
||||
'active_maintenance': {
|
||||
'id': active_maintenance.id,
|
||||
'title': active_maintenance.title,
|
||||
'type': active_maintenance.maintenance_type.value
|
||||
} if active_maintenance else None,
|
||||
'pending_housekeeping_count': pending_housekeeping
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {'rooms': result}
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
import traceback
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error in get_room_status_board: {str(e)}')
|
||||
logger.error(f'Traceback: {traceback.format_exc()}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
301
Backend/src/routes/analytics_routes.py
Normal file
301
Backend/src/routes/analytics_routes.py
Normal file
@@ -0,0 +1,301 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import authorize_roles, get_current_user
|
||||
from ..models.user import User
|
||||
from ..services.analytics_service import AnalyticsService
|
||||
|
||||
router = APIRouter(prefix='/analytics', tags=['analytics'])
|
||||
|
||||
# ==================== REVENUE ANALYTICS ====================
|
||||
|
||||
@router.get('/revenue/revpar')
|
||||
async def get_revpar(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get RevPAR (Revenue Per Available Room)"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_revpar(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/revenue/adr')
|
||||
async def get_adr(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get ADR (Average Daily Rate)"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_adr(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/revenue/occupancy')
|
||||
async def get_occupancy_rate(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Occupancy Rate"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_occupancy_rate(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/revenue/forecast')
|
||||
async def get_revenue_forecast(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Revenue Forecast"""
|
||||
try:
|
||||
result = AnalyticsService.get_revenue_forecast(db, days)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/revenue/market-penetration')
|
||||
async def get_market_penetration(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Market Penetration Analysis"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_market_penetration(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ==================== OPERATIONAL ANALYTICS ====================
|
||||
|
||||
@router.get('/operational/staff-performance')
|
||||
async def get_staff_performance(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Staff Performance Metrics"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_staff_performance(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/operational/service-usage')
|
||||
async def get_service_usage_analytics(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Service Usage Analytics"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_service_usage_analytics(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/operational/efficiency')
|
||||
async def get_operational_efficiency(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Operational Efficiency Metrics"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_operational_efficiency(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ==================== GUEST ANALYTICS ====================
|
||||
|
||||
@router.get('/guest/lifetime-value')
|
||||
async def get_guest_lifetime_value(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Guest Lifetime Value Analysis"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_guest_lifetime_value(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/guest/acquisition-cost')
|
||||
async def get_customer_acquisition_cost(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Customer Acquisition Cost Analysis"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_customer_acquisition_cost(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/guest/repeat-rate')
|
||||
async def get_repeat_guest_rate(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Repeat Guest Rate"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_repeat_guest_rate(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/guest/satisfaction-trends')
|
||||
async def get_guest_satisfaction_trends(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Guest Satisfaction Trends"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_guest_satisfaction_trends(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ==================== FINANCIAL ANALYTICS ====================
|
||||
|
||||
@router.get('/financial/profit-loss')
|
||||
async def get_profit_loss(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Profit & Loss Report"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_profit_loss(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/financial/payment-methods')
|
||||
async def get_payment_method_analytics(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Payment Method Analytics"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_payment_method_analytics(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/financial/refunds')
|
||||
async def get_refund_analysis(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
current_user: User = Depends(authorize_roles('admin', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Refund Analysis"""
|
||||
try:
|
||||
start, end = AnalyticsService.parse_date_range(start_date, end_date)
|
||||
result = AnalyticsService.get_refund_analysis(db, start, end)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# ==================== COMPREHENSIVE ANALYTICS ====================
|
||||
|
||||
@router.get('/comprehensive')
|
||||
async def get_comprehensive_analytics(
|
||||
start_date: Optional[str] = Query(None, alias='from'),
|
||||
end_date: Optional[str] = Query(None, alias='to'),
|
||||
include_revenue: bool = Query(True),
|
||||
include_operational: bool = Query(True),
|
||||
include_guest: bool = Query(True),
|
||||
include_financial: bool = Query(True),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get Comprehensive Analytics across all categories"""
|
||||
try:
|
||||
result = AnalyticsService.get_comprehensive_analytics(
|
||||
db,
|
||||
start_date,
|
||||
end_date,
|
||||
include_revenue,
|
||||
include_operational,
|
||||
include_guest,
|
||||
include_financial
|
||||
)
|
||||
return {'status': 'success', 'data': result}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload, load_only
|
||||
from sqlalchemy import and_, or_, func
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import random
|
||||
@@ -22,6 +22,8 @@ from fastapi import Request
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template
|
||||
from ..services.loyalty_service import LoyaltyService
|
||||
from ..utils.currency_helpers import get_currency_symbol
|
||||
from ..utils.response_helpers import success_response
|
||||
router = APIRouter(prefix='/bookings', tags=['bookings'])
|
||||
|
||||
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
|
||||
@@ -46,7 +48,20 @@ def calculate_booking_payment_balance(booking: Booking) -> dict:
|
||||
@router.get('/')
|
||||
async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), startDate: Optional[str]=Query(None), endDate: Optional[str]=Query(None), 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(Booking).options(selectinload(Booking.payments), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type))
|
||||
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
|
||||
query = db.query(Booking).options(
|
||||
load_only(
|
||||
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
|
||||
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
|
||||
Booking.total_price, Booking.original_price, Booking.discount_amount,
|
||||
Booking.promotion_code, Booking.status, Booking.deposit_paid,
|
||||
Booking.requires_deposit, Booking.special_requests,
|
||||
Booking.created_at, Booking.updated_at
|
||||
),
|
||||
selectinload(Booking.payments),
|
||||
joinedload(Booking.user),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
)
|
||||
if search:
|
||||
query = query.filter(Booking.booking_number.like(f'%{search}%'))
|
||||
if status_filter:
|
||||
@@ -60,7 +75,8 @@ async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Opt
|
||||
if endDate:
|
||||
end = datetime.fromisoformat(endDate.replace('Z', '+00:00'))
|
||||
query = query.filter(Booking.check_in_date <= end)
|
||||
total = query.count()
|
||||
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
|
||||
total = query.with_entities(func.count(Booking.id)).scalar()
|
||||
offset = (page - 1) * limit
|
||||
bookings = query.order_by(Booking.created_at.desc()).offset(offset).limit(limit).all()
|
||||
result = []
|
||||
@@ -96,7 +112,9 @@ async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Opt
|
||||
else:
|
||||
booking_dict['payments'] = []
|
||||
result.append(booking_dict)
|
||||
return {'status': 'success', 'data': {'bookings': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
|
||||
return success_response(
|
||||
data={'bookings': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
import traceback
|
||||
@@ -140,7 +158,7 @@ async def get_my_bookings(request: Request, current_user: User=Depends(get_curre
|
||||
else:
|
||||
booking_dict['payments'] = []
|
||||
result.append(booking_dict)
|
||||
return {'success': True, 'data': {'bookings': result}}
|
||||
return success_response(data={'bookings': result})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -190,9 +208,36 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
|
||||
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
|
||||
if check_in >= check_out:
|
||||
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date')
|
||||
overlapping = db.query(Booking).filter(and_(Booking.room_id == room_id, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first()
|
||||
if overlapping:
|
||||
raise HTTPException(status_code=409, detail='Room already booked for the selected dates')
|
||||
|
||||
# Check for maintenance blocks
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceStatus
|
||||
maintenance_block = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == room_id,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]),
|
||||
or_(
|
||||
and_(
|
||||
RoomMaintenance.block_start.isnot(None),
|
||||
RoomMaintenance.block_end.isnot(None),
|
||||
RoomMaintenance.block_start < check_out,
|
||||
RoomMaintenance.block_end > check_in
|
||||
),
|
||||
and_(
|
||||
RoomMaintenance.scheduled_start < check_out,
|
||||
RoomMaintenance.scheduled_end.isnot(None),
|
||||
RoomMaintenance.scheduled_end > check_in
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if maintenance_block:
|
||||
raise HTTPException(status_code=409, detail=f'Room is blocked for maintenance: {maintenance_block.title}')
|
||||
booking_number = generate_booking_number()
|
||||
|
||||
# Calculate room price
|
||||
@@ -330,6 +375,17 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
|
||||
db.add(service_usage)
|
||||
db.commit()
|
||||
db.refresh(booking)
|
||||
|
||||
# Send booking confirmation notification
|
||||
try:
|
||||
from ..services.notification_service import NotificationService
|
||||
if booking.status == BookingStatus.confirmed:
|
||||
NotificationService.send_booking_confirmation(db, booking)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send booking confirmation notification: {e}')
|
||||
|
||||
try:
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from ..utils.mailer import send_email
|
||||
@@ -430,7 +486,8 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
|
||||
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor}
|
||||
if booking.room.room_type:
|
||||
booking_dict['room']['room_type'] = {'id': booking.room.room_type.id, 'name': booking.room.room_type.name, 'base_price': float(booking.room.room_type.base_price) if booking.room.room_type.base_price else 0.0, 'capacity': booking.room.room_type.capacity}
|
||||
return {'success': True, 'data': {'booking': booking_dict}, 'message': f'Booking created. Please pay {deposit_percentage}% deposit to confirm.' if requires_deposit else 'Booking created successfully'}
|
||||
message = f'Booking created. Please pay {deposit_percentage}% deposit to confirm.' if requires_deposit else 'Booking created successfully'
|
||||
return success_response(data={'booking': booking_dict}, message=message)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -449,7 +506,8 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
|
||||
booking = db.query(Booking).options(selectinload(Booking.payments), selectinload(Booking.service_usages).selectinload(ServiceUsage.service), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.id == id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
from ..utils.role_helpers import is_admin
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -497,7 +555,7 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
|
||||
else:
|
||||
logger.info(f'Get booking {id} - No service_usages found, initializing empty array')
|
||||
booking_dict['service_usages'] = []
|
||||
return {'success': True, 'data': {'booking': booking_dict}}
|
||||
return success_response(data={'booking': booking_dict})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -513,10 +571,10 @@ 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')
|
||||
if booking.status == BookingStatus.confirmed:
|
||||
raise HTTPException(status_code=400, detail='Cannot cancel a confirmed booking. Please contact support for assistance.')
|
||||
# Customers can only cancel pending bookings
|
||||
# Admin/Staff can cancel any booking via update_booking endpoint
|
||||
if booking.status != BookingStatus.pending:
|
||||
raise HTTPException(status_code=400, detail=f'Cannot cancel booking with status: {booking.status.value}. Only pending bookings can be cancelled.')
|
||||
raise HTTPException(status_code=400, detail=f'Cannot cancel booking with status: {booking.status.value}. Only pending bookings can be cancelled. Please contact support for assistance.')
|
||||
booking = db.query(Booking).options(selectinload(Booking.payments)).filter(Booking.id == id).first()
|
||||
payments_updated = False
|
||||
if booking.payments:
|
||||
@@ -536,7 +594,37 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
|
||||
payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
|
||||
payments_updated = True
|
||||
booking.status = BookingStatus.cancelled
|
||||
if payments_updated > 0:
|
||||
|
||||
# Update room status when booking is cancelled
|
||||
if booking.room:
|
||||
# Check if room has other active bookings
|
||||
active_booking = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == booking.room_id,
|
||||
Booking.id != booking.id,
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
|
||||
Booking.check_in_date <= datetime.utcnow(),
|
||||
Booking.check_out_date > datetime.utcnow()
|
||||
)
|
||||
).first()
|
||||
|
||||
if not active_booking:
|
||||
# Check for maintenance
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceStatus
|
||||
active_maintenance = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == booking.room_id,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
|
||||
)
|
||||
).first()
|
||||
|
||||
if active_maintenance:
|
||||
booking.room.status = RoomStatus.maintenance
|
||||
else:
|
||||
booking.room.status = RoomStatus.available
|
||||
|
||||
if payments_updated:
|
||||
db.flush()
|
||||
db.commit()
|
||||
try:
|
||||
@@ -549,14 +637,14 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Failed to send cancellation email: {e}')
|
||||
return {'success': True, 'data': {'booking': booking}}
|
||||
return success_response(data={'booking': booking})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))])
|
||||
async def update_booking(id: int, booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
booking = db.query(Booking).options(
|
||||
@@ -567,11 +655,36 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
old_status = booking.status
|
||||
status_value = booking_data.get('status')
|
||||
room = booking.room
|
||||
new_status = None
|
||||
if status_value:
|
||||
try:
|
||||
new_status = BookingStatus(status_value)
|
||||
booking.status = new_status
|
||||
if new_status == BookingStatus.cancelled:
|
||||
|
||||
# Update room status based on booking status
|
||||
if new_status == BookingStatus.checked_in:
|
||||
# Set room to occupied when checked in
|
||||
if room and room.status != RoomStatus.maintenance:
|
||||
room.status = RoomStatus.occupied
|
||||
elif new_status == BookingStatus.checked_out:
|
||||
# Set room to cleaning when checked out (housekeeping needed)
|
||||
if room:
|
||||
# Check if there's active maintenance
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceStatus
|
||||
active_maintenance = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == room.id,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
|
||||
)
|
||||
).first()
|
||||
if active_maintenance:
|
||||
room.status = RoomStatus.maintenance
|
||||
else:
|
||||
room.status = RoomStatus.cleaning
|
||||
elif new_status == BookingStatus.cancelled:
|
||||
# Update room status when booking is cancelled
|
||||
if booking.payments:
|
||||
for payment in booking.payments:
|
||||
if payment.payment_status == PaymentStatus.pending:
|
||||
@@ -580,10 +693,48 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
|
||||
cancellation_note = f'\nPayment cancelled due to booking cancellation on {datetime.utcnow().isoformat()}'
|
||||
payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
|
||||
db.flush()
|
||||
|
||||
# Check if room has other active bookings
|
||||
if room:
|
||||
active_booking = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.room_id == room.id,
|
||||
Booking.id != booking.id,
|
||||
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in]),
|
||||
Booking.check_in_date <= datetime.utcnow(),
|
||||
Booking.check_out_date > datetime.utcnow()
|
||||
)
|
||||
).first()
|
||||
|
||||
if not active_booking:
|
||||
# Check for maintenance
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceStatus
|
||||
active_maintenance = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == room.id,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress])
|
||||
)
|
||||
).first()
|
||||
|
||||
if active_maintenance:
|
||||
room.status = RoomStatus.maintenance
|
||||
else:
|
||||
room.status = RoomStatus.available
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail='Invalid status')
|
||||
db.commit()
|
||||
|
||||
# Send booking confirmation notification if status changed to confirmed
|
||||
if new_status == BookingStatus.confirmed:
|
||||
try:
|
||||
from ..services.notification_service import NotificationService
|
||||
NotificationService.send_booking_confirmation(db, booking)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send booking confirmation notification: {e}')
|
||||
|
||||
# Reload booking with all relationships after commit
|
||||
booking = db.query(Booking).options(
|
||||
selectinload(Booking.payments),
|
||||
@@ -610,8 +761,7 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
|
||||
room_type_name = room.room_type.name if room and room.room_type else 'Room'
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
guest_name = booking.user.full_name if booking.user else 'Guest'
|
||||
guest_email = booking.user.email if booking.user else None
|
||||
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=guest_name, room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=booking.requires_deposit, deposit_amount=float(booking.total_price) * 0.2 if booking.requires_deposit else None, original_price=float(booking.original_price) if booking.original_price else None, discount_amount=float(booking.discount_amount) if booking.discount_amount else None, promotion_code=booking.promotion_code, client_url=client_url, currency_symbol=currency_symbol)
|
||||
@@ -673,10 +823,10 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
|
||||
'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
|
||||
}
|
||||
|
||||
response_data = {'status': 'success', 'message': 'Booking updated successfully', 'data': {'booking': booking_dict}}
|
||||
message = 'Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance.' if payment_warning else 'Booking updated successfully'
|
||||
response_data = success_response(data={'booking': booking_dict}, message=message)
|
||||
if payment_warning:
|
||||
response_data['warning'] = payment_warning
|
||||
response_data['message'] = 'Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance.'
|
||||
return response_data
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -722,7 +872,7 @@ async def check_booking_by_number(booking_number: str, db: Session=Depends(get_d
|
||||
booking_dict['payments'] = []
|
||||
payment_balance = calculate_booking_payment_balance(booking)
|
||||
booking_dict['payment_balance'] = {'total_paid': payment_balance['total_paid'], 'total_price': payment_balance['total_price'], 'remaining_balance': payment_balance['remaining_balance'], 'is_fully_paid': payment_balance['is_fully_paid'], 'payment_percentage': payment_balance['payment_percentage']}
|
||||
response_data = {'status': 'success', 'data': {'booking': booking_dict}}
|
||||
response_data = success_response(data={'booking': booking_dict})
|
||||
if payment_balance['remaining_balance'] > 0.01:
|
||||
response_data['warning'] = {'message': f'Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}', 'remaining_balance': payment_balance['remaining_balance'], 'payment_percentage': payment_balance['payment_percentage']}
|
||||
return response_data
|
||||
@@ -758,6 +908,8 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
|
||||
check_out_date = booking_data.get('check_out_date')
|
||||
total_price = booking_data.get('total_price')
|
||||
guest_count = booking_data.get('guest_count', 1)
|
||||
if guest_count < 1 or guest_count > 20:
|
||||
raise HTTPException(status_code=400, detail='Guest count must be between 1 and 20')
|
||||
notes = booking_data.get('notes')
|
||||
payment_method = booking_data.get('payment_method', 'cash')
|
||||
payment_status = booking_data.get('payment_status', 'unpaid') # 'full', 'deposit', or 'unpaid'
|
||||
@@ -793,6 +945,10 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
|
||||
else:
|
||||
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
|
||||
|
||||
# Validate dates
|
||||
if check_in >= check_out:
|
||||
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date')
|
||||
|
||||
# Check for overlapping bookings
|
||||
overlapping = db.query(Booking).filter(
|
||||
and_(
|
||||
@@ -1118,11 +1274,10 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
|
||||
'capacity': booking.room.room_type.capacity
|
||||
}
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'data': {'booking': booking_dict},
|
||||
'message': f'Booking created successfully by {current_user.full_name}'
|
||||
}
|
||||
return success_response(
|
||||
data={'booking': booking_dict},
|
||||
message=f'Booking created successfully by {current_user.full_name}'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
584
Backend/src/routes/email_campaign_routes.py
Normal file
584
Backend/src/routes/email_campaign_routes.py
Normal file
@@ -0,0 +1,584 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from typing import Optional, List, Union
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, EmailStr, field_validator
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.email_campaign import (
|
||||
Campaign, CampaignStatus, CampaignType,
|
||||
CampaignSegment, EmailTemplate, CampaignEmail, EmailStatus,
|
||||
DripSequence, DripSequenceStep, Unsubscribe
|
||||
)
|
||||
from ..services.email_campaign_service import email_campaign_service
|
||||
|
||||
router = APIRouter(prefix="/email-campaigns", tags=["Email Campaigns"])
|
||||
|
||||
# Pydantic Models
|
||||
class CampaignCreate(BaseModel):
|
||||
name: str
|
||||
subject: str
|
||||
html_content: str
|
||||
text_content: Optional[str] = None
|
||||
campaign_type: str = "newsletter"
|
||||
segment_id: Optional[Union[int, str]] = None
|
||||
scheduled_at: Optional[datetime] = None
|
||||
template_id: Optional[Union[int, str]] = None
|
||||
from_name: Optional[str] = None
|
||||
from_email: Optional[str] = None
|
||||
reply_to_email: Optional[str] = None
|
||||
track_opens: bool = True
|
||||
track_clicks: bool = True
|
||||
|
||||
@field_validator('segment_id', 'template_id', mode='before')
|
||||
@classmethod
|
||||
def parse_int_or_none(cls, v):
|
||||
if v is None or v == '' or v == 'undefined' or (isinstance(v, str) and v.strip() == ''):
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
return None
|
||||
|
||||
class CampaignUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
html_content: Optional[str] = None
|
||||
text_content: Optional[str] = None
|
||||
segment_id: Optional[Union[int, str]] = None
|
||||
scheduled_at: Optional[datetime] = None
|
||||
status: Optional[str] = None
|
||||
|
||||
@field_validator('segment_id', mode='before')
|
||||
@classmethod
|
||||
def parse_int_or_none(cls, v):
|
||||
if v is None or v == '' or v == 'undefined' or (isinstance(v, str) and v.strip() == ''):
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
return None
|
||||
|
||||
class SegmentCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
criteria: dict
|
||||
|
||||
class TemplateCreate(BaseModel):
|
||||
name: str
|
||||
subject: str
|
||||
html_content: str
|
||||
text_content: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
variables: Optional[List[str]] = None
|
||||
|
||||
class DripSequenceCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
trigger_event: Optional[str] = None
|
||||
|
||||
class DripStepCreate(BaseModel):
|
||||
subject: str
|
||||
html_content: str
|
||||
text_content: Optional[str] = None
|
||||
delay_days: int = 0
|
||||
delay_hours: int = 0
|
||||
template_id: Optional[Union[int, str]] = None
|
||||
|
||||
@field_validator('template_id', mode='before')
|
||||
@classmethod
|
||||
def parse_int_or_none(cls, v):
|
||||
if v is None or v == '' or v == 'undefined' or (isinstance(v, str) and v.strip() == ''):
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return int(v)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(v, int):
|
||||
return v
|
||||
return None
|
||||
|
||||
# Campaign Routes
|
||||
@router.get("")
|
||||
async def get_campaigns(
|
||||
status_filter: Optional[str] = Query(None, alias='status'),
|
||||
campaign_type: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all email campaigns"""
|
||||
query = db.query(Campaign)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
status_enum = CampaignStatus(status_filter)
|
||||
query = query.filter(Campaign.status == status_enum)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if campaign_type:
|
||||
try:
|
||||
type_enum = CampaignType(campaign_type)
|
||||
query = query.filter(Campaign.campaign_type == type_enum)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
campaigns = query.order_by(Campaign.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
return [{
|
||||
"id": c.id,
|
||||
"name": c.name,
|
||||
"subject": c.subject,
|
||||
"campaign_type": c.campaign_type.value,
|
||||
"status": c.status.value,
|
||||
"total_recipients": c.total_recipients,
|
||||
"total_sent": c.total_sent,
|
||||
"total_opened": c.total_opened,
|
||||
"total_clicked": c.total_clicked,
|
||||
"open_rate": float(c.open_rate) if c.open_rate else None,
|
||||
"click_rate": float(c.click_rate) if c.click_rate else None,
|
||||
"scheduled_at": c.scheduled_at.isoformat() if c.scheduled_at else None,
|
||||
"sent_at": c.sent_at.isoformat() if c.sent_at else None,
|
||||
"created_at": c.created_at.isoformat() if c.created_at else None
|
||||
} for c in campaigns]
|
||||
|
||||
# Segment Routes (must be before /{campaign_id} to avoid route conflicts)
|
||||
@router.get("/segments")
|
||||
async def get_segments(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all campaign segments"""
|
||||
try:
|
||||
segments = db.query(CampaignSegment).filter(CampaignSegment.is_active == True).all()
|
||||
return [{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"criteria": s.criteria,
|
||||
"estimated_count": s.estimated_count,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None
|
||||
} for s in segments]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error fetching segments: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch segments: {str(e)}")
|
||||
|
||||
@router.post("/segments")
|
||||
async def create_segment(
|
||||
data: SegmentCreate,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new campaign segment"""
|
||||
try:
|
||||
segment = email_campaign_service.create_segment(
|
||||
db=db,
|
||||
name=data.name,
|
||||
criteria=data.criteria,
|
||||
description=data.description,
|
||||
created_by=current_user.id
|
||||
)
|
||||
return {"status": "success", "segment_id": segment.id, "estimated_count": segment.estimated_count}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error creating segment: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create segment: {str(e)}")
|
||||
|
||||
# Template Routes (must be before /{campaign_id} to avoid route conflicts)
|
||||
@router.get("/templates")
|
||||
async def get_templates(
|
||||
category: Optional[str] = Query(None, description="Filter by template category"),
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all email templates"""
|
||||
try:
|
||||
query = db.query(EmailTemplate).filter(EmailTemplate.is_active == True)
|
||||
if category:
|
||||
query = query.filter(EmailTemplate.category == category)
|
||||
|
||||
templates = query.all()
|
||||
result = [{
|
||||
"id": t.id,
|
||||
"name": t.name,
|
||||
"subject": t.subject,
|
||||
"category": t.category,
|
||||
"variables": t.variables,
|
||||
"created_at": t.created_at.isoformat() if t.created_at else None
|
||||
} for t in templates]
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error fetching templates: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch templates: {str(e)}")
|
||||
|
||||
@router.post("/templates")
|
||||
async def create_template(
|
||||
data: TemplateCreate,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new email template"""
|
||||
try:
|
||||
template = EmailTemplate(
|
||||
name=data.name,
|
||||
subject=data.subject,
|
||||
html_content=data.html_content,
|
||||
text_content=data.text_content,
|
||||
category=data.category,
|
||||
variables=data.variables,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(template)
|
||||
db.commit()
|
||||
db.refresh(template)
|
||||
return {"status": "success", "template_id": template.id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error creating template: {str(e)}", exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create template: {str(e)}")
|
||||
|
||||
# Drip Sequence Routes (must be before /{campaign_id} to avoid route conflicts)
|
||||
@router.get("/drip-sequences")
|
||||
async def get_drip_sequences(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all drip sequences"""
|
||||
try:
|
||||
# Use eager loading to avoid lazy loading issues
|
||||
sequences = db.query(DripSequence).options(
|
||||
selectinload(DripSequence.steps)
|
||||
).filter(DripSequence.is_active == True).all()
|
||||
|
||||
return [{
|
||||
"id": s.id,
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"trigger_event": s.trigger_event,
|
||||
"step_count": len(s.steps) if s.steps else 0,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None
|
||||
} for s in sequences]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error fetching drip sequences: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch drip sequences: {str(e)}")
|
||||
|
||||
@router.post("/drip-sequences")
|
||||
async def create_drip_sequence(
|
||||
data: DripSequenceCreate,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new drip sequence"""
|
||||
try:
|
||||
sequence = email_campaign_service.create_drip_sequence(
|
||||
db=db,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
trigger_event=data.trigger_event,
|
||||
created_by=current_user.id
|
||||
)
|
||||
return {"status": "success", "sequence_id": sequence.id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error creating drip sequence: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to create drip sequence: {str(e)}")
|
||||
|
||||
@router.post("/drip-sequences/{sequence_id}/steps")
|
||||
async def add_drip_step(
|
||||
sequence_id: int,
|
||||
data: DripStepCreate,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a step to a drip sequence"""
|
||||
try:
|
||||
# Ensure template_id is integer or None
|
||||
template_id = int(data.template_id) if data.template_id is not None else None
|
||||
|
||||
step = email_campaign_service.add_drip_step(
|
||||
db=db,
|
||||
sequence_id=sequence_id,
|
||||
subject=data.subject,
|
||||
html_content=data.html_content,
|
||||
text_content=data.text_content,
|
||||
delay_days=data.delay_days,
|
||||
delay_hours=data.delay_hours,
|
||||
template_id=template_id
|
||||
)
|
||||
return {"status": "success", "step_id": step.id}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error adding drip step: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add drip step: {str(e)}")
|
||||
|
||||
@router.get("/{campaign_id}")
|
||||
async def get_campaign(
|
||||
campaign_id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific campaign"""
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
return {
|
||||
"id": campaign.id,
|
||||
"name": campaign.name,
|
||||
"subject": campaign.subject,
|
||||
"html_content": campaign.html_content,
|
||||
"text_content": campaign.text_content,
|
||||
"campaign_type": campaign.campaign_type.value,
|
||||
"status": campaign.status.value,
|
||||
"segment_id": campaign.segment_id,
|
||||
"scheduled_at": campaign.scheduled_at.isoformat() if campaign.scheduled_at else None,
|
||||
"total_recipients": campaign.total_recipients,
|
||||
"total_sent": campaign.total_sent,
|
||||
"total_delivered": campaign.total_delivered,
|
||||
"total_opened": campaign.total_opened,
|
||||
"total_clicked": campaign.total_clicked,
|
||||
"total_bounced": campaign.total_bounced,
|
||||
"open_rate": float(campaign.open_rate) if campaign.open_rate else None,
|
||||
"click_rate": float(campaign.click_rate) if campaign.click_rate else None,
|
||||
"created_at": campaign.created_at.isoformat() if campaign.created_at else None
|
||||
}
|
||||
|
||||
@router.post("")
|
||||
async def create_campaign(
|
||||
data: CampaignCreate,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new email campaign"""
|
||||
try:
|
||||
campaign_type = CampaignType(data.campaign_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid campaign type")
|
||||
|
||||
campaign = email_campaign_service.create_campaign(
|
||||
db=db,
|
||||
name=data.name,
|
||||
subject=data.subject,
|
||||
html_content=data.html_content,
|
||||
text_content=data.text_content,
|
||||
campaign_type=campaign_type,
|
||||
segment_id=data.segment_id,
|
||||
scheduled_at=data.scheduled_at,
|
||||
template_id=data.template_id,
|
||||
created_by=current_user.id,
|
||||
from_name=data.from_name,
|
||||
from_email=data.from_email,
|
||||
reply_to_email=data.reply_to_email,
|
||||
track_opens=data.track_opens,
|
||||
track_clicks=data.track_clicks
|
||||
)
|
||||
|
||||
return {"status": "success", "campaign_id": campaign.id}
|
||||
|
||||
@router.put("/{campaign_id}")
|
||||
async def update_campaign(
|
||||
campaign_id: int,
|
||||
data: CampaignUpdate,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a campaign"""
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
if data.name:
|
||||
campaign.name = data.name
|
||||
if data.subject:
|
||||
campaign.subject = data.subject
|
||||
if data.html_content:
|
||||
campaign.html_content = data.html_content
|
||||
if data.text_content is not None:
|
||||
campaign.text_content = data.text_content
|
||||
if data.segment_id is not None:
|
||||
campaign.segment_id = int(data.segment_id) if isinstance(data.segment_id, str) else data.segment_id
|
||||
if data.scheduled_at is not None:
|
||||
campaign.scheduled_at = data.scheduled_at
|
||||
if data.status:
|
||||
try:
|
||||
campaign.status = CampaignStatus(data.status)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
|
||||
return {"status": "success", "message": "Campaign updated"}
|
||||
|
||||
@router.post("/{campaign_id}/send")
|
||||
async def send_campaign(
|
||||
campaign_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Send an email campaign"""
|
||||
try:
|
||||
result = email_campaign_service.send_campaign(db=db, campaign_id=campaign_id)
|
||||
return {"status": "success", "result": result}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send campaign: {str(e)}")
|
||||
|
||||
@router.get("/{campaign_id}/analytics")
|
||||
async def get_campaign_analytics(
|
||||
campaign_id: int,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get campaign analytics"""
|
||||
campaign = db.query(Campaign).filter(Campaign.id == campaign_id).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
|
||||
# Get email status breakdown
|
||||
emails = db.query(CampaignEmail).filter(CampaignEmail.campaign_id == campaign_id).all()
|
||||
|
||||
status_breakdown = {}
|
||||
for status in EmailStatus:
|
||||
status_breakdown[status.value] = len([e for e in emails if e.status == status])
|
||||
|
||||
return {
|
||||
"campaign_id": campaign.id,
|
||||
"total_recipients": campaign.total_recipients,
|
||||
"total_sent": campaign.total_sent,
|
||||
"total_delivered": campaign.total_delivered,
|
||||
"total_opened": campaign.total_opened,
|
||||
"total_clicked": campaign.total_clicked,
|
||||
"total_bounced": campaign.total_bounced,
|
||||
"total_unsubscribed": campaign.total_unsubscribed,
|
||||
"open_rate": float(campaign.open_rate) if campaign.open_rate else 0,
|
||||
"click_rate": float(campaign.click_rate) if campaign.click_rate else 0,
|
||||
"bounce_rate": float(campaign.bounce_rate) if campaign.bounce_rate else 0,
|
||||
"status_breakdown": status_breakdown
|
||||
}
|
||||
|
||||
# Tracking Routes (public endpoints for email tracking)
|
||||
@router.get("/track/open/{campaign_email_id}")
|
||||
async def track_email_open(
|
||||
campaign_email_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Track email open (called by tracking pixel)"""
|
||||
email_campaign_service.track_email_open(db=db, campaign_email_id=campaign_email_id)
|
||||
# Return 1x1 transparent pixel (GIF)
|
||||
from fastapi.responses import Response
|
||||
# 1x1 transparent GIF
|
||||
pixel = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00\x21\xf9\x04\x01\x00\x00\x00\x00\x2c\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02\x04\x01\x00\x3b'
|
||||
return Response(content=pixel, media_type="image/gif")
|
||||
|
||||
@router.get("/track/click/{campaign_email_id}")
|
||||
async def track_email_click(
|
||||
campaign_email_id: int,
|
||||
url: str = Query(...),
|
||||
request: Request = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Track email click"""
|
||||
ip_address = request.client.host if request and request.client else None
|
||||
user_agent = request.headers.get("User-Agent") if request else None
|
||||
|
||||
email_campaign_service.track_email_click(
|
||||
db=db,
|
||||
campaign_email_id=campaign_email_id,
|
||||
url=url,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent
|
||||
)
|
||||
|
||||
# Redirect to the actual URL
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=url)
|
||||
|
||||
# Unsubscribe Routes
|
||||
@router.post("/unsubscribe")
|
||||
async def unsubscribe(
|
||||
email: EmailStr = Query(...),
|
||||
campaign_id: Optional[Union[int, str]] = Query(None),
|
||||
unsubscribe_all: bool = Query(False),
|
||||
reason: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Unsubscribe from email campaigns"""
|
||||
# Parse campaign_id if it's a string
|
||||
parsed_campaign_id = None
|
||||
if campaign_id is not None and campaign_id != '' and campaign_id != 'undefined':
|
||||
try:
|
||||
parsed_campaign_id = int(campaign_id) if isinstance(campaign_id, str) else campaign_id
|
||||
except (ValueError, TypeError):
|
||||
parsed_campaign_id = None
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
unsubscribe_record = Unsubscribe(
|
||||
email=email,
|
||||
user_id=user.id if user else None,
|
||||
campaign_id=parsed_campaign_id,
|
||||
unsubscribe_all=unsubscribe_all,
|
||||
reason=reason
|
||||
)
|
||||
db.add(unsubscribe_record)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Successfully unsubscribed"}
|
||||
|
||||
@router.post("/drip-sequences/process")
|
||||
async def process_drip_sequences(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Manually trigger drip sequence processing"""
|
||||
try:
|
||||
email_campaign_service.process_drip_sequences(db=db)
|
||||
return {"status": "success", "message": "Drip sequences processed"}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
from ..config.logging_config import get_logger
|
||||
logger = get_logger(__name__)
|
||||
logger.error(f"Error processing drip sequences: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Failed to process drip sequences: {str(e)}")
|
||||
|
||||
575
Backend/src/routes/group_booking_routes.py
Normal file
575
Backend/src/routes/group_booking_routes.py
Normal file
@@ -0,0 +1,575 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.role import Role
|
||||
from ..models.group_booking import (
|
||||
GroupBooking, GroupBookingMember, GroupRoomBlock, GroupPayment,
|
||||
GroupBookingStatus, PaymentOption
|
||||
)
|
||||
from ..models.room import Room
|
||||
from ..models.room_type import RoomType
|
||||
from ..services.group_booking_service import GroupBookingService
|
||||
from ..services.room_service import get_base_url
|
||||
from fastapi import Request
|
||||
|
||||
router = APIRouter(prefix='/group-bookings', tags=['group-bookings'])
|
||||
|
||||
|
||||
@router.post('/')
|
||||
async def create_group_booking(
|
||||
booking_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new group booking"""
|
||||
try:
|
||||
# Extract data
|
||||
coordinator_name = booking_data.get('coordinator_name') or current_user.full_name
|
||||
coordinator_email = booking_data.get('coordinator_email') or current_user.email
|
||||
coordinator_phone = booking_data.get('coordinator_phone') or current_user.phone
|
||||
|
||||
check_in_date = datetime.fromisoformat(booking_data['check_in_date'].replace('Z', '+00:00'))
|
||||
check_out_date = datetime.fromisoformat(booking_data['check_out_date'].replace('Z', '+00:00'))
|
||||
|
||||
room_blocks = booking_data.get('room_blocks', [])
|
||||
if not room_blocks:
|
||||
raise HTTPException(status_code=400, detail="At least one room block is required")
|
||||
|
||||
payment_option = PaymentOption(booking_data.get('payment_option', 'coordinator_pays_all'))
|
||||
deposit_required = booking_data.get('deposit_required', False)
|
||||
deposit_percentage = booking_data.get('deposit_percentage')
|
||||
|
||||
group_booking = GroupBookingService.create_group_booking(
|
||||
db=db,
|
||||
coordinator_id=current_user.id,
|
||||
coordinator_name=coordinator_name,
|
||||
coordinator_email=coordinator_email,
|
||||
coordinator_phone=coordinator_phone,
|
||||
check_in_date=check_in_date,
|
||||
check_out_date=check_out_date,
|
||||
room_blocks=room_blocks,
|
||||
group_name=booking_data.get('group_name'),
|
||||
group_type=booking_data.get('group_type'),
|
||||
payment_option=payment_option,
|
||||
deposit_required=deposit_required,
|
||||
deposit_percentage=deposit_percentage,
|
||||
special_requests=booking_data.get('special_requests'),
|
||||
notes=booking_data.get('notes'),
|
||||
cancellation_policy=booking_data.get('cancellation_policy'),
|
||||
cancellation_deadline=datetime.fromisoformat(booking_data['cancellation_deadline'].replace('Z', '+00:00')) if booking_data.get('cancellation_deadline') else None,
|
||||
cancellation_penalty_percentage=booking_data.get('cancellation_penalty_percentage'),
|
||||
group_discount_percentage=booking_data.get('group_discount_percentage')
|
||||
)
|
||||
|
||||
# Load relationships
|
||||
db.refresh(group_booking)
|
||||
group_booking = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.coordinator)
|
||||
).filter(GroupBooking.id == group_booking.id).first()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Group booking created successfully',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking)
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
import traceback
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error creating group booking: {str(e)}')
|
||||
logger.error(traceback.format_exc())
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def get_group_bookings(
|
||||
search: 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)
|
||||
):
|
||||
"""Get all group bookings (admin/staff only)"""
|
||||
try:
|
||||
query = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.coordinator),
|
||||
selectinload(GroupBooking.payments)
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
GroupBooking.group_booking_number.like(f'%{search}%') |
|
||||
GroupBooking.group_name.like(f'%{search}%') |
|
||||
GroupBooking.coordinator_name.like(f'%{search}%')
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(GroupBooking.status == GroupBookingStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
group_bookings = query.order_by(GroupBooking.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'group_bookings': [_serialize_group_booking(gb) for gb in group_bookings],
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error getting group bookings: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/me')
|
||||
async def get_my_group_bookings(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get group bookings for current user (as coordinator)"""
|
||||
try:
|
||||
group_bookings = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.payments)
|
||||
).filter(GroupBooking.coordinator_id == current_user.id).order_by(GroupBooking.created_at.desc()).all()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'group_bookings': [_serialize_group_booking(gb) for gb in group_bookings]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error getting my group bookings: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/{group_booking_id}')
|
||||
async def get_group_booking(
|
||||
group_booking_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific group booking"""
|
||||
try:
|
||||
group_booking = db.query(GroupBooking).options(
|
||||
selectinload(GroupBooking.room_blocks).joinedload(GroupRoomBlock.room_type),
|
||||
selectinload(GroupBooking.members),
|
||||
selectinload(GroupBooking.coordinator),
|
||||
selectinload(GroupBooking.payments),
|
||||
selectinload(GroupBooking.individual_bookings)
|
||||
).filter(GroupBooking.id == group_booking_id).first()
|
||||
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
# Check authorization
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name not in ['admin', 'staff']:
|
||||
if group_booking.coordinator_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view this group booking")
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking, detailed=True)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error getting group booking: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/members')
|
||||
async def add_member(
|
||||
group_booking_id: int,
|
||||
member_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a member to a group booking"""
|
||||
try:
|
||||
# Check authorization
|
||||
group_booking = db.query(GroupBooking).filter(GroupBooking.id == group_booking_id).first()
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name not in ['admin', 'staff']:
|
||||
if group_booking.coordinator_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add members")
|
||||
|
||||
member = GroupBookingService.add_member_to_group(
|
||||
db=db,
|
||||
group_booking_id=group_booking_id,
|
||||
full_name=member_data.get('full_name'),
|
||||
email=member_data.get('email'),
|
||||
phone=member_data.get('phone'),
|
||||
user_id=member_data.get('user_id'),
|
||||
room_block_id=member_data.get('room_block_id'),
|
||||
special_requests=member_data.get('special_requests'),
|
||||
preferences=member_data.get('preferences')
|
||||
)
|
||||
|
||||
db.refresh(member)
|
||||
member = db.query(GroupBookingMember).options(
|
||||
joinedload(GroupBookingMember.user),
|
||||
joinedload(GroupBookingMember.room_block),
|
||||
joinedload(GroupBookingMember.assigned_room)
|
||||
).filter(GroupBookingMember.id == member.id).first()
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Member added successfully',
|
||||
'data': {
|
||||
'member': _serialize_member(member)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error adding member: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/confirm')
|
||||
async def confirm_group_booking(
|
||||
group_booking_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Confirm a group booking"""
|
||||
try:
|
||||
group_booking = GroupBookingService.confirm_group_booking(db, group_booking_id)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Group booking confirmed successfully',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking)
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error confirming group booking: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/members/{member_id}/assign-room')
|
||||
async def assign_room_to_member(
|
||||
group_booking_id: int,
|
||||
member_id: int,
|
||||
assignment_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Assign a room to a group member and create individual booking"""
|
||||
try:
|
||||
room_id = assignment_data.get('room_id')
|
||||
if not room_id:
|
||||
raise HTTPException(status_code=400, detail="room_id is required")
|
||||
|
||||
booking = GroupBookingService.create_individual_booking_from_member(
|
||||
db=db,
|
||||
member_id=member_id,
|
||||
room_id=room_id
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Room assigned and booking created successfully',
|
||||
'data': {
|
||||
'booking': {
|
||||
'id': booking.id,
|
||||
'booking_number': booking.booking_number,
|
||||
'room_id': booking.room_id,
|
||||
'status': booking.status.value if hasattr(booking.status, 'value') else str(booking.status)
|
||||
}
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error assigning room: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/payments')
|
||||
async def add_payment(
|
||||
group_booking_id: int,
|
||||
payment_data: dict,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add a payment to a group booking"""
|
||||
try:
|
||||
# Check authorization
|
||||
group_booking = db.query(GroupBooking).filter(GroupBooking.id == group_booking_id).first()
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if role and role.name not in ['admin', 'staff']:
|
||||
if group_booking.coordinator_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to add payments")
|
||||
|
||||
payment = GroupBookingService.add_group_payment(
|
||||
db=db,
|
||||
group_booking_id=group_booking_id,
|
||||
amount=Decimal(str(payment_data.get('amount'))),
|
||||
payment_method=payment_data.get('payment_method'),
|
||||
payment_type=payment_data.get('payment_type', 'deposit'),
|
||||
transaction_id=payment_data.get('transaction_id'),
|
||||
paid_by_member_id=payment_data.get('paid_by_member_id'),
|
||||
paid_by_user_id=payment_data.get('paid_by_user_id', current_user.id),
|
||||
notes=payment_data.get('notes')
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Payment added successfully',
|
||||
'data': {
|
||||
'payment': _serialize_payment(payment)
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error adding payment: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post('/{group_booking_id}/cancel')
|
||||
async def cancel_group_booking(
|
||||
group_booking_id: int,
|
||||
cancellation_data: dict,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cancel a group booking"""
|
||||
try:
|
||||
group_booking = GroupBookingService.cancel_group_booking(
|
||||
db=db,
|
||||
group_booking_id=group_booking_id,
|
||||
cancellation_reason=cancellation_data.get('reason')
|
||||
)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'Group booking cancelled successfully',
|
||||
'data': {
|
||||
'group_booking': _serialize_group_booking(group_booking)
|
||||
}
|
||||
}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error cancelling group booking: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get('/{group_booking_id}/availability')
|
||||
async def check_availability(
|
||||
group_booking_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Check room availability for a group booking"""
|
||||
try:
|
||||
group_booking = db.query(GroupBooking).filter(GroupBooking.id == group_booking_id).first()
|
||||
if not group_booking:
|
||||
raise HTTPException(status_code=404, detail="Group booking not found")
|
||||
|
||||
availability_results = []
|
||||
room_blocks = db.query(GroupRoomBlock).filter(
|
||||
GroupRoomBlock.group_booking_id == group_booking_id
|
||||
).all()
|
||||
|
||||
for room_block in room_blocks:
|
||||
availability = GroupBookingService.check_room_availability(
|
||||
db=db,
|
||||
room_type_id=room_block.room_type_id,
|
||||
check_in=group_booking.check_in_date,
|
||||
check_out=group_booking.check_out_date,
|
||||
num_rooms=room_block.rooms_blocked
|
||||
)
|
||||
|
||||
availability_results.append({
|
||||
'room_block_id': room_block.id,
|
||||
'room_type_id': room_block.room_type_id,
|
||||
'rooms_blocked': room_block.rooms_blocked,
|
||||
'availability': availability
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'availability': availability_results
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Error checking availability: {str(e)}')
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# Helper functions for serialization
|
||||
def _serialize_group_booking(group_booking: GroupBooking, detailed: bool = False) -> dict:
|
||||
"""Serialize group booking to dict"""
|
||||
data = {
|
||||
'id': group_booking.id,
|
||||
'group_booking_number': group_booking.group_booking_number,
|
||||
'coordinator': {
|
||||
'id': group_booking.coordinator_id,
|
||||
'name': group_booking.coordinator_name,
|
||||
'email': group_booking.coordinator_email,
|
||||
'phone': group_booking.coordinator_phone
|
||||
},
|
||||
'group_name': group_booking.group_name,
|
||||
'group_type': group_booking.group_type,
|
||||
'total_rooms': group_booking.total_rooms,
|
||||
'total_guests': group_booking.total_guests,
|
||||
'check_in_date': group_booking.check_in_date.isoformat() if group_booking.check_in_date else None,
|
||||
'check_out_date': group_booking.check_out_date.isoformat() if group_booking.check_out_date else None,
|
||||
'base_rate_per_room': float(group_booking.base_rate_per_room) if group_booking.base_rate_per_room else 0.0,
|
||||
'group_discount_percentage': float(group_booking.group_discount_percentage) if group_booking.group_discount_percentage else 0.0,
|
||||
'group_discount_amount': float(group_booking.group_discount_amount) if group_booking.group_discount_amount else 0.0,
|
||||
'original_total_price': float(group_booking.original_total_price) if group_booking.original_total_price else 0.0,
|
||||
'discount_amount': float(group_booking.discount_amount) if group_booking.discount_amount else 0.0,
|
||||
'total_price': float(group_booking.total_price) if group_booking.total_price else 0.0,
|
||||
'payment_option': group_booking.payment_option.value if hasattr(group_booking.payment_option, 'value') else str(group_booking.payment_option),
|
||||
'deposit_required': group_booking.deposit_required,
|
||||
'deposit_percentage': group_booking.deposit_percentage,
|
||||
'deposit_amount': float(group_booking.deposit_amount) if group_booking.deposit_amount else None,
|
||||
'amount_paid': float(group_booking.amount_paid) if group_booking.amount_paid else 0.0,
|
||||
'balance_due': float(group_booking.balance_due) if group_booking.balance_due else 0.0,
|
||||
'status': group_booking.status.value if hasattr(group_booking.status, 'value') else str(group_booking.status),
|
||||
'special_requests': group_booking.special_requests,
|
||||
'notes': group_booking.notes,
|
||||
'created_at': group_booking.created_at.isoformat() if group_booking.created_at else None,
|
||||
'updated_at': group_booking.updated_at.isoformat() if group_booking.updated_at else None
|
||||
}
|
||||
|
||||
if detailed:
|
||||
data['room_blocks'] = [_serialize_room_block(rb) for rb in group_booking.room_blocks] if group_booking.room_blocks else []
|
||||
data['members'] = [_serialize_member(m) for m in group_booking.members] if group_booking.members else []
|
||||
data['payments'] = [_serialize_payment(p) for p in group_booking.payments] if group_booking.payments else []
|
||||
data['cancellation_policy'] = group_booking.cancellation_policy
|
||||
data['cancellation_deadline'] = group_booking.cancellation_deadline.isoformat() if group_booking.cancellation_deadline else None
|
||||
data['cancellation_penalty_percentage'] = float(group_booking.cancellation_penalty_percentage) if group_booking.cancellation_penalty_percentage else None
|
||||
data['confirmed_at'] = group_booking.confirmed_at.isoformat() if group_booking.confirmed_at else None
|
||||
data['cancelled_at'] = group_booking.cancelled_at.isoformat() if group_booking.cancelled_at else None
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _serialize_room_block(room_block: GroupRoomBlock) -> dict:
|
||||
"""Serialize room block to dict"""
|
||||
return {
|
||||
'id': room_block.id,
|
||||
'room_type_id': room_block.room_type_id,
|
||||
'room_type': {
|
||||
'id': room_block.room_type.id,
|
||||
'name': room_block.room_type.name,
|
||||
'base_price': float(room_block.room_type.base_price) if room_block.room_type.base_price else 0.0
|
||||
} if room_block.room_type else None,
|
||||
'rooms_blocked': room_block.rooms_blocked,
|
||||
'rooms_confirmed': room_block.rooms_confirmed,
|
||||
'rooms_available': room_block.rooms_available,
|
||||
'rate_per_room': float(room_block.rate_per_room) if room_block.rate_per_room else 0.0,
|
||||
'total_block_price': float(room_block.total_block_price) if room_block.total_block_price else 0.0,
|
||||
'is_active': room_block.is_active,
|
||||
'block_released_at': room_block.block_released_at.isoformat() if room_block.block_released_at else None
|
||||
}
|
||||
|
||||
|
||||
def _serialize_member(member: GroupBookingMember) -> dict:
|
||||
"""Serialize group member to dict"""
|
||||
return {
|
||||
'id': member.id,
|
||||
'full_name': member.full_name,
|
||||
'email': member.email,
|
||||
'phone': member.phone,
|
||||
'user_id': member.user_id,
|
||||
'room_block_id': member.room_block_id,
|
||||
'assigned_room_id': member.assigned_room_id,
|
||||
'individual_booking_id': member.individual_booking_id,
|
||||
'special_requests': member.special_requests,
|
||||
'preferences': member.preferences,
|
||||
'individual_amount': float(member.individual_amount) if member.individual_amount else None,
|
||||
'individual_paid': float(member.individual_paid) if member.individual_paid else 0.0,
|
||||
'individual_balance': float(member.individual_balance) if member.individual_balance else 0.0,
|
||||
'is_checked_in': member.is_checked_in,
|
||||
'checked_in_at': member.checked_in_at.isoformat() if member.checked_in_at else None,
|
||||
'is_checked_out': member.is_checked_out,
|
||||
'checked_out_at': member.checked_out_at.isoformat() if member.checked_out_at else None
|
||||
}
|
||||
|
||||
|
||||
def _serialize_payment(payment: GroupPayment) -> dict:
|
||||
"""Serialize group payment to dict"""
|
||||
return {
|
||||
'id': payment.id,
|
||||
'amount': float(payment.amount) if payment.amount else 0.0,
|
||||
'payment_method': payment.payment_method,
|
||||
'payment_type': payment.payment_type,
|
||||
'payment_status': payment.payment_status,
|
||||
'transaction_id': payment.transaction_id,
|
||||
'payment_date': payment.payment_date.isoformat() if payment.payment_date else None,
|
||||
'notes': payment.notes,
|
||||
'paid_by_member_id': payment.paid_by_member_id,
|
||||
'paid_by_user_id': payment.paid_by_user_id,
|
||||
'created_at': payment.created_at.isoformat() if payment.created_at else None
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ from ..models.guest_tag import GuestTag
|
||||
from ..models.guest_communication import GuestCommunication, CommunicationType, CommunicationDirection
|
||||
from ..models.guest_segment import GuestSegment
|
||||
from ..services.guest_profile_service import GuestProfileService
|
||||
from ..utils.role_helpers import is_customer
|
||||
import json
|
||||
|
||||
router = APIRouter(prefix='/guest-profiles', tags=['guest-profiles'])
|
||||
@@ -88,8 +89,9 @@ async def get_guest_profile(
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail=f'User with ID {user_id} not found')
|
||||
|
||||
# Check if user is a customer (role_id == 3)
|
||||
if user.role_id != 3:
|
||||
# Check if user is a customer
|
||||
from ..utils.role_helpers import is_customer
|
||||
if not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail=f'User with ID {user_id} is not a guest (customer)')
|
||||
|
||||
# Get analytics
|
||||
@@ -189,8 +191,8 @@ async def update_guest_preferences(
|
||||
):
|
||||
"""Update guest preferences"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
preferences = db.query(GuestPreference).filter(GuestPreference.user_id == user_id).first()
|
||||
@@ -240,8 +242,8 @@ async def create_guest_note(
|
||||
):
|
||||
"""Create a note for a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
note = GuestNote(
|
||||
@@ -302,8 +304,8 @@ async def toggle_vip_status(
|
||||
):
|
||||
"""Toggle VIP status for a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
user.is_vip = vip_data.get('is_vip', False)
|
||||
@@ -327,8 +329,8 @@ async def add_tag_to_guest(
|
||||
):
|
||||
"""Add a tag to a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
tag_id = tag_data.get('tag_id')
|
||||
@@ -357,8 +359,8 @@ async def remove_tag_from_guest(
|
||||
):
|
||||
"""Remove a tag from a guest"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first()
|
||||
@@ -386,8 +388,8 @@ async def create_communication(
|
||||
):
|
||||
"""Create a communication record"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
comm = GuestCommunication(
|
||||
@@ -420,8 +422,8 @@ async def get_guest_analytics(
|
||||
):
|
||||
"""Get guest analytics"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
analytics = GuestProfileService.get_guest_analytics(user_id, db)
|
||||
@@ -441,8 +443,8 @@ async def update_guest_metrics(
|
||||
):
|
||||
"""Update guest metrics (lifetime value, satisfaction score, etc.)"""
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first()
|
||||
if not user:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not is_customer(user, db):
|
||||
raise HTTPException(status_code=404, detail='Guest not found')
|
||||
|
||||
metrics = GuestProfileService.update_guest_metrics(user_id, db)
|
||||
|
||||
@@ -8,14 +8,16 @@ from ..models.user import User
|
||||
from ..models.invoice import Invoice, InvoiceStatus
|
||||
from ..models.booking import Booking
|
||||
from ..services.invoice_service import InvoiceService
|
||||
from ..utils.role_helpers import can_access_all_invoices, can_create_invoices
|
||||
from ..utils.response_helpers import success_response
|
||||
router = APIRouter(prefix='/invoices', tags=['invoices'])
|
||||
|
||||
@router.get('/')
|
||||
async def get_invoices(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:
|
||||
user_id = None if current_user.role_id in [1, 4] else current_user.id # admin and accountant can see all invoices
|
||||
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 {'status': 'success', 'data': result}
|
||||
return success_response(data=result)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -25,9 +27,9 @@ 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 current_user.role_id not in [1, 4] and invoice['user_id'] != current_user.id: # admin and accountant can see all invoices
|
||||
if not can_access_all_invoices(current_user, db) and invoice['user_id'] != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
return {'status': 'success', 'data': {'invoice': invoice}}
|
||||
return success_response(data={'invoice': invoice})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -36,7 +38,7 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user
|
||||
@router.post('/')
|
||||
async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
if current_user.role_id not in [1, 2, 4]: # admin, staff, and accountant can create invoices
|
||||
if not can_create_invoices(current_user, db):
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
booking_id = invoice_data.get('booking_id')
|
||||
if not booking_id:
|
||||
@@ -55,7 +57,7 @@ async def create_invoice(invoice_data: dict, current_user: User=Depends(get_curr
|
||||
invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note
|
||||
invoice_kwargs['notes'] = invoice_notes
|
||||
invoice = InvoiceService.create_invoice_from_booking(booking_id=booking_id, db=db, created_by_id=current_user.id, tax_rate=invoice_data.get('tax_rate', 0.0), discount_amount=invoice_data.get('discount_amount', 0.0), due_days=invoice_data.get('due_days', 30), **invoice_kwargs)
|
||||
return {'status': 'success', 'message': 'Invoice created successfully', 'data': {'invoice': invoice}}
|
||||
return success_response(data={'invoice': invoice}, message='Invoice created successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
@@ -70,7 +72,7 @@ async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends
|
||||
if not invoice:
|
||||
raise HTTPException(status_code=404, detail='Invoice not found')
|
||||
updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, **invoice_data)
|
||||
return {'status': 'success', 'message': 'Invoice updated successfully', 'data': {'invoice': updated_invoice}}
|
||||
return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
@@ -83,7 +85,7 @@ async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=D
|
||||
try:
|
||||
amount = payment_data.get('amount')
|
||||
updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id)
|
||||
return {'status': 'success', 'message': 'Invoice marked as paid successfully', 'data': {'invoice': updated_invoice}}
|
||||
return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
@@ -99,7 +101,7 @@ async def delete_invoice(id: int, current_user: User=Depends(authorize_roles('ad
|
||||
raise HTTPException(status_code=404, detail='Invoice not found')
|
||||
db.delete(invoice)
|
||||
db.commit()
|
||||
return {'status': 'success', 'message': 'Invoice deleted successfully'}
|
||||
return success_response(message='Invoice deleted successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -112,10 +114,10 @@ async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(ge
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if current_user.role_id not in [1, 4] and booking.user_id != current_user.id: # admin and accountant can see all invoices
|
||||
if not can_access_all_invoices(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
result = InvoiceService.get_invoices(db=db, booking_id=booking_id)
|
||||
return {'status': 'success', 'data': result}
|
||||
return success_response(data=result)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
306
Backend/src/routes/notification_routes.py
Normal file
306
Backend/src/routes/notification_routes.py
Normal file
@@ -0,0 +1,306 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import authorize_roles, get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.notification import NotificationChannel, NotificationStatus, NotificationType
|
||||
from ..services.notification_service import NotificationService
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix='/notifications', tags=['notifications'])
|
||||
|
||||
# Request/Response Models
|
||||
class NotificationSendRequest(BaseModel):
|
||||
user_id: Optional[int] = None
|
||||
notification_type: str
|
||||
channel: str
|
||||
content: str
|
||||
subject: Optional[str] = None
|
||||
template_id: Optional[int] = None
|
||||
priority: Optional[str] = 'normal'
|
||||
scheduled_at: Optional[str] = None
|
||||
booking_id: Optional[int] = None
|
||||
payment_id: Optional[int] = None
|
||||
meta_data: Optional[Dict[str, Any]] = None
|
||||
|
||||
class TemplateCreateRequest(BaseModel):
|
||||
name: str
|
||||
notification_type: str
|
||||
channel: str
|
||||
content: str
|
||||
subject: Optional[str] = None
|
||||
variables: Optional[List[str]] = None
|
||||
|
||||
class PreferencesUpdateRequest(BaseModel):
|
||||
email_enabled: Optional[bool] = None
|
||||
sms_enabled: Optional[bool] = None
|
||||
push_enabled: Optional[bool] = None
|
||||
whatsapp_enabled: Optional[bool] = None
|
||||
in_app_enabled: Optional[bool] = None
|
||||
booking_confirmation_email: Optional[bool] = None
|
||||
booking_confirmation_sms: Optional[bool] = None
|
||||
payment_receipt_email: Optional[bool] = None
|
||||
payment_receipt_sms: Optional[bool] = None
|
||||
pre_arrival_reminder_email: Optional[bool] = None
|
||||
pre_arrival_reminder_sms: Optional[bool] = None
|
||||
check_in_reminder_email: Optional[bool] = None
|
||||
check_in_reminder_sms: Optional[bool] = None
|
||||
check_out_reminder_email: Optional[bool] = None
|
||||
check_out_reminder_sms: Optional[bool] = None
|
||||
marketing_campaign_email: Optional[bool] = None
|
||||
marketing_campaign_sms: Optional[bool] = None
|
||||
loyalty_update_email: Optional[bool] = None
|
||||
loyalty_update_sms: Optional[bool] = None
|
||||
system_alert_email: Optional[bool] = None
|
||||
system_alert_push: Optional[bool] = None
|
||||
|
||||
# Notifications
|
||||
@router.get('/')
|
||||
async def get_notifications(
|
||||
user_id: Optional[int] = Query(None),
|
||||
notification_type: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get notifications"""
|
||||
try:
|
||||
notifications = NotificationService.get_notifications(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
notification_type=NotificationType(notification_type) if notification_type else None,
|
||||
channel=NotificationChannel(channel) if channel else None,
|
||||
status=NotificationStatus(status) if status else None,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': n.id,
|
||||
'user_id': n.user_id,
|
||||
'notification_type': n.notification_type.value,
|
||||
'channel': n.channel.value,
|
||||
'subject': n.subject,
|
||||
'content': n.content,
|
||||
'status': n.status.value,
|
||||
'priority': n.priority,
|
||||
'sent_at': n.sent_at.isoformat() if n.sent_at else None,
|
||||
'delivered_at': n.delivered_at.isoformat() if n.delivered_at else None,
|
||||
'read_at': n.read_at.isoformat() if n.read_at else None,
|
||||
'created_at': n.created_at.isoformat(),
|
||||
} for n in notifications]}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/my-notifications')
|
||||
async def get_my_notifications(
|
||||
status: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user's notifications"""
|
||||
try:
|
||||
notifications = NotificationService.get_notifications(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
status=NotificationStatus(status) if status else None,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': n.id,
|
||||
'notification_type': n.notification_type.value,
|
||||
'channel': n.channel.value,
|
||||
'subject': n.subject,
|
||||
'content': n.content,
|
||||
'status': n.status.value,
|
||||
'read_at': n.read_at.isoformat() if n.read_at else None,
|
||||
'created_at': n.created_at.isoformat(),
|
||||
} for n in notifications]}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/send')
|
||||
async def send_notification(
|
||||
notification_data: NotificationSendRequest,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Send notification"""
|
||||
try:
|
||||
scheduled_at = None
|
||||
if notification_data.scheduled_at:
|
||||
scheduled_at = datetime.fromisoformat(notification_data.scheduled_at.replace('Z', '+00:00'))
|
||||
|
||||
notification = NotificationService.send_notification(
|
||||
db=db,
|
||||
user_id=notification_data.user_id,
|
||||
notification_type=NotificationType(notification_data.notification_type),
|
||||
channel=NotificationChannel(notification_data.channel),
|
||||
content=notification_data.content,
|
||||
subject=notification_data.subject,
|
||||
template_id=notification_data.template_id,
|
||||
priority=notification_data.priority or 'normal',
|
||||
scheduled_at=scheduled_at,
|
||||
booking_id=notification_data.booking_id,
|
||||
payment_id=notification_data.payment_id,
|
||||
meta_data=notification_data.meta_data
|
||||
)
|
||||
return {'status': 'success', 'data': {
|
||||
'id': notification.id,
|
||||
'status': notification.status.value,
|
||||
'created_at': notification.created_at.isoformat()
|
||||
}}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{notification_id}/read')
|
||||
async def mark_notification_read(
|
||||
notification_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Mark notification as read"""
|
||||
try:
|
||||
notification = NotificationService.mark_as_read(db, notification_id, current_user.id)
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail='Notification not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': notification.id,
|
||||
'status': notification.status.value,
|
||||
'read_at': notification.read_at.isoformat() if notification.read_at else None
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Templates
|
||||
@router.post('/templates')
|
||||
async def create_template(
|
||||
template_data: TemplateCreateRequest,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create notification template"""
|
||||
try:
|
||||
template = NotificationService.create_template(
|
||||
db=db,
|
||||
name=template_data.name,
|
||||
notification_type=NotificationType(template_data.notification_type),
|
||||
channel=NotificationChannel(template_data.channel),
|
||||
content=template_data.content,
|
||||
created_by=current_user.id,
|
||||
subject=template_data.subject,
|
||||
variables=template_data.variables
|
||||
)
|
||||
return {'status': 'success', 'data': {
|
||||
'id': template.id,
|
||||
'name': template.name,
|
||||
'created_at': template.created_at.isoformat()
|
||||
}}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/templates')
|
||||
async def get_templates(
|
||||
notification_type: Optional[str] = Query(None),
|
||||
channel: Optional[str] = Query(None),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get notification templates"""
|
||||
try:
|
||||
from ..models.notification import NotificationTemplate
|
||||
query = db.query(NotificationTemplate)
|
||||
|
||||
if notification_type:
|
||||
query = query.filter(NotificationTemplate.notification_type == NotificationType(notification_type))
|
||||
if channel:
|
||||
query = query.filter(NotificationTemplate.channel == NotificationChannel(channel))
|
||||
|
||||
templates = query.filter(NotificationTemplate.is_active == True).all()
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': t.id,
|
||||
'name': t.name,
|
||||
'notification_type': t.notification_type.value,
|
||||
'channel': t.channel.value,
|
||||
'subject': t.subject,
|
||||
'content': t.content,
|
||||
'variables': t.variables,
|
||||
} for t in templates]}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Preferences
|
||||
@router.get('/preferences')
|
||||
async def get_preferences(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get user notification preferences"""
|
||||
try:
|
||||
preferences = NotificationService.get_user_preferences(db, current_user.id)
|
||||
return {'status': 'success', 'data': {
|
||||
'email_enabled': preferences.email_enabled,
|
||||
'sms_enabled': preferences.sms_enabled,
|
||||
'push_enabled': preferences.push_enabled,
|
||||
'whatsapp_enabled': preferences.whatsapp_enabled,
|
||||
'in_app_enabled': preferences.in_app_enabled,
|
||||
'booking_confirmation_email': preferences.booking_confirmation_email,
|
||||
'booking_confirmation_sms': preferences.booking_confirmation_sms,
|
||||
'payment_receipt_email': preferences.payment_receipt_email,
|
||||
'payment_receipt_sms': preferences.payment_receipt_sms,
|
||||
'pre_arrival_reminder_email': preferences.pre_arrival_reminder_email,
|
||||
'pre_arrival_reminder_sms': preferences.pre_arrival_reminder_sms,
|
||||
'check_in_reminder_email': preferences.check_in_reminder_email,
|
||||
'check_in_reminder_sms': preferences.check_in_reminder_sms,
|
||||
'check_out_reminder_email': preferences.check_out_reminder_email,
|
||||
'check_out_reminder_sms': preferences.check_out_reminder_sms,
|
||||
'marketing_campaign_email': preferences.marketing_campaign_email,
|
||||
'marketing_campaign_sms': preferences.marketing_campaign_sms,
|
||||
'loyalty_update_email': preferences.loyalty_update_email,
|
||||
'loyalty_update_sms': preferences.loyalty_update_sms,
|
||||
'system_alert_email': preferences.system_alert_email,
|
||||
'system_alert_push': preferences.system_alert_push,
|
||||
}}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/preferences')
|
||||
async def update_preferences(
|
||||
preferences_data: PreferencesUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update user notification preferences"""
|
||||
try:
|
||||
prefs_dict = preferences_data.dict(exclude_unset=True)
|
||||
preferences = NotificationService.update_user_preferences(db, current_user.id, prefs_dict)
|
||||
return {'status': 'success', 'data': {
|
||||
'email_enabled': preferences.email_enabled,
|
||||
'sms_enabled': preferences.sms_enabled,
|
||||
'push_enabled': preferences.push_enabled,
|
||||
'whatsapp_enabled': preferences.whatsapp_enabled,
|
||||
'in_app_enabled': preferences.in_app_enabled,
|
||||
}}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
435
Backend/src/routes/package_routes.py
Normal file
435
Backend/src/routes/package_routes.py
Normal file
@@ -0,0 +1,435 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, and_
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.package import Package, PackageItem, PackageStatus, PackageItemType
|
||||
from ..models.rate_plan import RatePlan
|
||||
from ..models.room_type import RoomType
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix='/packages', tags=['packages'])
|
||||
|
||||
# Pydantic models
|
||||
class PackageItemCreate(BaseModel):
|
||||
item_type: str
|
||||
item_id: Optional[int] = None
|
||||
item_name: str
|
||||
item_description: Optional[str] = None
|
||||
quantity: int = 1
|
||||
unit: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
included: bool = True
|
||||
price_modifier: Optional[float] = None
|
||||
display_order: int = 0
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
class PackageCreate(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
status: str = 'active'
|
||||
base_price: Optional[float] = None
|
||||
price_modifier: float = 1.0
|
||||
discount_percentage: Optional[float] = None
|
||||
room_type_id: Optional[int] = None
|
||||
min_nights: Optional[int] = None
|
||||
max_nights: Optional[int] = None
|
||||
valid_from: Optional[str] = None
|
||||
valid_to: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
highlights: Optional[List[str]] = None
|
||||
terms_conditions: Optional[str] = None
|
||||
extra_data: Optional[dict] = None
|
||||
items: Optional[List[PackageItemCreate]] = []
|
||||
|
||||
class PackageUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
base_price: Optional[float] = None
|
||||
price_modifier: Optional[float] = None
|
||||
discount_percentage: Optional[float] = None
|
||||
room_type_id: Optional[int] = None
|
||||
min_nights: Optional[int] = None
|
||||
max_nights: Optional[int] = None
|
||||
valid_from: Optional[str] = None
|
||||
valid_to: Optional[str] = None
|
||||
image_url: Optional[str] = None
|
||||
highlights: Optional[List[str]] = None
|
||||
terms_conditions: Optional[str] = None
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
@router.get('/')
|
||||
async def get_packages(
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias='status'),
|
||||
room_type_id: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
query = db.query(Package)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Package.name.like(f'%{search}%'),
|
||||
Package.code.like(f'%{search}%'),
|
||||
Package.description.like(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(Package.status == PackageStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if room_type_id:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Package.room_type_id == room_type_id,
|
||||
Package.room_type_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
packages = query.order_by(Package.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for pkg in packages:
|
||||
pkg_dict = {
|
||||
'id': pkg.id,
|
||||
'name': pkg.name,
|
||||
'code': pkg.code,
|
||||
'description': pkg.description,
|
||||
'status': pkg.status.value if isinstance(pkg.status, PackageStatus) else pkg.status,
|
||||
'base_price': float(pkg.base_price) if pkg.base_price else None,
|
||||
'price_modifier': float(pkg.price_modifier) if pkg.price_modifier else 1.0,
|
||||
'discount_percentage': float(pkg.discount_percentage) if pkg.discount_percentage else None,
|
||||
'room_type_id': pkg.room_type_id,
|
||||
'room_type_name': pkg.room_type.name if pkg.room_type else None,
|
||||
'min_nights': pkg.min_nights,
|
||||
'max_nights': pkg.max_nights,
|
||||
'valid_from': pkg.valid_from.isoformat() if pkg.valid_from else None,
|
||||
'valid_to': pkg.valid_to.isoformat() if pkg.valid_to else None,
|
||||
'image_url': pkg.image_url,
|
||||
'highlights': pkg.highlights,
|
||||
'terms_conditions': pkg.terms_conditions,
|
||||
'extra_data': pkg.extra_data,
|
||||
'created_at': pkg.created_at.isoformat() if pkg.created_at else None,
|
||||
'updated_at': pkg.updated_at.isoformat() if pkg.updated_at else None,
|
||||
}
|
||||
result.append(pkg_dict)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'packages': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/{id}')
|
||||
async def get_package(id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
pkg = db.query(Package).filter(Package.id == id).first()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail='Package not found')
|
||||
|
||||
items = db.query(PackageItem).filter(PackageItem.package_id == id).order_by(PackageItem.display_order.asc()).all()
|
||||
|
||||
pkg_dict = {
|
||||
'id': pkg.id,
|
||||
'name': pkg.name,
|
||||
'code': pkg.code,
|
||||
'description': pkg.description,
|
||||
'status': pkg.status.value if isinstance(pkg.status, PackageStatus) else pkg.status,
|
||||
'base_price': float(pkg.base_price) if pkg.base_price else None,
|
||||
'price_modifier': float(pkg.price_modifier) if pkg.price_modifier else 1.0,
|
||||
'discount_percentage': float(pkg.discount_percentage) if pkg.discount_percentage else None,
|
||||
'room_type_id': pkg.room_type_id,
|
||||
'room_type_name': pkg.room_type.name if pkg.room_type else None,
|
||||
'min_nights': pkg.min_nights,
|
||||
'max_nights': pkg.max_nights,
|
||||
'valid_from': pkg.valid_from.isoformat() if pkg.valid_from else None,
|
||||
'valid_to': pkg.valid_to.isoformat() if pkg.valid_to else None,
|
||||
'image_url': pkg.image_url,
|
||||
'highlights': pkg.highlights,
|
||||
'terms_conditions': pkg.terms_conditions,
|
||||
'extra_data': pkg.extra_data,
|
||||
'items': [
|
||||
{
|
||||
'id': item.id,
|
||||
'item_type': item.item_type.value if isinstance(item.item_type, PackageItemType) else item.item_type,
|
||||
'item_id': item.item_id,
|
||||
'item_name': item.item_name,
|
||||
'item_description': item.item_description,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'price': float(item.price) if item.price else None,
|
||||
'included': item.included,
|
||||
'price_modifier': float(item.price_modifier) if item.price_modifier else None,
|
||||
'display_order': item.display_order,
|
||||
'extra_data': item.extra_data,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
'created_at': pkg.created_at.isoformat() if pkg.created_at else None,
|
||||
'updated_at': pkg.updated_at.isoformat() if pkg.updated_at else None,
|
||||
}
|
||||
|
||||
return {'status': 'success', 'data': {'package': pkg_dict}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def create_package(package_data: PackageCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
try:
|
||||
# Check if code already exists
|
||||
existing = db.query(Package).filter(Package.code == package_data.code).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail='Package code already exists')
|
||||
|
||||
# Validate room_type_id if provided
|
||||
if package_data.room_type_id:
|
||||
room_type = db.query(RoomType).filter(RoomType.id == package_data.room_type_id).first()
|
||||
if not room_type:
|
||||
raise HTTPException(status_code=404, detail='Room type not found')
|
||||
|
||||
# Create package
|
||||
pkg = Package(
|
||||
name=package_data.name,
|
||||
code=package_data.code,
|
||||
description=package_data.description,
|
||||
status=PackageStatus(package_data.status),
|
||||
base_price=Decimal(str(package_data.base_price)) if package_data.base_price else None,
|
||||
price_modifier=Decimal(str(package_data.price_modifier)),
|
||||
discount_percentage=Decimal(str(package_data.discount_percentage)) if package_data.discount_percentage else None,
|
||||
room_type_id=package_data.room_type_id,
|
||||
min_nights=package_data.min_nights,
|
||||
max_nights=package_data.max_nights,
|
||||
valid_from=datetime.strptime(package_data.valid_from, '%Y-%m-%d').date() if package_data.valid_from else None,
|
||||
valid_to=datetime.strptime(package_data.valid_to, '%Y-%m-%d').date() if package_data.valid_to else None,
|
||||
image_url=package_data.image_url,
|
||||
highlights=package_data.highlights,
|
||||
terms_conditions=package_data.terms_conditions,
|
||||
extra_data=package_data.extra_data,
|
||||
)
|
||||
|
||||
db.add(pkg)
|
||||
db.flush()
|
||||
|
||||
# Create items
|
||||
if package_data.items:
|
||||
for item_data in package_data.items:
|
||||
item = PackageItem(
|
||||
package_id=pkg.id,
|
||||
item_type=PackageItemType(item_data.item_type),
|
||||
item_id=item_data.item_id,
|
||||
item_name=item_data.item_name,
|
||||
item_description=item_data.item_description,
|
||||
quantity=item_data.quantity,
|
||||
unit=item_data.unit,
|
||||
price=Decimal(str(item_data.price)) if item_data.price else None,
|
||||
included=item_data.included,
|
||||
price_modifier=Decimal(str(item_data.price_modifier)) if item_data.price_modifier else None,
|
||||
display_order=item_data.display_order,
|
||||
extra_data=item_data.extra_data,
|
||||
)
|
||||
db.add(item)
|
||||
|
||||
db.commit()
|
||||
db.refresh(pkg)
|
||||
|
||||
return {'status': 'success', 'message': 'Package created successfully', 'data': {'package_id': pkg.id}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def update_package(id: int, package_data: PackageUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
try:
|
||||
pkg = db.query(Package).filter(Package.id == id).first()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail='Package not found')
|
||||
|
||||
# Update fields
|
||||
if package_data.name is not None:
|
||||
pkg.name = package_data.name
|
||||
if package_data.description is not None:
|
||||
pkg.description = package_data.description
|
||||
if package_data.status is not None:
|
||||
pkg.status = PackageStatus(package_data.status)
|
||||
if package_data.base_price is not None:
|
||||
pkg.base_price = Decimal(str(package_data.base_price)) if package_data.base_price else None
|
||||
if package_data.price_modifier is not None:
|
||||
pkg.price_modifier = Decimal(str(package_data.price_modifier))
|
||||
if package_data.discount_percentage is not None:
|
||||
pkg.discount_percentage = Decimal(str(package_data.discount_percentage)) if package_data.discount_percentage else None
|
||||
if package_data.room_type_id is not None:
|
||||
if package_data.room_type_id:
|
||||
room_type = db.query(RoomType).filter(RoomType.id == package_data.room_type_id).first()
|
||||
if not room_type:
|
||||
raise HTTPException(status_code=404, detail='Room type not found')
|
||||
pkg.room_type_id = package_data.room_type_id
|
||||
if package_data.min_nights is not None:
|
||||
pkg.min_nights = package_data.min_nights
|
||||
if package_data.max_nights is not None:
|
||||
pkg.max_nights = package_data.max_nights
|
||||
if package_data.valid_from is not None:
|
||||
pkg.valid_from = datetime.strptime(package_data.valid_from, '%Y-%m-%d').date() if package_data.valid_from else None
|
||||
if package_data.valid_to is not None:
|
||||
pkg.valid_to = datetime.strptime(package_data.valid_to, '%Y-%m-%d').date() if package_data.valid_to else None
|
||||
if package_data.image_url is not None:
|
||||
pkg.image_url = package_data.image_url
|
||||
if package_data.highlights is not None:
|
||||
pkg.highlights = package_data.highlights
|
||||
if package_data.terms_conditions is not None:
|
||||
pkg.terms_conditions = package_data.terms_conditions
|
||||
if package_data.extra_data is not None:
|
||||
pkg.extra_data = package_data.extra_data
|
||||
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Package updated successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def delete_package(id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
try:
|
||||
pkg = db.query(Package).filter(Package.id == id).first()
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail='Package not found')
|
||||
|
||||
# Check if package is used in rate plans
|
||||
rate_plan_count = db.query(RatePlan).filter(RatePlan.package_id == id).count()
|
||||
if rate_plan_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f'Cannot delete package. It is used in {rate_plan_count} rate plan(s)')
|
||||
|
||||
# Delete items first
|
||||
db.query(PackageItem).filter(PackageItem.package_id == id).delete()
|
||||
|
||||
db.delete(pkg)
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Package deleted successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/available/{room_type_id}')
|
||||
async def get_available_packages(
|
||||
room_type_id: int,
|
||||
check_in: str = Query(...),
|
||||
check_out: str = Query(...),
|
||||
num_nights: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get available packages for a room type and date range"""
|
||||
try:
|
||||
check_in_date = datetime.strptime(check_in, '%Y-%m-%d').date()
|
||||
check_out_date = datetime.strptime(check_out, '%Y-%m-%d').date()
|
||||
|
||||
if num_nights is None:
|
||||
num_nights = (check_out_date - check_in_date).days
|
||||
|
||||
# Query packages
|
||||
query = db.query(Package).filter(
|
||||
Package.status == PackageStatus.active,
|
||||
or_(
|
||||
Package.room_type_id == room_type_id,
|
||||
Package.room_type_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by date range
|
||||
query = query.filter(
|
||||
or_(
|
||||
Package.valid_from.is_(None),
|
||||
Package.valid_from <= check_in_date
|
||||
),
|
||||
or_(
|
||||
Package.valid_to.is_(None),
|
||||
Package.valid_to >= check_out_date
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by nights
|
||||
query = query.filter(
|
||||
or_(
|
||||
Package.min_nights.is_(None),
|
||||
Package.min_nights <= num_nights
|
||||
),
|
||||
or_(
|
||||
Package.max_nights.is_(None),
|
||||
Package.max_nights >= num_nights
|
||||
)
|
||||
)
|
||||
|
||||
packages = query.order_by(Package.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for pkg in packages:
|
||||
items = db.query(PackageItem).filter(PackageItem.package_id == pkg.id).order_by(PackageItem.display_order.asc()).all()
|
||||
|
||||
pkg_dict = {
|
||||
'id': pkg.id,
|
||||
'name': pkg.name,
|
||||
'code': pkg.code,
|
||||
'description': pkg.description,
|
||||
'base_price': float(pkg.base_price) if pkg.base_price else None,
|
||||
'price_modifier': float(pkg.price_modifier) if pkg.price_modifier else 1.0,
|
||||
'discount_percentage': float(pkg.discount_percentage) if pkg.discount_percentage else None,
|
||||
'image_url': pkg.image_url,
|
||||
'highlights': pkg.highlights,
|
||||
'items': [
|
||||
{
|
||||
'id': item.id,
|
||||
'item_type': item.item_type.value if isinstance(item.item_type, PackageItemType) else item.item_type,
|
||||
'item_name': item.item_name,
|
||||
'item_description': item.item_description,
|
||||
'quantity': item.quantity,
|
||||
'unit': item.unit,
|
||||
'price': float(item.price) if item.price else None,
|
||||
'included': item.included,
|
||||
}
|
||||
for item in items
|
||||
],
|
||||
}
|
||||
result.append(pkg_dict)
|
||||
|
||||
return {'status': 'success', 'data': {'packages': result}}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid date format: {str(e)}')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload
|
||||
from sqlalchemy.orm import Session, joinedload, selectinload, load_only
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
@@ -9,10 +9,14 @@ from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..utils.role_helpers import can_access_all_payments
|
||||
from ..utils.currency_helpers import get_currency_symbol
|
||||
from ..utils.response_helpers import success_response
|
||||
from ..utils.mailer import send_email
|
||||
from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
|
||||
from ..services.stripe_service import StripeService
|
||||
from ..services.paypal_service import PayPalService
|
||||
from ..services.borica_service import BoricaService
|
||||
from ..services.loyalty_service import LoyaltyService
|
||||
router = APIRouter(prefix='/payments', tags=['payments'])
|
||||
|
||||
@@ -20,7 +24,18 @@ async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reaso
|
||||
if booking.status == BookingStatus.cancelled:
|
||||
return
|
||||
from sqlalchemy.orm import selectinload
|
||||
booking = db.query(Booking).options(selectinload(Booking.payments)).filter(Booking.id == booking.id).first()
|
||||
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
|
||||
booking = db.query(Booking).options(
|
||||
load_only(
|
||||
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
|
||||
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
|
||||
Booking.total_price, Booking.original_price, Booking.discount_amount,
|
||||
Booking.promotion_code, Booking.status, Booking.deposit_paid,
|
||||
Booking.requires_deposit, Booking.special_requests,
|
||||
Booking.created_at, Booking.updated_at
|
||||
),
|
||||
selectinload(Booking.payments)
|
||||
).filter(Booking.id == booking.id).first()
|
||||
if booking.payments:
|
||||
for payment in booking.payments:
|
||||
if payment.payment_status == PaymentStatus.pending:
|
||||
@@ -55,10 +70,23 @@ async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Opt
|
||||
query = query.filter(Payment.payment_status == PaymentStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
if current_user.role_id not in [1, 4]: # admin and accountant can see all payments
|
||||
if not can_access_all_payments(current_user, db):
|
||||
query = query.join(Booking).filter(Booking.user_id == current_user.id)
|
||||
total = query.count()
|
||||
query = query.options(selectinload(Payment.booking).selectinload(Booking.user))
|
||||
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
|
||||
query = query.options(
|
||||
selectinload(Payment.booking).options(
|
||||
load_only(
|
||||
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
|
||||
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
|
||||
Booking.total_price, Booking.original_price, Booking.discount_amount,
|
||||
Booking.promotion_code, Booking.status, Booking.deposit_paid,
|
||||
Booking.requires_deposit, Booking.special_requests,
|
||||
Booking.created_at, Booking.updated_at
|
||||
),
|
||||
joinedload(Booking.user)
|
||||
)
|
||||
)
|
||||
offset = (page - 1) * limit
|
||||
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
|
||||
result = []
|
||||
@@ -69,7 +97,7 @@ async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Opt
|
||||
if payment.booking.user:
|
||||
payment_dict['booking']['user'] = {'id': payment.booking.user.id, 'name': payment.booking.user.full_name, 'full_name': payment.booking.user.full_name, 'email': payment.booking.user.email}
|
||||
result.append(payment_dict)
|
||||
return {'status': 'success', 'data': {'payments': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
|
||||
return success_response(data={'payments': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -81,12 +109,26 @@ async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Opt
|
||||
@router.get('/booking/{booking_id}')
|
||||
async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
from ..utils.role_helpers import is_admin
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
payments = db.query(Payment).options(joinedload(Payment.booking).joinedload(Booking.user)).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
||||
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
|
||||
payments = db.query(Payment).options(
|
||||
joinedload(Payment.booking).options(
|
||||
load_only(
|
||||
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
|
||||
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
|
||||
Booking.total_price, Booking.original_price, Booking.discount_amount,
|
||||
Booking.promotion_code, Booking.status, Booking.deposit_paid,
|
||||
Booking.requires_deposit, Booking.special_requests,
|
||||
Booking.created_at, Booking.updated_at
|
||||
),
|
||||
joinedload(Booking.user)
|
||||
)
|
||||
).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all()
|
||||
result = []
|
||||
for payment in payments:
|
||||
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}
|
||||
@@ -95,7 +137,7 @@ async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends
|
||||
if payment.booking.user:
|
||||
payment_dict['booking']['user'] = {'id': payment.booking.user.id, 'name': payment.booking.user.full_name, 'full_name': payment.booking.user.full_name, 'email': payment.booking.user.email}
|
||||
result.append(payment_dict)
|
||||
return {'status': 'success', 'data': {'payments': result}}
|
||||
return success_response(data={'payments': result})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -107,13 +149,13 @@ async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail='Payment not found')
|
||||
if current_user.role_id not in [1, 4]: # admin and accountant can see all payments
|
||||
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')
|
||||
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}
|
||||
return {'status': 'success', 'data': {'payment': payment_dict}}
|
||||
return success_response(data={'payment': payment_dict})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -129,7 +171,8 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
from ..utils.role_helpers import is_admin
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if payment_data.get('mark_as_paid') else None, notes=payment_data.get('notes'))
|
||||
if payment_data.get('mark_as_paid'):
|
||||
@@ -139,6 +182,16 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
|
||||
# Send payment receipt notification
|
||||
if payment.payment_status == PaymentStatus.completed:
|
||||
try:
|
||||
from ..services.notification_service import NotificationService
|
||||
NotificationService.send_payment_receipt(db, payment)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send payment receipt notification: {e}')
|
||||
|
||||
# Award loyalty points if payment completed and booking is confirmed
|
||||
if payment.payment_status == PaymentStatus.completed and booking:
|
||||
try:
|
||||
@@ -168,15 +221,14 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, payment_type=payment.payment_type.value if payment.payment_type else None, total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
|
||||
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Failed to send payment confirmation email: {e}')
|
||||
return {'status': 'success', 'message': 'Payment created successfully', 'data': {'payment': payment}}
|
||||
return success_response(data={'payment': payment}, message='Payment created successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -195,9 +247,29 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
|
||||
try:
|
||||
new_status = PaymentStatus(status_value)
|
||||
payment.payment_status = new_status
|
||||
# Only cancel booking if it's a full refund or all payments are failed
|
||||
if new_status in [PaymentStatus.failed, PaymentStatus.refunded]:
|
||||
booking = db.query(Booking).filter(Booking.id == payment.booking_id).first()
|
||||
# Use load_only to exclude non-existent columns (rate_plan_id, group_booking_id)
|
||||
booking = db.query(Booking).options(
|
||||
load_only(
|
||||
Booking.id, Booking.booking_number, Booking.user_id, Booking.room_id,
|
||||
Booking.check_in_date, Booking.check_out_date, Booking.num_guests,
|
||||
Booking.total_price, Booking.original_price, Booking.discount_amount,
|
||||
Booking.promotion_code, Booking.status, Booking.deposit_paid,
|
||||
Booking.requires_deposit, Booking.special_requests,
|
||||
Booking.created_at, Booking.updated_at
|
||||
),
|
||||
selectinload(Booking.payments)
|
||||
).filter(Booking.id == payment.booking_id).first()
|
||||
if booking and booking.status != BookingStatus.cancelled:
|
||||
# Check if this is a full refund or if all payments are failed
|
||||
total_paid = sum(float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)
|
||||
total_price = float(booking.total_price) if booking.total_price else 0.0
|
||||
all_payments_failed = all(p.payment_status in [PaymentStatus.failed, PaymentStatus.refunded] for p in booking.payments)
|
||||
is_full_refund = new_status == PaymentStatus.refunded and float(payment.amount) >= total_price
|
||||
|
||||
# Only cancel if it's a full refund or all payments failed
|
||||
if is_full_refund or (new_status == PaymentStatus.failed and all_payments_failed):
|
||||
await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}')
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail='Invalid payment status')
|
||||
@@ -209,22 +281,29 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
|
||||
# Send payment receipt notification
|
||||
try:
|
||||
from ..services.notification_service import NotificationService
|
||||
NotificationService.send_payment_receipt(db, payment)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send payment receipt notification: {e}')
|
||||
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
payment = db.query(Payment).filter(Payment.id == id).first()
|
||||
if payment.booking and payment.booking.user:
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
email_html = payment_confirmation_email_template(booking_number=payment.booking.booking_number, guest_name=payment.booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, client_url=client_url, currency_symbol=currency_symbol)
|
||||
await send_email(to=payment.booking.user.email, subject=f'Payment Confirmed - {payment.booking.booking_number}', html=email_html)
|
||||
if payment.payment_type == PaymentType.deposit and payment.booking:
|
||||
@@ -240,7 +319,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
print(f'Failed to send payment confirmation email: {e}')
|
||||
return {'status': 'success', 'message': 'Payment status updated successfully', 'data': {'payment': payment}}
|
||||
return success_response(data={'payment': payment}, message='Payment status updated successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -270,7 +349,8 @@ async def create_stripe_payment_intent(intent_data: dict, current_user: User=Dep
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
from ..utils.role_helpers import is_admin
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
if booking.requires_deposit and (not booking.deposit_paid):
|
||||
deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
|
||||
@@ -294,7 +374,7 @@ async def create_stripe_payment_intent(intent_data: dict, current_user: User=Dep
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error('Payment intent created but client_secret is missing')
|
||||
raise HTTPException(status_code=500, detail='Failed to create payment intent. Client secret is missing.')
|
||||
return {'status': 'success', 'message': 'Payment intent created successfully', 'data': {'client_secret': intent['client_secret'], 'payment_intent_id': intent['id'], 'publishable_key': publishable_key}}
|
||||
return success_response(data={'client_secret': intent['client_secret'], 'payment_intent_id': intent['id'], 'publishable_key': publishable_key}, message='Payment intent created successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
@@ -330,15 +410,14 @@ async def confirm_stripe_payment(payment_data: dict, current_user: User=Depends(
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='stripe', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
|
||||
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send payment confirmation email: {e}')
|
||||
return {'status': 'success', 'message': 'Payment confirmed successfully', 'data': {'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}}
|
||||
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
@@ -369,7 +448,7 @@ async def stripe_webhook(request: Request, db: Session=Depends(get_db)):
|
||||
if not signature:
|
||||
raise HTTPException(status_code=400, detail='Missing stripe-signature header')
|
||||
result = await StripeService.handle_webhook(payload=payload, signature=signature, db=db)
|
||||
return {'status': 'success', 'data': result}
|
||||
return success_response(data=result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
@@ -395,10 +474,11 @@ async def create_paypal_order(order_data: dict, current_user: User=Depends(get_c
|
||||
raise HTTPException(status_code=400, detail='booking_id and amount are required')
|
||||
if amount > 100000:
|
||||
raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000. Please contact support for large payments.")
|
||||
from ..utils.role_helpers import is_admin
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if current_user.role_id != 1 and booking.user_id != current_user.id:
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
if booking.requires_deposit and (not booking.deposit_paid):
|
||||
deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
|
||||
@@ -415,7 +495,7 @@ async def create_paypal_order(order_data: dict, current_user: User=Depends(get_c
|
||||
order = PayPalService.create_order(amount=amount, currency=currency, metadata={'booking_id': str(booking_id), 'booking_number': booking.booking_number, 'user_id': str(current_user.id), 'description': f'Hotel Booking Payment - {booking.booking_number}', 'return_url': return_url, 'cancel_url': cancel_url}, db=db)
|
||||
if not order.get('approval_url'):
|
||||
raise HTTPException(status_code=500, detail='Failed to create PayPal order. Approval URL is missing.')
|
||||
return {'status': 'success', 'message': 'PayPal order created successfully', 'data': {'order_id': order['id'], 'approval_url': order['approval_url'], 'status': order['status']}}
|
||||
return success_response(data={'order_id': order['id'], 'approval_url': order['approval_url'], 'status': order['status']}, message='PayPal order created successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
@@ -445,7 +525,7 @@ async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(g
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if booking and booking.status != BookingStatus.cancelled:
|
||||
await cancel_booking_on_payment_failure(booking, db, reason='PayPal payment canceled by user')
|
||||
return {'status': 'success', 'message': 'Payment canceled and booking cancelled'}
|
||||
return success_response(message='Payment canceled and booking cancelled')
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
@@ -475,15 +555,14 @@ async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'}
|
||||
currency_symbol = currency_symbols.get(currency, currency)
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='paypal', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
|
||||
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send payment confirmation email: {e}')
|
||||
return {'status': 'success', 'message': 'Payment confirmed successfully', 'data': {'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}}
|
||||
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
@@ -499,3 +578,173 @@ async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(
|
||||
logger.error(f'Unexpected error confirming PayPal payment: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/borica/create-payment')
|
||||
async def create_borica_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
from ..services.borica_service import get_borica_terminal_id, get_borica_merchant_id
|
||||
terminal_id = get_borica_terminal_id(db)
|
||||
merchant_id = get_borica_merchant_id(db)
|
||||
if not terminal_id or not merchant_id:
|
||||
if not settings.BORICA_TERMINAL_ID or not settings.BORICA_MERCHANT_ID:
|
||||
raise HTTPException(status_code=500, detail='Borica is not configured. Please configure Borica settings in Admin Panel or set BORICA_TERMINAL_ID and BORICA_MERCHANT_ID environment variables.')
|
||||
booking_id = payment_data.get('booking_id')
|
||||
amount = float(payment_data.get('amount', 0))
|
||||
currency = payment_data.get('currency', 'BGN')
|
||||
if not booking_id or amount <= 0:
|
||||
raise HTTPException(status_code=400, detail='booking_id and amount are required')
|
||||
if amount > 100000:
|
||||
raise HTTPException(status_code=400, detail=f"Amount {amount:,.2f} exceeds maximum of 100,000. Please contact support for large payments.")
|
||||
from ..utils.role_helpers import is_admin
|
||||
booking = db.query(Booking).filter(Booking.id == booking_id).first()
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Forbidden')
|
||||
if booking.requires_deposit and (not booking.deposit_paid):
|
||||
deposit_payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
|
||||
if deposit_payment:
|
||||
expected_deposit_amount = float(deposit_payment.amount)
|
||||
if abs(amount - expected_deposit_amount) > 0.01:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Amount mismatch for deposit payment: Requested {amount:,.2f}, Expected deposit {expected_deposit_amount:,.2f}, Booking total {float(booking.total_price):,.2f}')
|
||||
raise HTTPException(status_code=400, detail=f'For pay-on-arrival bookings, only the deposit amount ({expected_deposit_amount:,.2f}) should be charged, not the full booking amount ({float(booking.total_price):,.2f}).')
|
||||
transaction_id = BoricaService.generate_transaction_id(booking_id)
|
||||
client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
return_url = payment_data.get('return_url', f'{client_url}/payment/borica/return')
|
||||
description = f'Hotel Booking Payment - {booking.booking_number}'
|
||||
payment_request = BoricaService.create_payment_request(amount=amount, currency=currency, order_id=transaction_id, description=description, return_url=return_url, db=db)
|
||||
payment_type = PaymentType.full
|
||||
if booking.requires_deposit and (not booking.deposit_paid):
|
||||
payment_type = PaymentType.deposit
|
||||
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.borica, payment_type=payment_type, payment_status=PaymentStatus.pending, transaction_id=transaction_id, notes=f'Borica payment initiated - Order: {transaction_id}')
|
||||
db.add(payment)
|
||||
db.commit()
|
||||
db.refresh(payment)
|
||||
return success_response(data={'payment_request': payment_request, 'payment_id': payment.id, 'transaction_id': transaction_id}, message='Borica payment request created successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Borica payment creation error: {str(e)}')
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Unexpected error creating Borica payment: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/borica/callback')
|
||||
async def borica_callback(request: Request, db: Session=Depends(get_db)):
|
||||
"""
|
||||
Handle Borica payment callback (POST from Borica gateway).
|
||||
Borica sends POST data with payment response.
|
||||
"""
|
||||
try:
|
||||
form_data = await request.form()
|
||||
response_data = dict(form_data)
|
||||
|
||||
# Also try to get from JSON if available
|
||||
try:
|
||||
json_data = await request.json()
|
||||
response_data.update(json_data)
|
||||
except:
|
||||
pass
|
||||
|
||||
payment = await BoricaService.confirm_payment(response_data=response_data, db=db)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first()
|
||||
if booking:
|
||||
db.refresh(booking)
|
||||
|
||||
if booking and booking.user:
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='borica', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
|
||||
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send payment confirmation email: {e}')
|
||||
|
||||
# Redirect to return URL with success status
|
||||
return_url = response_data.get('BACKREF', '')
|
||||
if return_url:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"{return_url}?status=success&order={response_data.get('ORDER', '')}&bookingId={payment['booking_id']}")
|
||||
|
||||
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except ValueError as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Borica payment callback error: {str(e)}')
|
||||
db.rollback()
|
||||
# Redirect to return URL with error status
|
||||
return_url = dict(await request.form()).get('BACKREF', '') if hasattr(request, 'form') else ''
|
||||
if return_url:
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=f"{return_url}?status=error&error={str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Unexpected error in Borica callback: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/borica/confirm')
|
||||
async def confirm_borica_payment(response_data: dict, db: Session=Depends(get_db)):
|
||||
try:
|
||||
payment = await BoricaService.confirm_payment(response_data=response_data, db=db)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first()
|
||||
if booking:
|
||||
db.refresh(booking)
|
||||
if booking and booking.user:
|
||||
try:
|
||||
from ..models.system_settings import SystemSettings
|
||||
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
|
||||
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
|
||||
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
|
||||
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
|
||||
currency_symbol = get_currency_symbol(currency)
|
||||
email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='borica', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol)
|
||||
await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f'Failed to send payment confirmation email: {e}')
|
||||
return success_response(data={'payment': payment, 'booking': {'id': booking.id if booking else None, 'booking_number': booking.booking_number if booking else None, 'status': booking.status.value if booking else None}}, message='Payment confirmed successfully')
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except ValueError as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Borica payment confirmation error: {str(e)}')
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f'Unexpected error confirming Borica payment: {str(e)}', exc_info=True)
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
496
Backend/src/routes/rate_plan_routes.py
Normal file
496
Backend/src/routes/rate_plan_routes.py
Normal file
@@ -0,0 +1,496 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, and_
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
from decimal import Decimal
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.rate_plan import RatePlan, RatePlanRule, RatePlanType, RatePlanStatus
|
||||
from ..models.room_type import RoomType
|
||||
from ..models.booking import Booking
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional as Opt
|
||||
|
||||
router = APIRouter(prefix='/rate-plans', tags=['rate-plans'])
|
||||
|
||||
# Pydantic models for request/response
|
||||
class RatePlanRuleCreate(BaseModel):
|
||||
rule_type: str
|
||||
rule_key: str
|
||||
rule_value: Optional[dict] = None
|
||||
price_modifier: Optional[float] = None
|
||||
discount_percentage: Optional[float] = None
|
||||
fixed_adjustment: Optional[float] = None
|
||||
priority: int = 100
|
||||
|
||||
class RatePlanCreate(BaseModel):
|
||||
name: str
|
||||
code: str
|
||||
description: Optional[str] = None
|
||||
plan_type: str
|
||||
status: str = 'active'
|
||||
base_price_modifier: float = 1.0
|
||||
discount_percentage: Optional[float] = None
|
||||
fixed_discount: Optional[float] = None
|
||||
room_type_id: Optional[int] = None
|
||||
min_nights: Optional[int] = None
|
||||
max_nights: Optional[int] = None
|
||||
advance_days_required: Optional[int] = None
|
||||
valid_from: Optional[str] = None
|
||||
valid_to: Optional[str] = None
|
||||
is_refundable: bool = True
|
||||
requires_deposit: bool = False
|
||||
deposit_percentage: Optional[float] = None
|
||||
cancellation_hours: Optional[int] = None
|
||||
corporate_code: Optional[str] = None
|
||||
requires_verification: bool = False
|
||||
verification_type: Optional[str] = None
|
||||
long_stay_nights: Optional[int] = None
|
||||
is_package: bool = False
|
||||
package_id: Optional[int] = None
|
||||
priority: int = 100
|
||||
extra_data: Optional[dict] = None
|
||||
rules: Optional[List[RatePlanRuleCreate]] = []
|
||||
|
||||
class RatePlanUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
base_price_modifier: Optional[float] = None
|
||||
discount_percentage: Optional[float] = None
|
||||
fixed_discount: Optional[float] = None
|
||||
room_type_id: Optional[int] = None
|
||||
min_nights: Optional[int] = None
|
||||
max_nights: Optional[int] = None
|
||||
advance_days_required: Optional[int] = None
|
||||
valid_from: Optional[str] = None
|
||||
valid_to: Optional[str] = None
|
||||
is_refundable: Optional[bool] = None
|
||||
requires_deposit: Optional[bool] = None
|
||||
deposit_percentage: Optional[float] = None
|
||||
cancellation_hours: Optional[int] = None
|
||||
corporate_code: Optional[str] = None
|
||||
requires_verification: Optional[bool] = None
|
||||
verification_type: Optional[str] = None
|
||||
long_stay_nights: Optional[int] = None
|
||||
package_id: Optional[int] = None
|
||||
priority: Optional[int] = None
|
||||
extra_data: Optional[dict] = None
|
||||
|
||||
@router.get('/')
|
||||
async def get_rate_plans(
|
||||
search: Optional[str] = Query(None),
|
||||
status_filter: Optional[str] = Query(None, alias='status'),
|
||||
plan_type: Optional[str] = Query(None),
|
||||
room_type_id: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=100),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
query = db.query(RatePlan)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
RatePlan.name.like(f'%{search}%'),
|
||||
RatePlan.code.like(f'%{search}%'),
|
||||
RatePlan.description.like(f'%{search}%')
|
||||
)
|
||||
)
|
||||
|
||||
if status_filter:
|
||||
try:
|
||||
query = query.filter(RatePlan.status == RatePlanStatus(status_filter))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if plan_type:
|
||||
try:
|
||||
query = query.filter(RatePlan.plan_type == RatePlanType(plan_type))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if room_type_id:
|
||||
query = query.filter(
|
||||
or_(
|
||||
RatePlan.room_type_id == room_type_id,
|
||||
RatePlan.room_type_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
rate_plans = query.order_by(RatePlan.priority.asc(), RatePlan.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for plan in rate_plans:
|
||||
plan_dict = {
|
||||
'id': plan.id,
|
||||
'name': plan.name,
|
||||
'code': plan.code,
|
||||
'description': plan.description,
|
||||
'plan_type': plan.plan_type.value if isinstance(plan.plan_type, RatePlanType) else plan.plan_type,
|
||||
'status': plan.status.value if isinstance(plan.status, RatePlanStatus) else plan.status,
|
||||
'base_price_modifier': float(plan.base_price_modifier) if plan.base_price_modifier else 1.0,
|
||||
'discount_percentage': float(plan.discount_percentage) if plan.discount_percentage else None,
|
||||
'fixed_discount': float(plan.fixed_discount) if plan.fixed_discount else None,
|
||||
'room_type_id': plan.room_type_id,
|
||||
'room_type_name': plan.room_type.name if plan.room_type else None,
|
||||
'min_nights': plan.min_nights,
|
||||
'max_nights': plan.max_nights,
|
||||
'advance_days_required': plan.advance_days_required,
|
||||
'valid_from': plan.valid_from.isoformat() if plan.valid_from else None,
|
||||
'valid_to': plan.valid_to.isoformat() if plan.valid_to else None,
|
||||
'is_refundable': plan.is_refundable,
|
||||
'requires_deposit': plan.requires_deposit,
|
||||
'deposit_percentage': float(plan.deposit_percentage) if plan.deposit_percentage else None,
|
||||
'cancellation_hours': plan.cancellation_hours,
|
||||
'corporate_code': plan.corporate_code,
|
||||
'requires_verification': plan.requires_verification,
|
||||
'verification_type': plan.verification_type,
|
||||
'long_stay_nights': plan.long_stay_nights,
|
||||
'is_package': plan.is_package,
|
||||
'package_id': plan.package_id,
|
||||
'priority': plan.priority,
|
||||
'extra_data': plan.extra_data,
|
||||
'created_at': plan.created_at.isoformat() if plan.created_at else None,
|
||||
'updated_at': plan.updated_at.isoformat() if plan.updated_at else None,
|
||||
}
|
||||
result.append(plan_dict)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'data': {
|
||||
'rate_plans': result,
|
||||
'pagination': {
|
||||
'total': total,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'totalPages': (total + limit - 1) // limit
|
||||
}
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/{id}')
|
||||
async def get_rate_plan(id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
plan = db.query(RatePlan).filter(RatePlan.id == id).first()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail='Rate plan not found')
|
||||
|
||||
rules = db.query(RatePlanRule).filter(RatePlanRule.rate_plan_id == id).order_by(RatePlanRule.priority.asc()).all()
|
||||
|
||||
plan_dict = {
|
||||
'id': plan.id,
|
||||
'name': plan.name,
|
||||
'code': plan.code,
|
||||
'description': plan.description,
|
||||
'plan_type': plan.plan_type.value if isinstance(plan.plan_type, RatePlanType) else plan.plan_type,
|
||||
'status': plan.status.value if isinstance(plan.status, RatePlanStatus) else plan.status,
|
||||
'base_price_modifier': float(plan.base_price_modifier) if plan.base_price_modifier else 1.0,
|
||||
'discount_percentage': float(plan.discount_percentage) if plan.discount_percentage else None,
|
||||
'fixed_discount': float(plan.fixed_discount) if plan.fixed_discount else None,
|
||||
'room_type_id': plan.room_type_id,
|
||||
'room_type_name': plan.room_type.name if plan.room_type else None,
|
||||
'min_nights': plan.min_nights,
|
||||
'max_nights': plan.max_nights,
|
||||
'advance_days_required': plan.advance_days_required,
|
||||
'valid_from': plan.valid_from.isoformat() if plan.valid_from else None,
|
||||
'valid_to': plan.valid_to.isoformat() if plan.valid_to else None,
|
||||
'is_refundable': plan.is_refundable,
|
||||
'requires_deposit': plan.requires_deposit,
|
||||
'deposit_percentage': float(plan.deposit_percentage) if plan.deposit_percentage else None,
|
||||
'cancellation_hours': plan.cancellation_hours,
|
||||
'corporate_code': plan.corporate_code,
|
||||
'requires_verification': plan.requires_verification,
|
||||
'verification_type': plan.verification_type,
|
||||
'long_stay_nights': plan.long_stay_nights,
|
||||
'is_package': plan.is_package,
|
||||
'package_id': plan.package_id,
|
||||
'priority': plan.priority,
|
||||
'extra_data': plan.extra_data,
|
||||
'rules': [
|
||||
{
|
||||
'id': rule.id,
|
||||
'rule_type': rule.rule_type,
|
||||
'rule_key': rule.rule_key,
|
||||
'rule_value': rule.rule_value,
|
||||
'price_modifier': float(rule.price_modifier) if rule.price_modifier else None,
|
||||
'discount_percentage': float(rule.discount_percentage) if rule.discount_percentage else None,
|
||||
'fixed_adjustment': float(rule.fixed_adjustment) if rule.fixed_adjustment else None,
|
||||
'priority': rule.priority,
|
||||
}
|
||||
for rule in rules
|
||||
],
|
||||
'created_at': plan.created_at.isoformat() if plan.created_at else None,
|
||||
'updated_at': plan.updated_at.isoformat() if plan.updated_at else None,
|
||||
}
|
||||
|
||||
return {'status': 'success', 'data': {'rate_plan': plan_dict}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def create_rate_plan(plan_data: RatePlanCreate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
try:
|
||||
# Check if code already exists
|
||||
existing = db.query(RatePlan).filter(RatePlan.code == plan_data.code).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail='Rate plan code already exists')
|
||||
|
||||
# Validate room_type_id if provided
|
||||
if plan_data.room_type_id:
|
||||
room_type = db.query(RoomType).filter(RoomType.id == plan_data.room_type_id).first()
|
||||
if not room_type:
|
||||
raise HTTPException(status_code=404, detail='Room type not found')
|
||||
|
||||
# Create rate plan
|
||||
rate_plan = RatePlan(
|
||||
name=plan_data.name,
|
||||
code=plan_data.code,
|
||||
description=plan_data.description,
|
||||
plan_type=RatePlanType(plan_data.plan_type),
|
||||
status=RatePlanStatus(plan_data.status),
|
||||
base_price_modifier=Decimal(str(plan_data.base_price_modifier)),
|
||||
discount_percentage=Decimal(str(plan_data.discount_percentage)) if plan_data.discount_percentage else None,
|
||||
fixed_discount=Decimal(str(plan_data.fixed_discount)) if plan_data.fixed_discount else None,
|
||||
room_type_id=plan_data.room_type_id,
|
||||
min_nights=plan_data.min_nights,
|
||||
max_nights=plan_data.max_nights,
|
||||
advance_days_required=plan_data.advance_days_required,
|
||||
valid_from=datetime.strptime(plan_data.valid_from, '%Y-%m-%d').date() if plan_data.valid_from else None,
|
||||
valid_to=datetime.strptime(plan_data.valid_to, '%Y-%m-%d').date() if plan_data.valid_to else None,
|
||||
is_refundable=plan_data.is_refundable,
|
||||
requires_deposit=plan_data.requires_deposit,
|
||||
deposit_percentage=Decimal(str(plan_data.deposit_percentage)) if plan_data.deposit_percentage else None,
|
||||
cancellation_hours=plan_data.cancellation_hours,
|
||||
corporate_code=plan_data.corporate_code,
|
||||
requires_verification=plan_data.requires_verification,
|
||||
verification_type=plan_data.verification_type,
|
||||
long_stay_nights=plan_data.long_stay_nights,
|
||||
is_package=plan_data.is_package,
|
||||
package_id=plan_data.package_id,
|
||||
priority=plan_data.priority,
|
||||
extra_data=plan_data.extra_data,
|
||||
)
|
||||
|
||||
db.add(rate_plan)
|
||||
db.flush()
|
||||
|
||||
# Create rules
|
||||
if plan_data.rules:
|
||||
for rule_data in plan_data.rules:
|
||||
rule = RatePlanRule(
|
||||
rate_plan_id=rate_plan.id,
|
||||
rule_type=rule_data.rule_type,
|
||||
rule_key=rule_data.rule_key,
|
||||
rule_value=rule_data.rule_value,
|
||||
price_modifier=Decimal(str(rule_data.price_modifier)) if rule_data.price_modifier else None,
|
||||
discount_percentage=Decimal(str(rule_data.discount_percentage)) if rule_data.discount_percentage else None,
|
||||
fixed_adjustment=Decimal(str(rule_data.fixed_adjustment)) if rule_data.fixed_adjustment else None,
|
||||
priority=rule_data.priority,
|
||||
)
|
||||
db.add(rule)
|
||||
|
||||
db.commit()
|
||||
db.refresh(rate_plan)
|
||||
|
||||
return {'status': 'success', 'message': 'Rate plan created successfully', 'data': {'rate_plan_id': rate_plan.id}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def update_rate_plan(id: int, plan_data: RatePlanUpdate, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
try:
|
||||
rate_plan = db.query(RatePlan).filter(RatePlan.id == id).first()
|
||||
if not rate_plan:
|
||||
raise HTTPException(status_code=404, detail='Rate plan not found')
|
||||
|
||||
# Update fields
|
||||
if plan_data.name is not None:
|
||||
rate_plan.name = plan_data.name
|
||||
if plan_data.description is not None:
|
||||
rate_plan.description = plan_data.description
|
||||
if plan_data.status is not None:
|
||||
rate_plan.status = RatePlanStatus(plan_data.status)
|
||||
if plan_data.base_price_modifier is not None:
|
||||
rate_plan.base_price_modifier = Decimal(str(plan_data.base_price_modifier))
|
||||
if plan_data.discount_percentage is not None:
|
||||
rate_plan.discount_percentage = Decimal(str(plan_data.discount_percentage))
|
||||
if plan_data.fixed_discount is not None:
|
||||
rate_plan.fixed_discount = Decimal(str(plan_data.fixed_discount))
|
||||
if plan_data.room_type_id is not None:
|
||||
if plan_data.room_type_id:
|
||||
room_type = db.query(RoomType).filter(RoomType.id == plan_data.room_type_id).first()
|
||||
if not room_type:
|
||||
raise HTTPException(status_code=404, detail='Room type not found')
|
||||
rate_plan.room_type_id = plan_data.room_type_id
|
||||
if plan_data.min_nights is not None:
|
||||
rate_plan.min_nights = plan_data.min_nights
|
||||
if plan_data.max_nights is not None:
|
||||
rate_plan.max_nights = plan_data.max_nights
|
||||
if plan_data.advance_days_required is not None:
|
||||
rate_plan.advance_days_required = plan_data.advance_days_required
|
||||
if plan_data.valid_from is not None:
|
||||
rate_plan.valid_from = datetime.strptime(plan_data.valid_from, '%Y-%m-%d').date() if plan_data.valid_from else None
|
||||
if plan_data.valid_to is not None:
|
||||
rate_plan.valid_to = datetime.strptime(plan_data.valid_to, '%Y-%m-%d').date() if plan_data.valid_to else None
|
||||
if plan_data.is_refundable is not None:
|
||||
rate_plan.is_refundable = plan_data.is_refundable
|
||||
if plan_data.requires_deposit is not None:
|
||||
rate_plan.requires_deposit = plan_data.requires_deposit
|
||||
if plan_data.deposit_percentage is not None:
|
||||
rate_plan.deposit_percentage = Decimal(str(plan_data.deposit_percentage)) if plan_data.deposit_percentage else None
|
||||
if plan_data.cancellation_hours is not None:
|
||||
rate_plan.cancellation_hours = plan_data.cancellation_hours
|
||||
if plan_data.corporate_code is not None:
|
||||
rate_plan.corporate_code = plan_data.corporate_code
|
||||
if plan_data.requires_verification is not None:
|
||||
rate_plan.requires_verification = plan_data.requires_verification
|
||||
if plan_data.verification_type is not None:
|
||||
rate_plan.verification_type = plan_data.verification_type
|
||||
if plan_data.long_stay_nights is not None:
|
||||
rate_plan.long_stay_nights = plan_data.long_stay_nights
|
||||
if plan_data.package_id is not None:
|
||||
rate_plan.package_id = plan_data.package_id
|
||||
if plan_data.priority is not None:
|
||||
rate_plan.priority = plan_data.priority
|
||||
if plan_data.extra_data is not None:
|
||||
rate_plan.extra_data = plan_data.extra_data
|
||||
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Rate plan updated successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid enum value: {str(e)}')
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
|
||||
async def delete_rate_plan(id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)):
|
||||
try:
|
||||
rate_plan = db.query(RatePlan).filter(RatePlan.id == id).first()
|
||||
if not rate_plan:
|
||||
raise HTTPException(status_code=404, detail='Rate plan not found')
|
||||
|
||||
# Check if rate plan is used in bookings
|
||||
booking_count = db.query(Booking).filter(Booking.rate_plan_id == id).count()
|
||||
if booking_count > 0:
|
||||
raise HTTPException(status_code=400, detail=f'Cannot delete rate plan. It is used in {booking_count} booking(s)')
|
||||
|
||||
# Delete rules first
|
||||
db.query(RatePlanRule).filter(RatePlanRule.rate_plan_id == id).delete()
|
||||
|
||||
db.delete(rate_plan)
|
||||
db.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Rate plan deleted successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/available/{room_type_id}')
|
||||
async def get_available_rate_plans(
|
||||
room_type_id: int,
|
||||
check_in: str = Query(...),
|
||||
check_out: str = Query(...),
|
||||
num_nights: Optional[int] = Query(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get available rate plans for a room type and date range"""
|
||||
try:
|
||||
check_in_date = datetime.strptime(check_in, '%Y-%m-%d').date()
|
||||
check_out_date = datetime.strptime(check_out, '%Y-%m-%d').date()
|
||||
|
||||
if num_nights is None:
|
||||
num_nights = (check_out_date - check_in_date).days
|
||||
|
||||
today = date.today()
|
||||
advance_days = (check_in_date - today).days
|
||||
|
||||
# Query rate plans
|
||||
query = db.query(RatePlan).filter(
|
||||
RatePlan.status == RatePlanStatus.active,
|
||||
or_(
|
||||
RatePlan.room_type_id == room_type_id,
|
||||
RatePlan.room_type_id.is_(None)
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by date range
|
||||
query = query.filter(
|
||||
or_(
|
||||
RatePlan.valid_from.is_(None),
|
||||
RatePlan.valid_from <= check_in_date
|
||||
),
|
||||
or_(
|
||||
RatePlan.valid_to.is_(None),
|
||||
RatePlan.valid_to >= check_out_date
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by advance days
|
||||
query = query.filter(
|
||||
or_(
|
||||
RatePlan.advance_days_required.is_(None),
|
||||
RatePlan.advance_days_required <= advance_days
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by nights
|
||||
query = query.filter(
|
||||
or_(
|
||||
RatePlan.min_nights.is_(None),
|
||||
RatePlan.min_nights <= num_nights
|
||||
),
|
||||
or_(
|
||||
RatePlan.max_nights.is_(None),
|
||||
RatePlan.max_nights >= num_nights
|
||||
)
|
||||
)
|
||||
|
||||
rate_plans = query.order_by(RatePlan.priority.asc()).all()
|
||||
|
||||
result = []
|
||||
for plan in rate_plans:
|
||||
plan_dict = {
|
||||
'id': plan.id,
|
||||
'name': plan.name,
|
||||
'code': plan.code,
|
||||
'description': plan.description,
|
||||
'plan_type': plan.plan_type.value if isinstance(plan.plan_type, RatePlanType) else plan.plan_type,
|
||||
'base_price_modifier': float(plan.base_price_modifier) if plan.base_price_modifier else 1.0,
|
||||
'discount_percentage': float(plan.discount_percentage) if plan.discount_percentage else None,
|
||||
'fixed_discount': float(plan.fixed_discount) if plan.fixed_discount else None,
|
||||
'is_refundable': plan.is_refundable,
|
||||
'requires_deposit': plan.requires_deposit,
|
||||
'deposit_percentage': float(plan.deposit_percentage) if plan.deposit_percentage else None,
|
||||
'cancellation_hours': plan.cancellation_hours,
|
||||
'requires_verification': plan.requires_verification,
|
||||
'verification_type': plan.verification_type,
|
||||
}
|
||||
result.append(plan_dict)
|
||||
|
||||
return {'status': 'success', 'data': {'rate_plans': result}}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=f'Invalid date format: {str(e)}')
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import Session, load_only, joinedload
|
||||
from sqlalchemy import func, and_
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
@@ -8,9 +8,10 @@ from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..models.payment import Payment, PaymentStatus
|
||||
from ..models.room import Room
|
||||
from ..models.room import Room, RoomStatus
|
||||
from ..models.service_usage import ServiceUsage
|
||||
from ..models.service import Service
|
||||
from ..utils.response_helpers import success_response
|
||||
router = APIRouter(prefix='/reports', tags=['reports'])
|
||||
|
||||
@router.get('')
|
||||
@@ -37,7 +38,8 @@ async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_dat
|
||||
if end_date:
|
||||
booking_query = booking_query.filter(Booking.created_at <= end_date)
|
||||
payment_query = payment_query.filter(Payment.payment_date <= end_date)
|
||||
total_bookings = booking_query.count()
|
||||
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
|
||||
total_bookings = booking_query.with_entities(func.count(Booking.id)).scalar() or 0
|
||||
total_revenue = payment_query.with_entities(func.sum(Payment.amount)).scalar() or 0.0
|
||||
total_customers = db.query(func.count(func.distinct(Booking.user_id))).scalar() or 0
|
||||
if start_date or end_date:
|
||||
@@ -47,7 +49,7 @@ async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_dat
|
||||
if end_date:
|
||||
customer_query = customer_query.filter(Booking.created_at <= end_date)
|
||||
total_customers = customer_query.scalar() or 0
|
||||
available_rooms = db.query(Room).filter(Room.status == 'available').count()
|
||||
available_rooms = db.query(Room).filter(Room.status == RoomStatus.available).count()
|
||||
occupied_rooms = db.query(func.count(func.distinct(Booking.room_id))).filter(Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in])).scalar() or 0
|
||||
revenue_by_date = []
|
||||
if start_date and end_date:
|
||||
@@ -61,7 +63,8 @@ async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_dat
|
||||
revenue_by_date = [{'date': str(date), 'revenue': float(revenue or 0), 'bookings': int(bookings or 0)} for date, revenue, bookings in daily_data]
|
||||
bookings_by_status = {}
|
||||
for status in BookingStatus:
|
||||
count = booking_query.filter(Booking.status == status).count()
|
||||
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
|
||||
count = booking_query.filter(Booking.status == status).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
status_name = status.value if hasattr(status, 'value') else str(status)
|
||||
bookings_by_status[status_name] = count
|
||||
top_rooms_query = db.query(Room.id, Room.room_number, func.count(Booking.id).label('bookings'), func.sum(Payment.amount).label('revenue')).join(Booking, Room.id == Booking.room_id).join(Payment, Booking.id == Payment.booking_id).filter(Payment.payment_status == PaymentStatus.completed)
|
||||
@@ -78,24 +81,25 @@ async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_dat
|
||||
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date <= end_date)
|
||||
service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by(func.sum(ServiceUsage.total_price).desc()).limit(10).all()
|
||||
service_usage = [{'service_id': service_id, 'service_name': service_name, 'usage_count': int(usage_count or 0), 'total_revenue': float(total_revenue or 0)} for service_id, service_name, usage_count, total_revenue in service_usage_data]
|
||||
return {'status': 'success', 'success': True, 'data': {'total_bookings': total_bookings, 'total_revenue': float(total_revenue), 'total_customers': int(total_customers), 'available_rooms': available_rooms, 'occupied_rooms': occupied_rooms, 'revenue_by_date': revenue_by_date if revenue_by_date else None, 'bookings_by_status': bookings_by_status, 'top_rooms': top_rooms if top_rooms else None, 'service_usage': service_usage if service_usage else None}}
|
||||
return success_response(data={'total_bookings': total_bookings, 'total_revenue': float(total_revenue), 'total_customers': int(total_customers), 'available_rooms': available_rooms, 'occupied_rooms': occupied_rooms, 'revenue_by_date': revenue_by_date if revenue_by_date else None, 'bookings_by_status': bookings_by_status, 'top_rooms': top_rooms if top_rooms else None, 'service_usage': service_usage if service_usage else None})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/dashboard')
|
||||
async def get_dashboard_stats(current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
|
||||
try:
|
||||
total_bookings = db.query(Booking).count()
|
||||
active_bookings = db.query(Booking).filter(Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count()
|
||||
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
|
||||
total_bookings = db.query(Booking).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
active_bookings = db.query(Booking).filter(Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
total_revenue = db.query(func.sum(Payment.amount)).filter(Payment.payment_status == PaymentStatus.completed).scalar() or 0.0
|
||||
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_revenue = db.query(func.sum(Payment.amount)).filter(and_(Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= today_start)).scalar() or 0.0
|
||||
total_rooms = db.query(Room).count()
|
||||
available_rooms = db.query(Room).filter(Room.status == 'available').count()
|
||||
available_rooms = db.query(Room).filter(Room.status == RoomStatus.available).count()
|
||||
week_ago = datetime.utcnow() - timedelta(days=7)
|
||||
recent_bookings = db.query(Booking).filter(Booking.created_at >= week_ago).count()
|
||||
recent_bookings = db.query(Booking).filter(Booking.created_at >= week_ago).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
pending_payments = db.query(Payment).filter(Payment.payment_status == PaymentStatus.pending).count()
|
||||
return {'status': 'success', 'data': {'total_bookings': total_bookings, 'active_bookings': active_bookings, 'total_revenue': float(total_revenue), 'today_revenue': float(today_revenue), 'total_rooms': total_rooms, 'available_rooms': available_rooms, 'recent_bookings': recent_bookings, 'pending_payments': pending_payments}}
|
||||
return success_response(data={'total_bookings': total_bookings, 'active_bookings': active_bookings, 'total_revenue': float(total_revenue), 'today_revenue': float(today_revenue), 'total_rooms': total_rooms, 'available_rooms': available_rooms, 'recent_bookings': recent_bookings, 'pending_payments': pending_payments})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -103,19 +107,28 @@ async def get_dashboard_stats(current_user: User=Depends(authorize_roles('admin'
|
||||
async def get_customer_dashboard_stats(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
total_bookings = db.query(Booking).filter(Booking.user_id == current_user.id).count()
|
||||
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
|
||||
total_bookings = db.query(Booking).filter(Booking.user_id == current_user.id).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
user_bookings = db.query(Booking.id).filter(Booking.user_id == current_user.id).subquery()
|
||||
total_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed)).scalar() or 0.0
|
||||
now = datetime.utcnow()
|
||||
currently_staying = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status == BookingStatus.checked_in, Booking.check_in_date <= now, Booking.check_out_date >= now)).count()
|
||||
upcoming_bookings_query = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]), Booking.check_in_date > now)).order_by(Booking.check_in_date.asc()).limit(5).all()
|
||||
currently_staying = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status == BookingStatus.checked_in, Booking.check_in_date <= now, Booking.check_out_date >= now)).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
# Use load_only to exclude non-existent columns and eagerly load room relationships
|
||||
upcoming_bookings_query = db.query(Booking).options(
|
||||
load_only(Booking.id, Booking.booking_number, Booking.check_in_date, Booking.check_out_date, Booking.status, Booking.total_price, Booking.user_id, Booking.room_id, Booking.created_at),
|
||||
joinedload(Booking.room).joinedload(Room.room_type)
|
||||
).filter(and_(Booking.user_id == current_user.id, Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]), Booking.check_in_date > now)).order_by(Booking.check_in_date.asc()).limit(5).all()
|
||||
upcoming_bookings = []
|
||||
for booking in upcoming_bookings_query:
|
||||
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'check_in_date': booking.check_in_date.isoformat() if booking.check_in_date else None, 'check_out_date': booking.check_out_date.isoformat() if booking.check_out_date else None, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'total_price': float(booking.total_price) if booking.total_price else 0.0}
|
||||
if booking.room:
|
||||
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'room_type': {'name': booking.room.room_type.name if booking.room.room_type else None}}
|
||||
upcoming_bookings.append(booking_dict)
|
||||
recent_bookings_query = db.query(Booking).filter(Booking.user_id == current_user.id).order_by(Booking.created_at.desc()).limit(5).all()
|
||||
# Use load_only to exclude non-existent columns and eagerly load room relationships
|
||||
recent_bookings_query = db.query(Booking).options(
|
||||
load_only(Booking.id, Booking.booking_number, Booking.status, Booking.user_id, Booking.room_id, Booking.created_at),
|
||||
joinedload(Booking.room)
|
||||
).filter(Booking.user_id == current_user.id).order_by(Booking.created_at.desc()).limit(5).all()
|
||||
recent_activity = []
|
||||
for booking in recent_bookings_query:
|
||||
activity_type = None
|
||||
@@ -135,8 +148,9 @@ async def get_customer_dashboard_stats(current_user: User=Depends(get_current_us
|
||||
recent_activity.append(activity_dict)
|
||||
last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0)
|
||||
last_month_end = now.replace(day=1, hour=0, minute=0, second=0) - timedelta(seconds=1)
|
||||
last_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= last_month_start, Booking.created_at <= last_month_end)).count()
|
||||
this_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0), Booking.created_at <= now)).count()
|
||||
# Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
|
||||
last_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= last_month_start, Booking.created_at <= last_month_end)).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
this_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0), Booking.created_at <= now)).with_entities(func.count(Booking.id)).scalar() or 0
|
||||
booking_change_percentage = 0
|
||||
if last_month_bookings > 0:
|
||||
booking_change_percentage = (this_month_bookings - last_month_bookings) / last_month_bookings * 100
|
||||
@@ -145,7 +159,7 @@ async def get_customer_dashboard_stats(current_user: User=Depends(get_current_us
|
||||
spending_change_percentage = 0
|
||||
if last_month_spending > 0:
|
||||
spending_change_percentage = (this_month_spending - last_month_spending) / last_month_spending * 100
|
||||
return {'status': 'success', 'success': True, 'data': {'total_bookings': total_bookings, 'total_spending': float(total_spending), 'currently_staying': currently_staying, 'upcoming_bookings': upcoming_bookings, 'recent_activity': recent_activity, 'booking_change_percentage': round(booking_change_percentage, 1), 'spending_change_percentage': round(spending_change_percentage, 1)}}
|
||||
return success_response(data={'total_bookings': total_bookings, 'total_spending': float(total_spending), 'currently_staying': currently_staying, 'upcoming_bookings': upcoming_bookings, 'recent_activity': recent_activity, 'booking_change_percentage': round(booking_change_percentage, 1), 'spending_change_percentage': round(spending_change_percentage, 1)})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -167,6 +181,6 @@ async def get_revenue_report(start_date: Optional[str]=Query(None), end_date: Op
|
||||
method_breakdown[method_name] = float(total or 0)
|
||||
daily_revenue = db.query(func.date(Payment.payment_date).label('date'), func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all()
|
||||
daily_breakdown = [{'date': date.isoformat() if isinstance(date, datetime) else str(date), 'revenue': float(total or 0)} for date, total in daily_revenue]
|
||||
return {'status': 'success', 'data': {'total_revenue': float(total_revenue), 'revenue_by_method': method_breakdown, 'daily_breakdown': daily_breakdown}}
|
||||
return success_response(data={'total_revenue': float(total_revenue), 'revenue_by_method': method_breakdown, 'daily_breakdown': daily_breakdown})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
router = APIRouter(prefix='/rooms', tags=['rooms'])
|
||||
|
||||
@router.get('/')
|
||||
async def get_rooms(request: Request, type: Optional[str]=Query(None), minPrice: Optional[float]=Query(None), maxPrice: Optional[float]=Query(None), capacity: Optional[int]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), sort: Optional[str]=Query(None), featured: Optional[bool]=Query(None), db: Session=Depends(get_db)):
|
||||
async def get_rooms(request: Request, type: Optional[str]=Query(None), minPrice: Optional[float]=Query(None), maxPrice: Optional[float]=Query(None), capacity: Optional[int]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=1000), sort: Optional[str]=Query(None), featured: Optional[bool]=Query(None), db: Session=Depends(get_db)):
|
||||
try:
|
||||
where_clause = {}
|
||||
room_type_where = {}
|
||||
@@ -90,6 +90,32 @@ async def search_available_rooms(request: Request, from_date: str=Query(..., ali
|
||||
overlapping = db.query(Booking).filter(and_(Booking.room_id == roomId, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first()
|
||||
if overlapping:
|
||||
return {'status': 'success', 'data': {'available': False, 'message': 'Room is already booked for the selected dates', 'room_id': roomId}}
|
||||
|
||||
# Check for maintenance blocks
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceStatus
|
||||
maintenance_block = db.query(RoomMaintenance).filter(
|
||||
and_(
|
||||
RoomMaintenance.room_id == roomId,
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]),
|
||||
or_(
|
||||
and_(
|
||||
RoomMaintenance.block_start.isnot(None),
|
||||
RoomMaintenance.block_end.isnot(None),
|
||||
RoomMaintenance.block_start < check_out,
|
||||
RoomMaintenance.block_end > check_in
|
||||
),
|
||||
and_(
|
||||
RoomMaintenance.scheduled_start < check_out,
|
||||
RoomMaintenance.scheduled_end.isnot(None),
|
||||
RoomMaintenance.scheduled_end > check_in
|
||||
)
|
||||
)
|
||||
)
|
||||
).first()
|
||||
if maintenance_block:
|
||||
return {'status': 'success', 'data': {'available': False, 'message': f'Room is blocked for maintenance: {maintenance_block.title}', 'room_id': roomId}}
|
||||
|
||||
return {'status': 'success', 'data': {'available': True, 'message': 'Room is available', 'room_id': roomId}}
|
||||
if check_in >= check_out:
|
||||
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date')
|
||||
@@ -100,6 +126,29 @@ async def search_available_rooms(request: Request, from_date: str=Query(..., ali
|
||||
query = query.filter(RoomType.capacity >= capacity)
|
||||
overlapping_rooms = db.query(Booking.room_id).filter(and_(Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).subquery()
|
||||
query = query.filter(~Room.id.in_(db.query(overlapping_rooms.c.room_id)))
|
||||
|
||||
# Exclude rooms blocked by maintenance
|
||||
from ..models.room_maintenance import RoomMaintenance, MaintenanceStatus
|
||||
blocked_rooms = db.query(RoomMaintenance.room_id).filter(
|
||||
and_(
|
||||
RoomMaintenance.blocks_room == True,
|
||||
RoomMaintenance.status.in_([MaintenanceStatus.scheduled, MaintenanceStatus.in_progress]),
|
||||
or_(
|
||||
and_(
|
||||
RoomMaintenance.block_start.isnot(None),
|
||||
RoomMaintenance.block_end.isnot(None),
|
||||
RoomMaintenance.block_start < check_out,
|
||||
RoomMaintenance.block_end > check_in
|
||||
),
|
||||
and_(
|
||||
RoomMaintenance.scheduled_start < check_out,
|
||||
RoomMaintenance.scheduled_end.isnot(None),
|
||||
RoomMaintenance.scheduled_end > check_in
|
||||
)
|
||||
)
|
||||
)
|
||||
).subquery()
|
||||
query = query.filter(~Room.id.in_(db.query(blocked_rooms.c.room_id)))
|
||||
total = query.count()
|
||||
query = query.order_by(Room.featured.desc(), Room.created_at.desc())
|
||||
offset = (page - 1) * limit
|
||||
|
||||
744
Backend/src/routes/security_routes.py
Normal file
744
Backend/src/routes/security_routes.py
Normal file
@@ -0,0 +1,744 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from pydantic import BaseModel, EmailStr
|
||||
import logging
|
||||
from ..config.logging_config import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.security_event import (
|
||||
SecurityEvent,
|
||||
SecurityEventType,
|
||||
SecurityEventSeverity,
|
||||
IPWhitelist,
|
||||
IPBlacklist
|
||||
)
|
||||
from ..services.security_monitoring_service import security_monitoring_service
|
||||
from ..services.gdpr_service import gdpr_service
|
||||
from ..services.encryption_service import encryption_service
|
||||
from ..services.security_scan_service import security_scan_service
|
||||
|
||||
# OAuth service is optional - only import if httpx is available
|
||||
try:
|
||||
from ..services.oauth_service import oauth_service
|
||||
OAUTH_AVAILABLE = True
|
||||
except ImportError:
|
||||
OAUTH_AVAILABLE = False
|
||||
oauth_service = None
|
||||
|
||||
router = APIRouter(prefix="/security", tags=["Security"])
|
||||
|
||||
# Security Events
|
||||
class SecurityEventResponse(BaseModel):
|
||||
id: int
|
||||
user_id: Optional[int]
|
||||
event_type: str
|
||||
severity: str
|
||||
ip_address: Optional[str]
|
||||
description: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@router.get("/events", response_model=List[SecurityEventResponse])
|
||||
async def get_security_events(
|
||||
user_id: Optional[int] = Query(None),
|
||||
event_type: Optional[str] = Query(None),
|
||||
severity: Optional[str] = Query(None),
|
||||
ip_address: Optional[str] = Query(None),
|
||||
resolved: Optional[bool] = Query(None),
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get security events"""
|
||||
|
||||
event_type_enum = None
|
||||
if event_type:
|
||||
try:
|
||||
event_type_enum = SecurityEventType(event_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid event type")
|
||||
|
||||
severity_enum = None
|
||||
if severity:
|
||||
try:
|
||||
severity_enum = SecurityEventSeverity(severity)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid severity")
|
||||
|
||||
start_date = datetime.utcnow() - timedelta(days=days)
|
||||
|
||||
events = security_monitoring_service.get_security_events(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
event_type=event_type_enum,
|
||||
severity=severity_enum,
|
||||
ip_address=ip_address,
|
||||
resolved=resolved,
|
||||
start_date=start_date,
|
||||
limit=limit,
|
||||
offset=offset
|
||||
)
|
||||
|
||||
return events
|
||||
|
||||
@router.get("/events/stats")
|
||||
async def get_security_stats(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get security statistics"""
|
||||
|
||||
stats = security_monitoring_service.get_security_stats(db=db, days=days)
|
||||
return stats
|
||||
|
||||
@router.post("/events/{event_id}/resolve")
|
||||
async def resolve_security_event(
|
||||
event_id: int,
|
||||
resolution_notes: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Mark a security event as resolved"""
|
||||
|
||||
try:
|
||||
event = security_monitoring_service.resolve_event(
|
||||
db=db,
|
||||
event_id=event_id,
|
||||
resolved_by=current_user.id,
|
||||
resolution_notes=resolution_notes
|
||||
)
|
||||
return {"status": "success", "message": "Event resolved", "event_id": event.id}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# IP Whitelist/Blacklist
|
||||
class IPWhitelistCreate(BaseModel):
|
||||
ip_address: str
|
||||
description: Optional[str] = None
|
||||
|
||||
class IPBlacklistCreate(BaseModel):
|
||||
ip_address: str
|
||||
reason: Optional[str] = None
|
||||
blocked_until: Optional[datetime] = None
|
||||
|
||||
@router.post("/ip/whitelist")
|
||||
async def add_ip_to_whitelist(
|
||||
data: IPWhitelistCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Add IP address to whitelist"""
|
||||
|
||||
# Check if already exists
|
||||
existing = db.query(IPWhitelist).filter(
|
||||
IPWhitelist.ip_address == data.ip_address
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
existing.description = data.description
|
||||
db.commit()
|
||||
return {"status": "success", "message": "IP whitelist updated"}
|
||||
|
||||
whitelist = IPWhitelist(
|
||||
ip_address=data.ip_address,
|
||||
description=data.description,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(whitelist)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "IP added to whitelist"}
|
||||
|
||||
@router.delete("/ip/whitelist/{ip_address}")
|
||||
async def remove_ip_from_whitelist(
|
||||
ip_address: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Remove IP address from whitelist"""
|
||||
|
||||
whitelist = db.query(IPWhitelist).filter(
|
||||
IPWhitelist.ip_address == ip_address
|
||||
).first()
|
||||
|
||||
if not whitelist:
|
||||
raise HTTPException(status_code=404, detail="IP not found in whitelist")
|
||||
|
||||
whitelist.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "IP removed from whitelist"}
|
||||
|
||||
@router.get("/ip/whitelist")
|
||||
async def get_whitelisted_ips(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get all whitelisted IPs"""
|
||||
|
||||
whitelist = db.query(IPWhitelist).filter(
|
||||
IPWhitelist.is_active == True
|
||||
).all()
|
||||
|
||||
return [{"id": w.id, "ip_address": w.ip_address, "description": w.description} for w in whitelist]
|
||||
|
||||
@router.post("/ip/blacklist")
|
||||
async def add_ip_to_blacklist(
|
||||
data: IPBlacklistCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Add IP address to blacklist"""
|
||||
|
||||
existing = db.query(IPBlacklist).filter(
|
||||
IPBlacklist.ip_address == data.ip_address
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
existing.is_active = True
|
||||
existing.reason = data.reason
|
||||
existing.blocked_until = data.blocked_until
|
||||
db.commit()
|
||||
return {"status": "success", "message": "IP blacklist updated"}
|
||||
|
||||
blacklist = IPBlacklist(
|
||||
ip_address=data.ip_address,
|
||||
reason=data.reason,
|
||||
blocked_until=data.blocked_until,
|
||||
created_by=current_user.id
|
||||
)
|
||||
db.add(blacklist)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "IP added to blacklist"}
|
||||
|
||||
@router.delete("/ip/blacklist/{ip_address}")
|
||||
async def remove_ip_from_blacklist(
|
||||
ip_address: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Remove IP address from blacklist"""
|
||||
|
||||
blacklist = db.query(IPBlacklist).filter(
|
||||
IPBlacklist.ip_address == ip_address
|
||||
).first()
|
||||
|
||||
if not blacklist:
|
||||
raise HTTPException(status_code=404, detail="IP not found in blacklist")
|
||||
|
||||
blacklist.is_active = False
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "IP removed from blacklist"}
|
||||
|
||||
@router.get("/ip/blacklist")
|
||||
async def get_blacklisted_ips(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get all blacklisted IPs"""
|
||||
|
||||
blacklist = db.query(IPBlacklist).filter(
|
||||
IPBlacklist.is_active == True
|
||||
).all()
|
||||
|
||||
return [{"id": b.id, "ip_address": b.ip_address, "reason": b.reason, "blocked_until": b.blocked_until} for b in blacklist]
|
||||
|
||||
# OAuth Provider Management
|
||||
class OAuthProviderCreate(BaseModel):
|
||||
name: str
|
||||
display_name: str
|
||||
client_id: str
|
||||
client_secret: str
|
||||
authorization_url: str
|
||||
token_url: str
|
||||
userinfo_url: str
|
||||
scopes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_sso_enabled: bool = False
|
||||
|
||||
class OAuthProviderUpdate(BaseModel):
|
||||
display_name: Optional[str] = None
|
||||
client_id: Optional[str] = None
|
||||
client_secret: Optional[str] = None
|
||||
authorization_url: Optional[str] = None
|
||||
token_url: Optional[str] = None
|
||||
userinfo_url: Optional[str] = None
|
||||
scopes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_sso_enabled: Optional[bool] = None
|
||||
|
||||
@router.get("/oauth/providers")
|
||||
async def get_oauth_providers(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get all OAuth providers"""
|
||||
from ..models.security_event import OAuthProvider
|
||||
providers = db.query(OAuthProvider).all()
|
||||
return [{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"display_name": p.display_name,
|
||||
"is_active": p.is_active,
|
||||
"is_sso_enabled": p.is_sso_enabled,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None
|
||||
} for p in providers]
|
||||
|
||||
@router.post("/oauth/providers")
|
||||
async def create_oauth_provider(
|
||||
data: OAuthProviderCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Create a new OAuth provider"""
|
||||
from ..models.security_event import OAuthProvider
|
||||
from ..services.encryption_service import encryption_service
|
||||
|
||||
# Encrypt client secret
|
||||
encrypted_secret = encryption_service.encrypt(data.client_secret)
|
||||
|
||||
provider = OAuthProvider(
|
||||
name=data.name,
|
||||
display_name=data.display_name,
|
||||
client_id=data.client_id,
|
||||
client_secret=encrypted_secret,
|
||||
authorization_url=data.authorization_url,
|
||||
token_url=data.token_url,
|
||||
userinfo_url=data.userinfo_url,
|
||||
scopes=data.scopes,
|
||||
is_active=data.is_active,
|
||||
is_sso_enabled=data.is_sso_enabled
|
||||
)
|
||||
db.add(provider)
|
||||
db.commit()
|
||||
db.refresh(provider)
|
||||
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.display_name,
|
||||
"is_active": provider.is_active,
|
||||
"is_sso_enabled": provider.is_sso_enabled
|
||||
}
|
||||
|
||||
@router.put("/oauth/providers/{provider_id}")
|
||||
async def update_oauth_provider(
|
||||
provider_id: int,
|
||||
data: OAuthProviderUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Update an OAuth provider"""
|
||||
from ..models.security_event import OAuthProvider
|
||||
from ..services.encryption_service import encryption_service
|
||||
|
||||
provider = db.query(OAuthProvider).filter(OAuthProvider.id == provider_id).first()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="OAuth provider not found")
|
||||
|
||||
if data.display_name is not None:
|
||||
provider.display_name = data.display_name
|
||||
if data.client_id is not None:
|
||||
provider.client_id = data.client_id
|
||||
if data.client_secret is not None:
|
||||
provider.client_secret = encryption_service.encrypt(data.client_secret)
|
||||
if data.authorization_url is not None:
|
||||
provider.authorization_url = data.authorization_url
|
||||
if data.token_url is not None:
|
||||
provider.token_url = data.token_url
|
||||
if data.userinfo_url is not None:
|
||||
provider.userinfo_url = data.userinfo_url
|
||||
if data.scopes is not None:
|
||||
provider.scopes = data.scopes
|
||||
if data.is_active is not None:
|
||||
provider.is_active = data.is_active
|
||||
if data.is_sso_enabled is not None:
|
||||
provider.is_sso_enabled = data.is_sso_enabled
|
||||
|
||||
db.commit()
|
||||
db.refresh(provider)
|
||||
|
||||
return {
|
||||
"id": provider.id,
|
||||
"name": provider.name,
|
||||
"display_name": provider.display_name,
|
||||
"is_active": provider.is_active,
|
||||
"is_sso_enabled": provider.is_sso_enabled
|
||||
}
|
||||
|
||||
@router.delete("/oauth/providers/{provider_id}")
|
||||
async def delete_oauth_provider(
|
||||
provider_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Delete an OAuth provider"""
|
||||
from ..models.security_event import OAuthProvider
|
||||
|
||||
provider = db.query(OAuthProvider).filter(OAuthProvider.id == provider_id).first()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="OAuth provider not found")
|
||||
|
||||
db.delete(provider)
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "OAuth provider deleted"}
|
||||
|
||||
# GDPR Request Management
|
||||
@router.get("/gdpr/requests")
|
||||
async def get_gdpr_requests(
|
||||
status: Optional[str] = Query(None),
|
||||
request_type: Optional[str] = Query(None),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get all GDPR requests"""
|
||||
from ..models.gdpr_compliance import DataSubjectRequest, DataSubjectRequestStatus, DataSubjectRequestType
|
||||
|
||||
query = db.query(DataSubjectRequest)
|
||||
|
||||
if status:
|
||||
try:
|
||||
status_enum = DataSubjectRequestStatus(status)
|
||||
query = query.filter(DataSubjectRequest.status == status_enum)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if request_type:
|
||||
try:
|
||||
type_enum = DataSubjectRequestType(request_type)
|
||||
query = query.filter(DataSubjectRequest.request_type == type_enum)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
requests = query.order_by(DataSubjectRequest.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
return [{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"email": r.email,
|
||||
"request_type": r.request_type.value,
|
||||
"status": r.status.value,
|
||||
"description": r.description,
|
||||
"verified": r.verified,
|
||||
"verified_at": r.verified_at.isoformat() if r.verified_at else None,
|
||||
"assigned_to": r.assigned_to,
|
||||
"completed_at": r.completed_at.isoformat() if r.completed_at else None,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None
|
||||
} for r in requests]
|
||||
|
||||
@router.get("/gdpr/requests/{request_id}")
|
||||
async def get_gdpr_request(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Get a specific GDPR request"""
|
||||
from ..models.gdpr_compliance import DataSubjectRequest
|
||||
|
||||
request = db.query(DataSubjectRequest).filter(DataSubjectRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="GDPR request not found")
|
||||
|
||||
return {
|
||||
"id": request.id,
|
||||
"user_id": request.user_id,
|
||||
"email": request.email,
|
||||
"request_type": request.request_type.value,
|
||||
"status": request.status.value,
|
||||
"description": request.description,
|
||||
"verified": request.verified,
|
||||
"verified_at": request.verified_at.isoformat() if request.verified_at else None,
|
||||
"assigned_to": request.assigned_to,
|
||||
"notes": request.notes,
|
||||
"response_data": request.response_data,
|
||||
"completed_at": request.completed_at.isoformat() if request.completed_at else None,
|
||||
"created_at": request.created_at.isoformat() if request.created_at else None
|
||||
}
|
||||
|
||||
@router.post("/gdpr/requests/{request_id}/assign")
|
||||
async def assign_gdpr_request(
|
||||
request_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Assign a GDPR request to the current admin"""
|
||||
from ..models.gdpr_compliance import DataSubjectRequest
|
||||
|
||||
request = db.query(DataSubjectRequest).filter(DataSubjectRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="GDPR request not found")
|
||||
|
||||
request.assigned_to = current_user.id
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Request assigned"}
|
||||
|
||||
@router.post("/gdpr/requests/{request_id}/complete")
|
||||
async def complete_gdpr_request(
|
||||
request_id: int,
|
||||
notes: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Mark a GDPR request as completed"""
|
||||
from ..models.gdpr_compliance import DataSubjectRequest, DataSubjectRequestStatus
|
||||
|
||||
request = db.query(DataSubjectRequest).filter(DataSubjectRequest.id == request_id).first()
|
||||
if not request:
|
||||
raise HTTPException(status_code=404, detail="GDPR request not found")
|
||||
|
||||
request.status = DataSubjectRequestStatus.completed
|
||||
request.completed_at = datetime.utcnow()
|
||||
request.completed_by = current_user.id
|
||||
if notes:
|
||||
request.notes = notes
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"status": "success", "message": "Request completed"}
|
||||
|
||||
# OAuth Routes
|
||||
@router.get("/oauth/{provider_name}/authorize")
|
||||
async def oauth_authorize(
|
||||
provider_name: str,
|
||||
redirect_uri: str = Query(...),
|
||||
state: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get OAuth authorization URL"""
|
||||
if not OAUTH_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="OAuth service is not available. Please install httpx: pip install httpx")
|
||||
|
||||
try:
|
||||
auth_url = oauth_service.get_authorization_url(
|
||||
db=db,
|
||||
provider_name=provider_name,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state
|
||||
)
|
||||
return {"authorization_url": auth_url}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/oauth/{provider_name}/callback")
|
||||
async def oauth_callback(
|
||||
provider_name: str,
|
||||
code: str = Query(...),
|
||||
redirect_uri: str = Query(...),
|
||||
state: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Handle OAuth callback"""
|
||||
if not OAUTH_AVAILABLE:
|
||||
raise HTTPException(status_code=503, detail="OAuth service is not available. Please install httpx: pip install httpx")
|
||||
|
||||
try:
|
||||
# Exchange code for token
|
||||
token_data = await oauth_service.exchange_code_for_token(
|
||||
db=db,
|
||||
provider_name=provider_name,
|
||||
code=code,
|
||||
redirect_uri=redirect_uri
|
||||
)
|
||||
|
||||
# Get user info
|
||||
user_info = await oauth_service.get_user_info(
|
||||
db=db,
|
||||
provider_name=provider_name,
|
||||
access_token=token_data['access_token']
|
||||
)
|
||||
|
||||
# Find or create user
|
||||
user = oauth_service.find_or_create_user_from_oauth(
|
||||
db=db,
|
||||
provider_name=provider_name,
|
||||
user_info=user_info
|
||||
)
|
||||
|
||||
# Save OAuth token
|
||||
from ..models.security_event import OAuthProvider
|
||||
provider = db.query(OAuthProvider).filter(
|
||||
OAuthProvider.name == provider_name
|
||||
).first()
|
||||
|
||||
oauth_service.save_oauth_token(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
provider_id=provider.id,
|
||||
provider_user_id=user_info.get('sub') or user_info.get('id'),
|
||||
access_token=token_data['access_token'],
|
||||
refresh_token=token_data.get('refresh_token'),
|
||||
expires_in=token_data.get('expires_in'),
|
||||
scopes=token_data.get('scope')
|
||||
)
|
||||
|
||||
# Generate JWT tokens for the user
|
||||
from ..services.auth_service import auth_service
|
||||
tokens = auth_service.generate_tokens(user.id)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"token": tokens["accessToken"],
|
||||
"refreshToken": tokens["refreshToken"],
|
||||
"user": {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"full_name": user.full_name
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# GDPR Routes
|
||||
class DataSubjectRequestCreate(BaseModel):
|
||||
email: EmailStr
|
||||
request_type: str
|
||||
description: Optional[str] = None
|
||||
|
||||
@router.post("/gdpr/request")
|
||||
async def create_data_subject_request(
|
||||
data: DataSubjectRequestCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a GDPR data subject request"""
|
||||
|
||||
try:
|
||||
request_type = DataSubjectRequestType(data.request_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid request type")
|
||||
|
||||
try:
|
||||
gdpr_request = gdpr_service.create_data_subject_request(
|
||||
db=db,
|
||||
email=data.email,
|
||||
request_type=request_type,
|
||||
description=data.description,
|
||||
ip_address=request.client.host if request.client else None,
|
||||
user_agent=request.headers.get("User-Agent")
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Request created. Please check your email for verification.",
|
||||
"verification_token": gdpr_request.verification_token
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
@router.post("/gdpr/verify/{verification_token}")
|
||||
async def verify_data_subject_request(
|
||||
verification_token: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Verify a data subject request"""
|
||||
|
||||
verified = gdpr_service.verify_request(db=db, verification_token=verification_token)
|
||||
|
||||
if not verified:
|
||||
raise HTTPException(status_code=404, detail="Invalid verification token")
|
||||
|
||||
return {"status": "success", "message": "Request verified"}
|
||||
|
||||
@router.get("/gdpr/data/{user_id}")
|
||||
async def get_user_data(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get user data (GDPR access request)"""
|
||||
# Users can only access their own data, unless admin
|
||||
if current_user.id != user_id:
|
||||
# Check if user is admin
|
||||
from ..models.role import Role
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if not role or role.name != "admin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
try:
|
||||
data = gdpr_service.get_user_data(db=db, user_id=user_id)
|
||||
return {"status": "success", "data": data}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
@router.delete("/gdpr/data/{user_id}")
|
||||
async def delete_user_data(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Delete user data (GDPR erasure request)"""
|
||||
|
||||
success = gdpr_service.delete_user_data(db=db, user_id=user_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return {"status": "success", "message": "User data deleted"}
|
||||
|
||||
@router.get("/gdpr/export/{user_id}")
|
||||
async def export_user_data(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Export user data (GDPR portability request)"""
|
||||
if current_user.id != user_id:
|
||||
# Check if user is admin
|
||||
from ..models.role import Role
|
||||
role = db.query(Role).filter(Role.id == current_user.role_id).first()
|
||||
if not role or role.name != "admin":
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
try:
|
||||
data = gdpr_service.export_user_data(db=db, user_id=user_id)
|
||||
return {"status": "success", "data": data}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# Security Scanning
|
||||
@router.post("/scan/run")
|
||||
async def run_security_scan(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Run a manual security scan"""
|
||||
try:
|
||||
results = security_scan_service.run_full_scan(db=db)
|
||||
return {"status": "success", "results": results}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
logger.error(f"Security scan failed: {str(e)}\n{error_details}")
|
||||
raise HTTPException(status_code=500, detail=f"Scan failed: {str(e)}")
|
||||
|
||||
@router.post("/scan/schedule")
|
||||
async def schedule_security_scan(
|
||||
interval_hours: int = Query(24, ge=1, le=168), # 1 hour to 1 week
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(authorize_roles("admin"))
|
||||
):
|
||||
"""Schedule automatic security scans"""
|
||||
try:
|
||||
schedule = security_scan_service.schedule_scan(db=db, interval_hours=interval_hours)
|
||||
return {"status": "success", "schedule": schedule}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to schedule scan: {str(e)}")
|
||||
|
||||
@@ -7,6 +7,7 @@ import random
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import get_current_user
|
||||
from ..models.user import User
|
||||
from ..utils.role_helpers import is_admin
|
||||
from ..models.service import Service
|
||||
from ..models.service_booking import (
|
||||
ServiceBooking,
|
||||
@@ -212,8 +213,7 @@ async def get_service_booking_by_id(
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Service booking not found")
|
||||
|
||||
|
||||
if booking.user_id != current_user.id and current_user.role_id != 1:
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
booking_dict = {
|
||||
@@ -281,10 +281,9 @@ async def create_service_stripe_payment_intent(
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Service booking not found")
|
||||
|
||||
if booking.user_id != current_user.id and current_user.role_id != 1:
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
if abs(float(booking.total_amount) - amount) > 0.01:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -341,10 +340,9 @@ async def confirm_service_stripe_payment(
|
||||
if not booking:
|
||||
raise HTTPException(status_code=404, detail="Service booking not found")
|
||||
|
||||
if booking.user_id != current_user.id and current_user.role_id != 1:
|
||||
if not is_admin(current_user, db) and booking.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Forbidden")
|
||||
|
||||
|
||||
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
|
||||
|
||||
if intent_data["status"] != "succeeded":
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
@@ -462,6 +462,366 @@ async def update_paypal_settings(
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/borica")
|
||||
async def get_borica_settings(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
terminal_id_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_terminal_id"
|
||||
).first()
|
||||
|
||||
merchant_id_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_merchant_id"
|
||||
).first()
|
||||
|
||||
private_key_path_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_private_key_path"
|
||||
).first()
|
||||
|
||||
certificate_path_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_certificate_path"
|
||||
).first()
|
||||
|
||||
gateway_url_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_gateway_url"
|
||||
).first()
|
||||
|
||||
mode_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_mode"
|
||||
).first()
|
||||
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return ""
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
result = {
|
||||
"borica_terminal_id": "",
|
||||
"borica_merchant_id": "",
|
||||
"borica_private_key_path": "",
|
||||
"borica_certificate_path": "",
|
||||
"borica_gateway_url": "",
|
||||
"borica_mode": "test",
|
||||
"borica_terminal_id_masked": "",
|
||||
"borica_merchant_id_masked": "",
|
||||
"has_terminal_id": False,
|
||||
"has_merchant_id": False,
|
||||
"has_private_key_path": False,
|
||||
"has_certificate_path": False,
|
||||
}
|
||||
|
||||
if terminal_id_setting:
|
||||
result["borica_terminal_id"] = terminal_id_setting.value
|
||||
result["borica_terminal_id_masked"] = mask_key(terminal_id_setting.value) if terminal_id_setting.value else ""
|
||||
result["has_terminal_id"] = bool(terminal_id_setting.value)
|
||||
result["updated_at"] = terminal_id_setting.updated_at.isoformat() if terminal_id_setting.updated_at else None
|
||||
result["updated_by"] = terminal_id_setting.updated_by.full_name if terminal_id_setting.updated_by else None
|
||||
|
||||
if merchant_id_setting:
|
||||
result["borica_merchant_id"] = merchant_id_setting.value
|
||||
result["borica_merchant_id_masked"] = mask_key(merchant_id_setting.value) if merchant_id_setting.value else ""
|
||||
result["has_merchant_id"] = bool(merchant_id_setting.value)
|
||||
|
||||
if private_key_path_setting:
|
||||
result["borica_private_key_path"] = private_key_path_setting.value
|
||||
result["has_private_key_path"] = bool(private_key_path_setting.value)
|
||||
|
||||
if certificate_path_setting:
|
||||
result["borica_certificate_path"] = certificate_path_setting.value
|
||||
result["has_certificate_path"] = bool(certificate_path_setting.value)
|
||||
|
||||
if gateway_url_setting:
|
||||
result["borica_gateway_url"] = gateway_url_setting.value or ""
|
||||
|
||||
if mode_setting:
|
||||
result["borica_mode"] = mode_setting.value or "test"
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": result
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put("/borica")
|
||||
async def update_borica_settings(
|
||||
borica_data: dict,
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
try:
|
||||
terminal_id = borica_data.get("borica_terminal_id", "").strip()
|
||||
merchant_id = borica_data.get("borica_merchant_id", "").strip()
|
||||
private_key_path = borica_data.get("borica_private_key_path", "").strip()
|
||||
certificate_path = borica_data.get("borica_certificate_path", "").strip()
|
||||
gateway_url = borica_data.get("borica_gateway_url", "").strip()
|
||||
mode = borica_data.get("borica_mode", "test").strip().lower()
|
||||
|
||||
if mode and mode not in ["test", "production"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid Borica mode. Must be 'test' or 'production'"
|
||||
)
|
||||
|
||||
if terminal_id:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_terminal_id"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = terminal_id
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="borica_terminal_id",
|
||||
value=terminal_id,
|
||||
description="Borica Terminal ID for processing payments",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
if merchant_id:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_merchant_id"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = merchant_id
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="borica_merchant_id",
|
||||
value=merchant_id,
|
||||
description="Borica Merchant ID for processing payments",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
if private_key_path:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_private_key_path"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = private_key_path
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="borica_private_key_path",
|
||||
value=private_key_path,
|
||||
description="Path to Borica private key file",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
if certificate_path:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_certificate_path"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = certificate_path
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="borica_certificate_path",
|
||||
value=certificate_path,
|
||||
description="Path to Borica certificate file",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
if gateway_url:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_gateway_url"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = gateway_url
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="borica_gateway_url",
|
||||
value=gateway_url,
|
||||
description="Borica gateway URL (test or production)",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
if mode:
|
||||
setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == "borica_mode"
|
||||
).first()
|
||||
|
||||
if setting:
|
||||
setting.value = mode
|
||||
setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key="borica_mode",
|
||||
value=mode,
|
||||
description="Borica mode: test or production",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
|
||||
def mask_key(key_value: str) -> str:
|
||||
if not key_value or len(key_value) < 4:
|
||||
return ""
|
||||
return "*" * (len(key_value) - 4) + key_value[-4:]
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Borica settings updated successfully",
|
||||
"data": {
|
||||
"borica_terminal_id": terminal_id if terminal_id else "",
|
||||
"borica_merchant_id": merchant_id if merchant_id else "",
|
||||
"borica_private_key_path": private_key_path if private_key_path else "",
|
||||
"borica_certificate_path": certificate_path if certificate_path else "",
|
||||
"borica_gateway_url": gateway_url if gateway_url else "",
|
||||
"borica_mode": mode,
|
||||
"borica_terminal_id_masked": mask_key(terminal_id) if terminal_id else "",
|
||||
"borica_merchant_id_masked": mask_key(merchant_id) if merchant_id else "",
|
||||
"has_terminal_id": bool(terminal_id),
|
||||
"has_merchant_id": bool(merchant_id),
|
||||
"has_private_key_path": bool(private_key_path),
|
||||
"has_certificate_path": bool(certificate_path),
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post("/borica/upload-certificate")
|
||||
async def upload_borica_certificate(
|
||||
file: UploadFile = File(...),
|
||||
file_type: str = Form("private_key"), # "private_key" or "certificate"
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload Borica certificate or private key file.
|
||||
file_type: "private_key" or "certificate"
|
||||
"""
|
||||
try:
|
||||
if not file:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No file provided"
|
||||
)
|
||||
|
||||
if not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Filename is required"
|
||||
)
|
||||
|
||||
# Validate file type
|
||||
allowed_extensions = ['.pem', '.key', '.crt', '.cer', '.p12', '.pfx']
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid file type. Allowed extensions: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# Validate file_type parameter
|
||||
if file_type not in ["private_key", "certificate"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="file_type must be 'private_key' or 'certificate'"
|
||||
)
|
||||
|
||||
# Create upload directory
|
||||
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "certificates" / "borica"
|
||||
upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
file_type_prefix = "private_key" if file_type == "private_key" else "certificate"
|
||||
filename = f"borica_{file_type_prefix}_{uuid.uuid4()}{file_ext}"
|
||||
file_path = upload_dir / filename
|
||||
|
||||
# Read and save file
|
||||
content = await file.read()
|
||||
if not content:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="File is empty"
|
||||
)
|
||||
|
||||
# Validate file size (max 1MB for certificate files)
|
||||
max_size = 1024 * 1024 # 1MB
|
||||
if len(content) > max_size:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"File size exceeds maximum allowed size of {max_size / 1024}KB"
|
||||
)
|
||||
|
||||
# Save file
|
||||
async with aiofiles.open(file_path, 'wb') as f:
|
||||
await f.write(content)
|
||||
|
||||
# Get absolute path
|
||||
absolute_path = str(file_path.resolve())
|
||||
|
||||
# Update system settings with the new path
|
||||
setting_key = "borica_private_key_path" if file_type == "private_key" else "borica_certificate_path"
|
||||
|
||||
# Delete old file if exists
|
||||
old_setting = db.query(SystemSettings).filter(
|
||||
SystemSettings.key == setting_key
|
||||
).first()
|
||||
|
||||
if old_setting and old_setting.value:
|
||||
old_file_path = Path(old_setting.value)
|
||||
# Only delete if it's in our uploads directory (safety check)
|
||||
if old_file_path.exists() and str(old_file_path).startswith(str(upload_dir)):
|
||||
try:
|
||||
old_file_path.unlink()
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not delete old file {old_setting.value}: {e}")
|
||||
|
||||
# Update or create setting
|
||||
if old_setting:
|
||||
old_setting.value = absolute_path
|
||||
old_setting.updated_by_id = current_user.id
|
||||
else:
|
||||
setting = SystemSettings(
|
||||
key=setting_key,
|
||||
value=absolute_path,
|
||||
description=f"Path to Borica {file_type.replace('_', ' ')} file",
|
||||
updated_by_id=current_user.id
|
||||
)
|
||||
db.add(setting)
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"Borica {file_type.replace('_', ' ')} uploaded successfully",
|
||||
"data": {
|
||||
"file_path": absolute_path,
|
||||
"file_type": file_type,
|
||||
"filename": filename
|
||||
}
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error uploading Borica certificate: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error uploading file: {str(e)}"
|
||||
)
|
||||
|
||||
@router.get("/smtp")
|
||||
async def get_smtp_settings(
|
||||
current_user: User = Depends(authorize_roles("admin")),
|
||||
|
||||
418
Backend/src/routes/task_routes.py
Normal file
418
Backend/src/routes/task_routes.py
Normal file
@@ -0,0 +1,418 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import authorize_roles, get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.workflow import TaskStatus, TaskPriority
|
||||
from ..services.task_service import TaskService
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter(prefix='/tasks', tags=['tasks'])
|
||||
|
||||
# Request/Response Models
|
||||
class TaskCreate(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
task_type: str = 'general'
|
||||
priority: Optional[str] = 'medium'
|
||||
workflow_instance_id: Optional[int] = None
|
||||
booking_id: Optional[int] = None
|
||||
room_id: Optional[int] = None
|
||||
assigned_to: Optional[int] = None
|
||||
due_date: Optional[str] = None
|
||||
estimated_duration_minutes: Optional[int] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
assigned_to: Optional[int] = None
|
||||
due_date: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
actual_duration_minutes: Optional[int] = None
|
||||
|
||||
class TaskCommentCreate(BaseModel):
|
||||
comment: str
|
||||
|
||||
# Task CRUD
|
||||
@router.post('/')
|
||||
async def create_task(
|
||||
task_data: TaskCreate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new task"""
|
||||
try:
|
||||
due_date = None
|
||||
if task_data.due_date:
|
||||
try:
|
||||
due_date = datetime.fromisoformat(task_data.due_date.replace('Z', '+00:00'))
|
||||
except:
|
||||
due_date = datetime.strptime(task_data.due_date, '%Y-%m-%dT%H:%M:%S')
|
||||
|
||||
task = TaskService.create_task(
|
||||
db=db,
|
||||
title=task_data.title,
|
||||
created_by=current_user.id,
|
||||
task_type=task_data.task_type,
|
||||
description=task_data.description,
|
||||
priority=TaskPriority(task_data.priority) if task_data.priority else TaskPriority.medium,
|
||||
workflow_instance_id=task_data.workflow_instance_id,
|
||||
booking_id=task_data.booking_id,
|
||||
room_id=task_data.room_id,
|
||||
assigned_to=task_data.assigned_to,
|
||||
due_date=due_date,
|
||||
estimated_duration_minutes=task_data.estimated_duration_minutes,
|
||||
metadata=task_data.metadata
|
||||
)
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'title': task.title,
|
||||
'status': task.status.value,
|
||||
'priority': task.priority.value,
|
||||
'created_at': task.created_at.isoformat()
|
||||
}}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/')
|
||||
async def get_tasks(
|
||||
assigned_to: Optional[int] = Query(None),
|
||||
created_by: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
task_type: Optional[str] = Query(None),
|
||||
booking_id: Optional[int] = Query(None),
|
||||
room_id: Optional[int] = Query(None),
|
||||
workflow_instance_id: Optional[int] = Query(None),
|
||||
overdue_only: bool = Query(False),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get tasks"""
|
||||
try:
|
||||
tasks = TaskService.get_tasks(
|
||||
db=db,
|
||||
assigned_to=assigned_to,
|
||||
created_by=created_by,
|
||||
status=TaskStatus(status) if status else None,
|
||||
priority=TaskPriority(priority) if priority else None,
|
||||
task_type=task_type,
|
||||
booking_id=booking_id,
|
||||
room_id=room_id,
|
||||
workflow_instance_id=workflow_instance_id,
|
||||
overdue_only=overdue_only,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
# Build response data safely
|
||||
result = []
|
||||
for t in tasks:
|
||||
try:
|
||||
result.append({
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'task_type': t.task_type,
|
||||
'status': t.status.value,
|
||||
'priority': t.priority.value,
|
||||
'workflow_instance_id': t.workflow_instance_id,
|
||||
'booking_id': t.booking_id,
|
||||
'room_id': t.room_id,
|
||||
'assigned_to': t.assigned_to,
|
||||
'assigned_to_name': t.assignee.full_name if t.assignee else None,
|
||||
'created_by': t.created_by,
|
||||
'due_date': t.due_date.isoformat() if t.due_date else None,
|
||||
'completed_at': t.completed_at.isoformat() if t.completed_at else None,
|
||||
'estimated_duration_minutes': t.estimated_duration_minutes,
|
||||
'actual_duration_minutes': t.actual_duration_minutes,
|
||||
'notes': t.notes,
|
||||
'created_at': t.created_at.isoformat() if t.created_at else None,
|
||||
'updated_at': t.updated_at.isoformat() if t.updated_at else None
|
||||
})
|
||||
except Exception as task_error:
|
||||
# Log the error for this specific task but continue with others
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error serializing task {t.id}: {str(task_error)}")
|
||||
continue
|
||||
|
||||
return {'status': 'success', 'data': result}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error in get_tasks: {str(e)}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/my-tasks')
|
||||
async def get_my_tasks(
|
||||
status: Optional[str] = Query(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get tasks assigned to current user"""
|
||||
try:
|
||||
tasks = TaskService.get_my_tasks(
|
||||
db=db,
|
||||
user_id=current_user.id,
|
||||
status=TaskStatus(status) if status else None
|
||||
)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': t.id,
|
||||
'title': t.title,
|
||||
'description': t.description,
|
||||
'task_type': t.task_type,
|
||||
'status': t.status.value,
|
||||
'priority': t.priority.value,
|
||||
'due_date': t.due_date.isoformat() if t.due_date else None,
|
||||
'created_at': t.created_at.isoformat()
|
||||
} for t in tasks]}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/{task_id}')
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get task by ID"""
|
||||
try:
|
||||
task = TaskService.get_task_by_id(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
comments = TaskService.get_task_comments(db, task_id)
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'title': task.title,
|
||||
'description': task.description,
|
||||
'task_type': task.task_type,
|
||||
'status': task.status.value,
|
||||
'priority': task.priority.value,
|
||||
'workflow_instance_id': task.workflow_instance_id,
|
||||
'booking_id': task.booking_id,
|
||||
'room_id': task.room_id,
|
||||
'assigned_to': task.assigned_to,
|
||||
'assigned_to_name': task.assignee.full_name if task.assignee else None,
|
||||
'created_by': task.created_by,
|
||||
'created_by_name': task.creator_user.full_name if task.creator_user else None,
|
||||
'due_date': task.due_date.isoformat() if task.due_date else None,
|
||||
'completed_at': task.completed_at.isoformat() if task.completed_at else None,
|
||||
'estimated_duration_minutes': task.estimated_duration_minutes,
|
||||
'actual_duration_minutes': task.actual_duration_minutes,
|
||||
'notes': task.notes,
|
||||
'metadata': task.meta_data,
|
||||
'comments': [{
|
||||
'id': c.id,
|
||||
'user_id': c.user_id,
|
||||
'user_name': c.user.full_name if c.user else None,
|
||||
'comment': c.comment,
|
||||
'created_at': c.created_at.isoformat()
|
||||
} for c in comments],
|
||||
'created_at': task.created_at.isoformat(),
|
||||
'updated_at': task.updated_at.isoformat()
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{task_id}')
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
task_data: TaskUpdate,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update task"""
|
||||
try:
|
||||
due_date = None
|
||||
if task_data.due_date:
|
||||
try:
|
||||
due_date = datetime.fromisoformat(task_data.due_date.replace('Z', '+00:00'))
|
||||
except:
|
||||
due_date = datetime.strptime(task_data.due_date, '%Y-%m-%dT%H:%M:%S')
|
||||
|
||||
task = TaskService.update_task(
|
||||
db=db,
|
||||
task_id=task_id,
|
||||
title=task_data.title,
|
||||
description=task_data.description,
|
||||
status=TaskStatus(task_data.status) if task_data.status else None,
|
||||
priority=TaskPriority(task_data.priority) if task_data.priority else None,
|
||||
assigned_to=task_data.assigned_to,
|
||||
due_date=due_date,
|
||||
notes=task_data.notes,
|
||||
actual_duration_minutes=task_data.actual_duration_minutes
|
||||
)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'status': task.status.value,
|
||||
'updated_at': task.updated_at.isoformat()
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{task_id}/assign')
|
||||
async def assign_task(
|
||||
task_id: int,
|
||||
user_id: int = Body(..., embed=True),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Assign task to a user"""
|
||||
try:
|
||||
task = TaskService.assign_task(db, task_id, user_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'assigned_to': task.assigned_to,
|
||||
'status': task.status.value
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{task_id}/start')
|
||||
async def start_task(
|
||||
task_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Start task (mark as in progress)"""
|
||||
try:
|
||||
task = TaskService.start_task(db, task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'status': task.status.value
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{task_id}/complete')
|
||||
async def complete_task(
|
||||
task_id: int,
|
||||
notes: Optional[str] = Body(None, embed=True),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Complete task"""
|
||||
try:
|
||||
task = TaskService.complete_task(db, task_id, notes)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'status': task.status.value,
|
||||
'completed_at': task.completed_at.isoformat() if task.completed_at else None
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{task_id}/cancel')
|
||||
async def cancel_task(
|
||||
task_id: int,
|
||||
reason: Optional[str] = Body(None, embed=True),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Cancel task"""
|
||||
try:
|
||||
task = TaskService.cancel_task(db, task_id, reason)
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail='Task not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': task.id,
|
||||
'status': task.status.value
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/{task_id}/comments')
|
||||
async def add_task_comment(
|
||||
task_id: int,
|
||||
comment_data: TaskCommentCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add comment to task"""
|
||||
try:
|
||||
comment = TaskService.add_task_comment(
|
||||
db=db,
|
||||
task_id=task_id,
|
||||
user_id=current_user.id,
|
||||
comment=comment_data.comment
|
||||
)
|
||||
return {'status': 'success', 'data': {
|
||||
'id': comment.id,
|
||||
'user_id': comment.user_id,
|
||||
'user_name': comment.user.full_name if comment.user else None,
|
||||
'comment': comment.comment,
|
||||
'created_at': comment.created_at.isoformat()
|
||||
}}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/statistics/')
|
||||
async def get_task_statistics(
|
||||
assigned_to: Optional[int] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get task statistics"""
|
||||
try:
|
||||
start = None
|
||||
end = None
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
|
||||
stats = TaskService.get_task_statistics(
|
||||
db=db,
|
||||
assigned_to=assigned_to or (current_user.id if current_user.role.name != 'admin' else None),
|
||||
start_date=start,
|
||||
end_date=end
|
||||
)
|
||||
return {'status': 'success', 'data': stats}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -8,6 +8,8 @@ from ..middleware.auth import get_current_user, authorize_roles
|
||||
from ..models.user import User
|
||||
from ..models.role import Role
|
||||
from ..models.booking import Booking, BookingStatus
|
||||
from ..utils.role_helpers import can_manage_users
|
||||
from ..utils.response_helpers import success_response
|
||||
router = APIRouter(prefix='/users', tags=['users'])
|
||||
|
||||
@router.get('/', dependencies=[Depends(authorize_roles('admin'))])
|
||||
@@ -30,7 +32,7 @@ async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query
|
||||
for user in users:
|
||||
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'address': user.address, 'avatar': user.avatar, 'currency': getattr(user, 'currency', 'VND'), 'is_active': user.is_active, 'status': 'active' if user.is_active else 'inactive', 'role_id': user.role_id, 'role': user.role.name if user.role else 'customer', 'created_at': user.created_at.isoformat() if user.created_at else None, 'updated_at': user.updated_at.isoformat() if user.updated_at else None}
|
||||
result.append(user_dict)
|
||||
return {'status': 'success', 'data': {'users': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
|
||||
return success_response(data={'users': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@@ -42,7 +44,7 @@ async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('ad
|
||||
raise HTTPException(status_code=404, detail='User not found')
|
||||
bookings = db.query(Booking).filter(Booking.user_id == id).order_by(Booking.created_at.desc()).limit(5).all()
|
||||
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'address': user.address, 'avatar': user.avatar, 'currency': getattr(user, 'currency', 'VND'), 'is_active': user.is_active, 'status': 'active' if user.is_active else 'inactive', 'role_id': user.role_id, 'role': user.role.name if user.role else 'customer', 'created_at': user.created_at.isoformat() if user.created_at else None, 'updated_at': user.updated_at.isoformat() if user.updated_at else None, 'bookings': [{'id': b.id, 'booking_number': b.booking_number, 'status': b.status.value if isinstance(b.status, BookingStatus) else b.status, 'created_at': b.created_at.isoformat() if b.created_at else None} for b in bookings]}
|
||||
return {'status': 'success', 'data': {'user': user_dict}}
|
||||
return success_response(data={'user': user_dict})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -70,7 +72,7 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
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 {'status': 'success', 'message': 'User created successfully', 'data': {'user': user_dict}}
|
||||
return success_response(data={'user': user_dict}, message='User created successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -80,7 +82,7 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role
|
||||
@router.put('/{id}')
|
||||
async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
|
||||
try:
|
||||
if current_user.role_id != 1 and current_user.id != id:
|
||||
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()
|
||||
if not user:
|
||||
@@ -93,13 +95,13 @@ async def update_user(id: int, user_data: dict, current_user: User=Depends(get_c
|
||||
role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4}
|
||||
if 'full_name' in user_data:
|
||||
user.full_name = user_data['full_name']
|
||||
if 'email' in user_data and current_user.role_id == 1:
|
||||
if 'email' in user_data and can_manage_users(current_user, db):
|
||||
user.email = user_data['email']
|
||||
if 'phone_number' in user_data:
|
||||
user.phone = user_data['phone_number']
|
||||
if 'role' in user_data and current_user.role_id == 1:
|
||||
if 'role' in user_data and can_manage_users(current_user, db):
|
||||
user.role_id = role_map.get(user_data['role'], 3)
|
||||
if 'status' in user_data and current_user.role_id == 1:
|
||||
if 'status' in user_data and can_manage_users(current_user, db):
|
||||
user.is_active = user_data['status'] == 'active'
|
||||
if 'currency' in user_data:
|
||||
currency = user_data['currency']
|
||||
@@ -112,7 +114,7 @@ async def update_user(id: int, user_data: dict, current_user: User=Depends(get_c
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
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 {'status': 'success', 'message': 'User updated successfully', 'data': {'user': user_dict}}
|
||||
return success_response(data={'user': user_dict}, message='User updated successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -130,7 +132,7 @@ async def delete_user(id: int, current_user: User=Depends(authorize_roles('admin
|
||||
raise HTTPException(status_code=400, detail='Cannot delete user with active bookings')
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {'status': 'success', 'message': 'User deleted successfully'}
|
||||
return success_response(message='User deleted successfully')
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
314
Backend/src/routes/workflow_routes.py
Normal file
314
Backend/src/routes/workflow_routes.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional, List, Dict, Any
|
||||
from ..config.database import get_db
|
||||
from ..middleware.auth import authorize_roles, get_current_user
|
||||
from ..models.user import User
|
||||
from ..models.workflow import WorkflowType, WorkflowStatus, WorkflowTrigger
|
||||
from ..services.workflow_service import WorkflowService
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix='/workflows', tags=['workflows'])
|
||||
|
||||
# Request/Response Models
|
||||
class WorkflowCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
workflow_type: str
|
||||
trigger: str
|
||||
steps: List[Dict[str, Any]]
|
||||
trigger_config: Optional[Dict[str, Any]] = None
|
||||
sla_hours: Optional[int] = None
|
||||
|
||||
class WorkflowUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
steps: Optional[List[Dict[str, Any]]] = None
|
||||
status: Optional[str] = None
|
||||
trigger_config: Optional[Dict[str, Any]] = None
|
||||
sla_hours: Optional[int] = None
|
||||
|
||||
class WorkflowTriggerRequest(BaseModel):
|
||||
booking_id: Optional[int] = None
|
||||
room_id: Optional[int] = None
|
||||
user_id: Optional[int] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
# Workflow CRUD
|
||||
@router.post('/')
|
||||
async def create_workflow(
|
||||
workflow_data: WorkflowCreate,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new workflow"""
|
||||
try:
|
||||
workflow = WorkflowService.create_workflow(
|
||||
db=db,
|
||||
name=workflow_data.name,
|
||||
workflow_type=WorkflowType(workflow_data.workflow_type),
|
||||
trigger=WorkflowTrigger(workflow_data.trigger),
|
||||
steps=workflow_data.steps,
|
||||
created_by=current_user.id,
|
||||
description=workflow_data.description,
|
||||
trigger_config=workflow_data.trigger_config,
|
||||
sla_hours=workflow_data.sla_hours
|
||||
)
|
||||
return {'status': 'success', 'data': {
|
||||
'id': workflow.id,
|
||||
'name': workflow.name,
|
||||
'workflow_type': workflow.workflow_type.value,
|
||||
'trigger': workflow.trigger.value,
|
||||
'status': workflow.status.value,
|
||||
'created_at': workflow.created_at.isoformat()
|
||||
}}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/')
|
||||
async def get_workflows(
|
||||
workflow_type: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get workflows"""
|
||||
try:
|
||||
workflows = WorkflowService.get_workflows(
|
||||
db=db,
|
||||
workflow_type=WorkflowType(workflow_type) if workflow_type else None,
|
||||
status=WorkflowStatus(status) if status else None,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': w.id,
|
||||
'name': w.name,
|
||||
'description': w.description,
|
||||
'workflow_type': w.workflow_type.value,
|
||||
'trigger': w.trigger.value,
|
||||
'status': w.status.value,
|
||||
'sla_hours': w.sla_hours,
|
||||
'steps': w.steps,
|
||||
'trigger_config': w.trigger_config,
|
||||
'created_at': w.created_at.isoformat(),
|
||||
'updated_at': w.updated_at.isoformat()
|
||||
} for w in workflows]}
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/{workflow_id}')
|
||||
async def get_workflow(
|
||||
workflow_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get workflow by ID"""
|
||||
try:
|
||||
workflow = WorkflowService.get_workflow_by_id(db, workflow_id)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail='Workflow not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': workflow.id,
|
||||
'name': workflow.name,
|
||||
'description': workflow.description,
|
||||
'workflow_type': workflow.workflow_type.value,
|
||||
'trigger': workflow.trigger.value,
|
||||
'status': workflow.status.value,
|
||||
'sla_hours': workflow.sla_hours,
|
||||
'steps': workflow.steps,
|
||||
'trigger_config': workflow.trigger_config,
|
||||
'created_at': workflow.created_at.isoformat(),
|
||||
'updated_at': workflow.updated_at.isoformat()
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.put('/{workflow_id}')
|
||||
async def update_workflow(
|
||||
workflow_id: int,
|
||||
workflow_data: WorkflowUpdate,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update workflow"""
|
||||
try:
|
||||
workflow = WorkflowService.update_workflow(
|
||||
db=db,
|
||||
workflow_id=workflow_id,
|
||||
name=workflow_data.name,
|
||||
description=workflow_data.description,
|
||||
steps=workflow_data.steps,
|
||||
status=WorkflowStatus(workflow_data.status) if workflow_data.status else None,
|
||||
trigger_config=workflow_data.trigger_config,
|
||||
sla_hours=workflow_data.sla_hours
|
||||
)
|
||||
if not workflow:
|
||||
raise HTTPException(status_code=404, detail='Workflow not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': workflow.id,
|
||||
'name': workflow.name,
|
||||
'status': workflow.status.value,
|
||||
'updated_at': workflow.updated_at.isoformat()
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.delete('/{workflow_id}')
|
||||
async def delete_workflow(
|
||||
workflow_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete workflow"""
|
||||
try:
|
||||
success = WorkflowService.delete_workflow(db, workflow_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail='Workflow not found')
|
||||
|
||||
return {'status': 'success', 'message': 'Workflow deleted successfully'}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Workflow Instances
|
||||
@router.post('/{workflow_id}/trigger')
|
||||
async def trigger_workflow(
|
||||
workflow_id: int,
|
||||
trigger_data: WorkflowTriggerRequest,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Trigger a workflow"""
|
||||
try:
|
||||
instance = WorkflowService.trigger_workflow(
|
||||
db=db,
|
||||
workflow_id=workflow_id,
|
||||
booking_id=trigger_data.booking_id,
|
||||
room_id=trigger_data.room_id,
|
||||
user_id=trigger_data.user_id,
|
||||
metadata=trigger_data.metadata
|
||||
)
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail='Workflow not found or inactive')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': instance.id,
|
||||
'workflow_id': instance.workflow_id,
|
||||
'status': instance.status,
|
||||
'started_at': instance.started_at.isoformat(),
|
||||
'due_date': instance.due_date.isoformat() if instance.due_date else None
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/instances/')
|
||||
async def get_workflow_instances(
|
||||
workflow_id: Optional[int] = Query(None),
|
||||
booking_id: Optional[int] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get workflow instances"""
|
||||
try:
|
||||
instances = WorkflowService.get_workflow_instances(
|
||||
db=db,
|
||||
workflow_id=workflow_id,
|
||||
booking_id=booking_id,
|
||||
status=status,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': i.id,
|
||||
'workflow_id': i.workflow_id,
|
||||
'booking_id': i.booking_id,
|
||||
'room_id': i.room_id,
|
||||
'user_id': i.user_id,
|
||||
'status': i.status,
|
||||
'started_at': i.started_at.isoformat(),
|
||||
'completed_at': i.completed_at.isoformat() if i.completed_at else None,
|
||||
'due_date': i.due_date.isoformat() if i.due_date else None
|
||||
} for i in instances]}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.post('/instances/{instance_id}/complete')
|
||||
async def complete_workflow_instance(
|
||||
instance_id: int,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Complete workflow instance"""
|
||||
try:
|
||||
instance = WorkflowService.complete_workflow_instance(db, instance_id)
|
||||
if not instance:
|
||||
raise HTTPException(status_code=404, detail='Workflow instance not found')
|
||||
|
||||
return {'status': 'success', 'data': {
|
||||
'id': instance.id,
|
||||
'status': instance.status,
|
||||
'completed_at': instance.completed_at.isoformat() if instance.completed_at else None
|
||||
}}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
# Predefined workflow types
|
||||
@router.get('/types/pre-arrival')
|
||||
async def get_pre_arrival_workflows(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get pre-arrival workflows"""
|
||||
try:
|
||||
workflows = WorkflowService.get_pre_arrival_workflows(db)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': w.id,
|
||||
'name': w.name,
|
||||
'description': w.description,
|
||||
'trigger': w.trigger.value,
|
||||
'sla_hours': w.sla_hours
|
||||
} for w in workflows]}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/types/room-preparation')
|
||||
async def get_room_preparation_workflows(
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get room preparation workflows"""
|
||||
try:
|
||||
workflows = WorkflowService.get_room_preparation_workflows(db)
|
||||
return {'status': 'success', 'data': [{
|
||||
'id': w.id,
|
||||
'name': w.name,
|
||||
'description': w.description,
|
||||
'trigger': w.trigger.value,
|
||||
'sla_hours': w.sla_hours
|
||||
} for w in workflows]}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Binary file not shown.
BIN
Backend/src/services/__pycache__/borica_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/borica_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/services/__pycache__/gdpr_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/gdpr_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/services/__pycache__/oauth_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/oauth_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user