This commit is contained in:
Iliyan Angelov
2025-11-23 18:59:18 +02:00
parent be07802066
commit 627959f52b
1840 changed files with 236564 additions and 3475 deletions

View File

@@ -35,6 +35,14 @@ JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 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 # CORS & Client Configuration
# ============================================ # ============================================

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

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

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

View File

@@ -21,6 +21,13 @@ paypal-checkout-serversdk>=1.0.3
pyotp==2.9.0 pyotp==2.9.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
httpx==0.25.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) # Enterprise features (optional but recommended)
# redis==5.0.1 # Uncomment if using Redis caching # redis==5.0.1 # Uncomment if using Redis caching

View File

@@ -10,4 +10,4 @@ if __name__ == '__main__':
base_dir = Path(__file__).parent base_dir = Path(__file__).parent
src_dir = str(base_dir / 'src') src_dir = str(base_dir / 'src')
use_reload = False 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)

View File

@@ -21,6 +21,7 @@ class Settings(BaseSettings):
JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm') 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_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') 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') 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') 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') 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_ID: str = Field(default='', description='PayPal client ID')
PAYPAL_CLIENT_SECRET: str = Field(default='', description='PayPal client secret') PAYPAL_CLIENT_SECRET: str = Field(default='', description='PayPal client secret')
PAYPAL_MODE: str = Field(default='sandbox', description='PayPal mode: sandbox or live') 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 @property
def database_url(self) -> str: def database_url(self) -> str:

View File

@@ -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()} 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='/api')
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) 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(room_routes.router, prefix='/api')
app.include_router(booking_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(payment_routes.router, prefix='/api')
app.include_router(invoice_routes.router, prefix='/api') app.include_router(invoice_routes.router, prefix='/api')
app.include_router(banner_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(chat_routes.router, prefix='/api')
app.include_router(loyalty_routes.router, prefix='/api') app.include_router(loyalty_routes.router, prefix='/api')
app.include_router(guest_profile_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(room_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(booking_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) 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(chat_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(loyalty_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(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='/api')
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
logger.info('All routes registered successfully') logger.info('All routes registered successfully')

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

View File

@@ -32,4 +32,16 @@ from .guest_note import GuestNote
from .guest_tag import GuestTag, guest_tag_association from .guest_tag import GuestTag, guest_tag_association
from .guest_communication import GuestCommunication, CommunicationType, CommunicationDirection from .guest_communication import GuestCommunication, CommunicationType, CommunicationDirection
from .guest_segment import GuestSegment, guest_segment_association 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.

View File

@@ -35,4 +35,8 @@ class Booking(Base):
payments = relationship('Payment', back_populates='booking', cascade='all, delete-orphan') payments = relationship('Payment', back_populates='booking', cascade='all, delete-orphan')
invoices = relationship('Invoice', back_populates='booking', cascade='all, delete-orphan') invoices = relationship('Invoice', back_populates='booking', cascade='all, delete-orphan')
service_usages = relationship('ServiceUsage', 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) 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')

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

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

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

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

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

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

View File

@@ -12,6 +12,7 @@ class PaymentMethod(str, enum.Enum):
e_wallet = 'e_wallet' e_wallet = 'e_wallet'
stripe = 'stripe' stripe = 'stripe'
paypal = 'paypal' paypal = 'paypal'
borica = 'borica'
class PaymentType(str, enum.Enum): class PaymentType(str, enum.Enum):
full = 'full' full = 'full'

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

View File

@@ -30,4 +30,8 @@ class Room(Base):
room_type = relationship('RoomType', back_populates='rooms') room_type = relationship('RoomType', back_populates='rooms')
bookings = relationship('Booking', back_populates='room') bookings = relationship('Booking', back_populates='room')
reviews = relationship('Review', back_populates='room') reviews = relationship('Review', back_populates='room')
favorites = relationship('Favorite', back_populates='room', cascade='all, delete-orphan') 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')

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

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

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

View File

@@ -13,4 +13,6 @@ class RoomType(Base):
amenities = Column(JSON, nullable=True) amenities = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
rooms = relationship('Room', back_populates='room_type') rooms = relationship('Room', back_populates='room_type')
rate_plans = relationship('RatePlan', back_populates='room_type')
packages = relationship('Package', back_populates='room_type')

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

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

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

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

View File

@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload, selectinload from sqlalchemy.orm import Session, joinedload, selectinload, load_only
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_, func
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
import random import random
@@ -22,6 +22,8 @@ from fastapi import Request
from ..utils.mailer import send_email from ..utils.mailer import send_email
from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template
from ..services.loyalty_service import LoyaltyService 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']) router = APIRouter(prefix='/bookings', tags=['bookings'])
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str: 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('/') @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)): 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: 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: if search:
query = query.filter(Booking.booking_number.like(f'%{search}%')) query = query.filter(Booking.booking_number.like(f'%{search}%'))
if status_filter: if status_filter:
@@ -60,7 +75,8 @@ async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Opt
if endDate: if endDate:
end = datetime.fromisoformat(endDate.replace('Z', '+00:00')) end = datetime.fromisoformat(endDate.replace('Z', '+00:00'))
query = query.filter(Booking.check_in_date <= end) 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 offset = (page - 1) * limit
bookings = query.order_by(Booking.created_at.desc()).offset(offset).limit(limit).all() bookings = query.order_by(Booking.created_at.desc()).offset(offset).limit(limit).all()
result = [] result = []
@@ -96,7 +112,9 @@ async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Opt
else: else:
booking_dict['payments'] = [] booking_dict['payments'] = []
result.append(booking_dict) 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: except Exception as e:
import logging import logging
import traceback import traceback
@@ -140,7 +158,7 @@ async def get_my_bookings(request: Request, current_user: User=Depends(get_curre
else: else:
booking_dict['payments'] = [] booking_dict['payments'] = []
result.append(booking_dict) result.append(booking_dict)
return {'success': True, 'data': {'bookings': result}} return success_response(data={'bookings': result})
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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')) check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
else: else:
check_out = datetime.strptime(check_out_date, '%Y-%m-%d') 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() 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: if overlapping:
raise HTTPException(status_code=409, detail='Room already booked for the selected dates') 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() booking_number = generate_booking_number()
# Calculate room price # 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.add(service_usage)
db.commit() db.commit()
db.refresh(booking) 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: try:
from ..services.invoice_service import InvoiceService from ..services.invoice_service import InvoiceService
from ..utils.mailer import send_email 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} booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor}
if booking.room.room_type: 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} 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: except HTTPException:
raise raise
except Exception as e: 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() 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: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') 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') raise HTTPException(status_code=403, detail='Forbidden')
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -497,7 +555,7 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
else: else:
logger.info(f'Get booking {id} - No service_usages found, initializing empty array') logger.info(f'Get booking {id} - No service_usages found, initializing empty array')
booking_dict['service_usages'] = [] booking_dict['service_usages'] = []
return {'success': True, 'data': {'booking': booking_dict}} return success_response(data={'booking': booking_dict})
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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') raise HTTPException(status_code=403, detail='Forbidden')
if booking.status == BookingStatus.cancelled: if booking.status == BookingStatus.cancelled:
raise HTTPException(status_code=400, detail='Booking already cancelled') raise HTTPException(status_code=400, detail='Booking already cancelled')
if booking.status == BookingStatus.confirmed: # Customers can only cancel pending bookings
raise HTTPException(status_code=400, detail='Cannot cancel a confirmed booking. Please contact support for assistance.') # Admin/Staff can cancel any booking via update_booking endpoint
if booking.status != BookingStatus.pending: 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() booking = db.query(Booking).options(selectinload(Booking.payments)).filter(Booking.id == id).first()
payments_updated = False payments_updated = False
if booking.payments: 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() payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
payments_updated = True payments_updated = True
booking.status = BookingStatus.cancelled 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.flush()
db.commit() db.commit()
try: try:
@@ -549,14 +637,14 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f'Failed to send cancellation email: {e}') logger.error(f'Failed to send cancellation email: {e}')
return {'success': True, 'data': {'booking': booking}} return success_response(data={'booking': booking})
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=str(e)) 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)): async def update_booking(id: int, booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
booking = db.query(Booking).options( 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') raise HTTPException(status_code=404, detail='Booking not found')
old_status = booking.status old_status = booking.status
status_value = booking_data.get('status') status_value = booking_data.get('status')
room = booking.room
new_status = None
if status_value: if status_value:
try: try:
new_status = BookingStatus(status_value) new_status = BookingStatus(status_value)
booking.status = new_status 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: if booking.payments:
for payment in booking.payments: for payment in booking.payments:
if payment.payment_status == PaymentStatus.pending: 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()}' 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() payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip()
db.flush() 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: except ValueError:
raise HTTPException(status_code=400, detail='Invalid status') raise HTTPException(status_code=400, detail='Invalid status')
db.commit() 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 # Reload booking with all relationships after commit
booking = db.query(Booking).options( booking = db.query(Booking).options(
selectinload(Booking.payments), 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' 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_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' 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 = get_currency_symbol(currency)
currency_symbol = currency_symbols.get(currency, currency)
guest_name = booking.user.full_name if booking.user else 'Guest' guest_name = booking.user.full_name if booking.user else 'Guest'
guest_email = booking.user.email if booking.user else None 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) 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, '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: if payment_warning:
response_data['warning'] = payment_warning response_data['warning'] = payment_warning
response_data['message'] = 'Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance.'
return response_data return response_data
except HTTPException: except HTTPException:
raise raise
@@ -722,7 +872,7 @@ async def check_booking_by_number(booking_number: str, db: Session=Depends(get_d
booking_dict['payments'] = [] booking_dict['payments'] = []
payment_balance = calculate_booking_payment_balance(booking) 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']} 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: 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']} 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 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') check_out_date = booking_data.get('check_out_date')
total_price = booking_data.get('total_price') total_price = booking_data.get('total_price')
guest_count = booking_data.get('guest_count', 1) 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') notes = booking_data.get('notes')
payment_method = booking_data.get('payment_method', 'cash') payment_method = booking_data.get('payment_method', 'cash')
payment_status = booking_data.get('payment_status', 'unpaid') # 'full', 'deposit', or 'unpaid' 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: else:
check_out = datetime.strptime(check_out_date, '%Y-%m-%d') 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 # Check for overlapping bookings
overlapping = db.query(Booking).filter( overlapping = db.query(Booking).filter(
and_( and_(
@@ -1118,11 +1274,10 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
'capacity': booking.room.room_type.capacity 'capacity': booking.room.room_type.capacity
} }
return { return success_response(
'success': True, data={'booking': booking_dict},
'data': {'booking': booking_dict}, message=f'Booking created successfully by {current_user.full_name}'
'message': f'Booking created successfully by {current_user.full_name}' )
}
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

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

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

View File

@@ -10,6 +10,7 @@ from ..models.guest_tag import GuestTag
from ..models.guest_communication import GuestCommunication, CommunicationType, CommunicationDirection from ..models.guest_communication import GuestCommunication, CommunicationType, CommunicationDirection
from ..models.guest_segment import GuestSegment from ..models.guest_segment import GuestSegment
from ..services.guest_profile_service import GuestProfileService from ..services.guest_profile_service import GuestProfileService
from ..utils.role_helpers import is_customer
import json import json
router = APIRouter(prefix='/guest-profiles', tags=['guest-profiles']) router = APIRouter(prefix='/guest-profiles', tags=['guest-profiles'])
@@ -88,8 +89,9 @@ async def get_guest_profile(
if not user: if not user:
raise HTTPException(status_code=404, detail=f'User with ID {user_id} not found') raise HTTPException(status_code=404, detail=f'User with ID {user_id} not found')
# Check if user is a customer (role_id == 3) # Check if user is a customer
if user.role_id != 3: 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)') raise HTTPException(status_code=404, detail=f'User with ID {user_id} is not a guest (customer)')
# Get analytics # Get analytics
@@ -189,8 +191,8 @@ async def update_guest_preferences(
): ):
"""Update guest preferences""" """Update guest preferences"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
preferences = db.query(GuestPreference).filter(GuestPreference.user_id == user_id).first() 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""" """Create a note for a guest"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
note = GuestNote( note = GuestNote(
@@ -302,8 +304,8 @@ async def toggle_vip_status(
): ):
"""Toggle VIP status for a guest""" """Toggle VIP status for a guest"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
user.is_vip = vip_data.get('is_vip', False) 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""" """Add a tag to a guest"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
tag_id = tag_data.get('tag_id') tag_id = tag_data.get('tag_id')
@@ -357,8 +359,8 @@ async def remove_tag_from_guest(
): ):
"""Remove a tag from a guest""" """Remove a tag from a guest"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first() tag = db.query(GuestTag).filter(GuestTag.id == tag_id).first()
@@ -386,8 +388,8 @@ async def create_communication(
): ):
"""Create a communication record""" """Create a communication record"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
comm = GuestCommunication( comm = GuestCommunication(
@@ -420,8 +422,8 @@ async def get_guest_analytics(
): ):
"""Get guest analytics""" """Get guest analytics"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
analytics = GuestProfileService.get_guest_analytics(user_id, db) 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.)""" """Update guest metrics (lifetime value, satisfaction score, etc.)"""
try: try:
user = db.query(User).filter(User.id == user_id, User.role_id == 3).first() user = db.query(User).filter(User.id == user_id).first()
if not user: if not user or not is_customer(user, db):
raise HTTPException(status_code=404, detail='Guest not found') raise HTTPException(status_code=404, detail='Guest not found')
metrics = GuestProfileService.update_guest_metrics(user_id, db) metrics = GuestProfileService.update_guest_metrics(user_id, db)

View File

@@ -8,14 +8,16 @@ from ..models.user import User
from ..models.invoice import Invoice, InvoiceStatus from ..models.invoice import Invoice, InvoiceStatus
from ..models.booking import Booking from ..models.booking import Booking
from ..services.invoice_service import InvoiceService 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 = APIRouter(prefix='/invoices', tags=['invoices'])
@router.get('/') @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)): 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: 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) 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(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) invoice = InvoiceService.get_invoice(id, db)
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found') 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') raise HTTPException(status_code=403, detail='Forbidden')
return {'status': 'success', 'data': {'invoice': invoice}} return success_response(data={'invoice': invoice})
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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('/') @router.post('/')
async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: 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') raise HTTPException(status_code=403, detail='Forbidden')
booking_id = invoice_data.get('booking_id') booking_id = invoice_data.get('booking_id')
if not 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_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note
invoice_kwargs['notes'] = invoice_notes 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) 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: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:
@@ -70,7 +72,7 @@ async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found') 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) 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: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:
@@ -83,7 +85,7 @@ async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=D
try: try:
amount = payment_data.get('amount') 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) 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: except HTTPException:
raise raise
except ValueError as e: 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') raise HTTPException(status_code=404, detail='Invoice not found')
db.delete(invoice) db.delete(invoice)
db.commit() db.commit()
return {'status': 'success', 'message': 'Invoice deleted successfully'} return success_response(message='Invoice deleted successfully')
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') 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') raise HTTPException(status_code=403, detail='Forbidden')
result = InvoiceService.get_invoices(db=db, booking_id=booking_id) result = InvoiceService.get_invoices(db=db, booking_id=booking_id)
return {'status': 'success', 'data': result} return success_response(data=result)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

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

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

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, Header 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 typing import Optional
from datetime import datetime from datetime import datetime
import os import os
@@ -9,10 +9,14 @@ from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus 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.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template
from ..services.stripe_service import StripeService from ..services.stripe_service import StripeService
from ..services.paypal_service import PayPalService from ..services.paypal_service import PayPalService
from ..services.borica_service import BoricaService
from ..services.loyalty_service import LoyaltyService from ..services.loyalty_service import LoyaltyService
router = APIRouter(prefix='/payments', tags=['payments']) 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: if booking.status == BookingStatus.cancelled:
return return
from sqlalchemy.orm import selectinload 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: if booking.payments:
for payment in booking.payments: for payment in booking.payments:
if payment.payment_status == PaymentStatus.pending: 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)) query = query.filter(Payment.payment_status == PaymentStatus(status_filter))
except ValueError: except ValueError:
pass 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) query = query.join(Booking).filter(Booking.user_id == current_user.id)
total = query.count() 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 offset = (page - 1) * limit
payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all() payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all()
result = [] result = []
@@ -69,7 +97,7 @@ async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Opt
if payment.booking.user: 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} 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) 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: except HTTPException:
raise raise
except Exception as e: 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}') @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)): async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
from ..utils.role_helpers import is_admin
booking = db.query(Booking).filter(Booking.id == booking_id).first() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') 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') 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 = [] result = []
for payment in payments: 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} 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: 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} 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) result.append(payment_dict)
return {'status': 'success', 'data': {'payments': result}} return success_response(data={'payments': result})
except HTTPException: except HTTPException:
raise raise
except Exception as e: 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() payment = db.query(Payment).filter(Payment.id == id).first()
if not payment: if not payment:
raise HTTPException(status_code=404, detail='Payment not found') 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: if payment.booking and payment.booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden') 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} 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: if payment.booking:
payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} 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: except HTTPException:
raise raise
except Exception as e: 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() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') 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') 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')) 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'): 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.commit()
db.refresh(payment) 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 # Award loyalty points if payment completed and booking is confirmed
if payment.payment_status == PaymentStatus.completed and booking: if payment.payment_status == PaymentStatus.completed and booking:
try: 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') 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_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' 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 = get_currency_symbol(currency)
currency_symbol = currency_symbols.get(currency, 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) 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) await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f'Failed to send payment confirmation email: {e}') 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -195,10 +247,30 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
try: try:
new_status = PaymentStatus(status_value) new_status = PaymentStatus(status_value)
payment.payment_status = new_status 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]: 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: if booking and booking.status != BookingStatus.cancelled:
await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}') # 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: except ValueError:
raise HTTPException(status_code=400, detail='Invalid payment status') raise HTTPException(status_code=400, detail='Invalid payment status')
if status_data.get('transaction_id'): if status_data.get('transaction_id'):
@@ -209,22 +281,29 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
db.commit() db.commit()
db.refresh(payment) db.refresh(payment)
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: 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: try:
from ..models.system_settings import SystemSettings from ..models.system_settings import SystemSettings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() 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') 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_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' 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 = get_currency_symbol(currency)
currency_symbol = currency_symbols.get(currency, currency)
payment = db.query(Payment).filter(Payment.id == id).first() payment = db.query(Payment).filter(Payment.id == id).first()
if payment.booking and payment.booking.user: if payment.booking and payment.booking.user:
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() 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') 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_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' 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 = get_currency_symbol(currency)
currency_symbol = currency_symbols.get(currency, 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) 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) 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: 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() db.commit()
except Exception as e: except Exception as e:
print(f'Failed to send payment confirmation email: {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: except HTTPException:
raise raise
except Exception as e: 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() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') 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') raise HTTPException(status_code=403, detail='Forbidden')
if booking.requires_deposit and (not booking.deposit_paid): 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() 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 = logging.getLogger(__name__)
logger.error('Payment intent created but client_secret is missing') 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.') 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: except HTTPException:
raise raise
except ValueError as e: 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') 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_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' 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 = get_currency_symbol(currency)
currency_symbol = currency_symbols.get(currency, 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) 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) await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment confirmation email: {e}') 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: except HTTPException:
db.rollback() db.rollback()
raise raise
@@ -369,7 +448,7 @@ async def stripe_webhook(request: Request, db: Session=Depends(get_db)):
if not signature: if not signature:
raise HTTPException(status_code=400, detail='Missing stripe-signature header') raise HTTPException(status_code=400, detail='Missing stripe-signature header')
result = await StripeService.handle_webhook(payload=payload, signature=signature, db=db) 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as 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') raise HTTPException(status_code=400, detail='booking_id and amount are required')
if amount > 100000: 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.") 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() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') 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') raise HTTPException(status_code=403, detail='Forbidden')
if booking.requires_deposit and (not booking.deposit_paid): 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() 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) 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'): if not order.get('approval_url'):
raise HTTPException(status_code=500, detail='Failed to create PayPal order. Approval URL is missing.') 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: except HTTPException:
raise raise
except ValueError as e: 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() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if booking and booking.status != BookingStatus.cancelled: if booking and booking.status != BookingStatus.cancelled:
await cancel_booking_on_payment_failure(booking, db, reason='PayPal payment canceled by user') 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: except HTTPException:
db.rollback() db.rollback()
raise 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') 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_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' 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 = get_currency_symbol(currency)
currency_symbol = currency_symbols.get(currency, 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) 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) await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html)
except Exception as e: except Exception as e:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.warning(f'Failed to send payment confirmation email: {e}') 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: except HTTPException:
db.rollback() db.rollback()
raise raise
@@ -498,4 +577,174 @@ async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f'Unexpected error confirming PayPal payment: {str(e)}', exc_info=True) logger.error(f'Unexpected error confirming PayPal payment: {str(e)}', exc_info=True)
db.rollback() 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)) raise HTTPException(status_code=500, detail=str(e))

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

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query 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 sqlalchemy import func, and_
from typing import Optional from typing import Optional
from datetime import datetime, timedelta 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.user import User
from ..models.booking import Booking, BookingStatus from ..models.booking import Booking, BookingStatus
from ..models.payment import Payment, PaymentStatus 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_usage import ServiceUsage
from ..models.service import Service from ..models.service import Service
from ..utils.response_helpers import success_response
router = APIRouter(prefix='/reports', tags=['reports']) router = APIRouter(prefix='/reports', tags=['reports'])
@router.get('') @router.get('')
@@ -37,7 +38,8 @@ async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_dat
if end_date: if end_date:
booking_query = booking_query.filter(Booking.created_at <= end_date) booking_query = booking_query.filter(Booking.created_at <= end_date)
payment_query = payment_query.filter(Payment.payment_date <= 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_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 total_customers = db.query(func.count(func.distinct(Booking.user_id))).scalar() or 0
if start_date or end_date: 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: if end_date:
customer_query = customer_query.filter(Booking.created_at <= end_date) customer_query = customer_query.filter(Booking.created_at <= end_date)
total_customers = customer_query.scalar() or 0 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 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 = [] revenue_by_date = []
if start_date and end_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] 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 = {} bookings_by_status = {}
for status in BookingStatus: 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) status_name = status.value if hasattr(status, 'value') else str(status)
bookings_by_status[status_name] = count 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) 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_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_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] 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/dashboard') @router.get('/dashboard')
async def get_dashboard_stats(current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): async def get_dashboard_stats(current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try: try:
total_bookings = db.query(Booking).count() # Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
active_bookings = db.query(Booking).filter(Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count() 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 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_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 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() 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) 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() 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(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)): async def get_customer_dashboard_stats(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
from datetime import datetime, timedelta 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() 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 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() 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() 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
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() # 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 = [] upcoming_bookings = []
for booking in upcoming_bookings_query: 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} 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: 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}} 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) 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 = [] recent_activity = []
for booking in recent_bookings_query: for booking in recent_bookings_query:
activity_type = None 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) recent_activity.append(activity_dict)
last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0) 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_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() # Use func.count() to avoid loading all columns (including non-existent rate_plan_id)
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() 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 booking_change_percentage = 0
if last_month_bookings > 0: if last_month_bookings > 0:
booking_change_percentage = (this_month_bookings - last_month_bookings) / last_month_bookings * 100 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 spending_change_percentage = 0
if last_month_spending > 0: if last_month_spending > 0:
spending_change_percentage = (this_month_spending - last_month_spending) / last_month_spending * 100 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(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) 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_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] 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -17,7 +17,7 @@ from pathlib import Path
router = APIRouter(prefix='/rooms', tags=['rooms']) router = APIRouter(prefix='/rooms', tags=['rooms'])
@router.get('/') @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: try:
where_clause = {} where_clause = {}
room_type_where = {} 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() 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: if overlapping:
return {'status': 'success', 'data': {'available': False, 'message': 'Room is already booked for the selected dates', 'room_id': roomId}} 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}} return {'status': 'success', 'data': {'available': True, 'message': 'Room is available', 'room_id': roomId}}
if check_in >= check_out: if check_in >= check_out:
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date') 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) 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() 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))) 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() total = query.count()
query = query.order_by(Room.featured.desc(), Room.created_at.desc()) query = query.order_by(Room.featured.desc(), Room.created_at.desc())
offset = (page - 1) * limit offset = (page - 1) * limit

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

View File

@@ -7,6 +7,7 @@ import random
from ..config.database import get_db from ..config.database import get_db
from ..middleware.auth import get_current_user from ..middleware.auth import get_current_user
from ..models.user import User from ..models.user import User
from ..utils.role_helpers import is_admin
from ..models.service import Service from ..models.service import Service
from ..models.service_booking import ( from ..models.service_booking import (
ServiceBooking, ServiceBooking,
@@ -212,8 +213,7 @@ async def get_service_booking_by_id(
if not booking: if not booking:
raise HTTPException(status_code=404, detail="Service booking not found") raise HTTPException(status_code=404, detail="Service booking not found")
if not is_admin(current_user, db) and booking.user_id != current_user.id:
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
booking_dict = { booking_dict = {
@@ -281,10 +281,9 @@ async def create_service_stripe_payment_intent(
if not booking: if not booking:
raise HTTPException(status_code=404, detail="Service booking not found") 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") raise HTTPException(status_code=403, detail="Forbidden")
if abs(float(booking.total_amount) - amount) > 0.01: if abs(float(booking.total_amount) - amount) > 0.01:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
@@ -341,10 +340,9 @@ async def confirm_service_stripe_payment(
if not booking: if not booking:
raise HTTPException(status_code=404, detail="Service booking not found") 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") raise HTTPException(status_code=403, detail="Forbidden")
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db) intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
if intent_data["status"] != "succeeded": if intent_data["status"] != "succeeded":

View File

@@ -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 sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@@ -462,6 +462,366 @@ async def update_paypal_settings(
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=str(e)) 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") @router.get("/smtp")
async def get_smtp_settings( async def get_smtp_settings(
current_user: User = Depends(authorize_roles("admin")), current_user: User = Depends(authorize_roles("admin")),

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

View File

@@ -8,6 +8,8 @@ from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.role import Role from ..models.role import Role
from ..models.booking import Booking, BookingStatus 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 = APIRouter(prefix='/users', tags=['users'])
@router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @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: 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} 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) 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(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') 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() 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]} 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -70,7 +72,7 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role
db.commit() db.commit()
db.refresh(user) 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} 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: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@@ -80,7 +82,7 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role
@router.put('/{id}') @router.put('/{id}')
async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: 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') raise HTTPException(status_code=403, detail='Forbidden')
user = db.query(User).filter(User.id == id).first() user = db.query(User).filter(User.id == id).first()
if not user: 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} role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4}
if 'full_name' in user_data: if 'full_name' in user_data:
user.full_name = user_data['full_name'] 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'] user.email = user_data['email']
if 'phone_number' in user_data: if 'phone_number' in user_data:
user.phone = user_data['phone_number'] 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) 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' user.is_active = user_data['status'] == 'active'
if 'currency' in user_data: if 'currency' in user_data:
currency = user_data['currency'] 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.commit()
db.refresh(user) 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} 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: except HTTPException:
raise raise
except Exception as e: 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') raise HTTPException(status_code=400, detail='Cannot delete user with active bookings')
db.delete(user) db.delete(user)
db.commit() db.commit()
return {'status': 'success', 'message': 'User deleted successfully'} return success_response(message='User deleted successfully')
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

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

Some files were not shown because too many files have changed in this diff Show More