diff --git a/Backend/alembic/env.py b/Backend/alembic/env.py index 2ffe86db..2b66a5bd 100644 --- a/Backend/alembic/env.py +++ b/Backend/alembic/env.py @@ -6,65 +6,31 @@ import os import sys from pathlib import Path from dotenv import load_dotenv - -# Load environment variables load_dotenv() - -# Add parent directory to path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) - -# Import models and Base from src.config.database import Base from src.config.settings import settings -from src.models import * # Import all models - -# this is the Alembic Config object +from src.models import * config = context.config - -# Interpret the config file for Python logging. if config.config_file_name is not None: fileConfig(config.config_file_name) - -# Get database URL from settings database_url = settings.database_url -config.set_main_option("sqlalchemy.url", database_url) - -# add your model's MetaData object here +config.set_main_option('sqlalchemy.url', database_url) target_metadata = Base.metadata def run_migrations_offline() -> None: - """Run migrations in 'offline' mode.""" - url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - + url = config.get_main_option('sqlalchemy.url') + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={'paramstyle': 'named'}) with context.begin_transaction(): context.run_migrations() - def run_migrations_online() -> None: - """Run migrations in 'online' mode.""" - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - + connectable = engine_from_config(config.get_section(config.config_ini_section, {}), prefix='sqlalchemy.', poolclass=pool.NullPool) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) - + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() - - if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() - + run_migrations_online() \ No newline at end of file diff --git a/Backend/alembic/versions/08e2f866e131_add_mfa_fields_to_users.py b/Backend/alembic/versions/08e2f866e131_add_mfa_fields_to_users.py index 0874374e..00d43537 100644 --- a/Backend/alembic/versions/08e2f866e131_add_mfa_fields_to_users.py +++ b/Backend/alembic/versions/08e2f866e131_add_mfa_fields_to_users.py @@ -1,31 +1,16 @@ -"""add_mfa_fields_to_users - -Revision ID: 08e2f866e131 -Revises: add_badges_to_page_content -Create Date: 2025-11-19 11:13:30.376194 - -""" from alembic import op import sqlalchemy as sa - - -# revision identifiers, used by Alembic. revision = '08e2f866e131' down_revision = 'add_badges_to_page_content' branch_labels = None depends_on = None - def upgrade() -> None: - # Add MFA fields to users table op.add_column('users', sa.Column('mfa_enabled', sa.Boolean(), nullable=False, server_default='0')) op.add_column('users', sa.Column('mfa_secret', sa.String(255), nullable=True)) op.add_column('users', sa.Column('mfa_backup_codes', sa.Text(), nullable=True)) - def downgrade() -> None: - # Remove MFA fields from users table op.drop_column('users', 'mfa_backup_codes') op.drop_column('users', 'mfa_secret') - op.drop_column('users', 'mfa_enabled') - + op.drop_column('users', 'mfa_enabled') \ No newline at end of file diff --git a/Backend/alembic/versions/1444eb61188e_add_section_title_fields_to_page_content.py b/Backend/alembic/versions/1444eb61188e_add_section_title_fields_to_page_content.py index 3f5845c1..55ed460e 100644 --- a/Backend/alembic/versions/1444eb61188e_add_section_title_fields_to_page_content.py +++ b/Backend/alembic/versions/1444eb61188e_add_section_title_fields_to_page_content.py @@ -1,23 +1,12 @@ -"""add_section_title_fields_to_page_content - -Revision ID: 1444eb61188e -Revises: ff515d77abbe -Create Date: 2025-11-20 15:51:29.671843 - -""" from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. revision = '1444eb61188e' down_revision = 'ff515d77abbe' branch_labels = None depends_on = None - def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.drop_index('bookings_room_id', table_name='bookings') op.drop_index('bookings_status', table_name='bookings') op.drop_index('bookings_user_id', table_name='bookings') @@ -57,10 +46,7 @@ def upgrade() -> None: op.add_column('page_contents', sa.Column('awards_section_subtitle', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('partners_section_title', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('partners_section_subtitle', sa.Text(), nullable=True)) - op.alter_column('password_reset_tokens', 'used', - existing_type=mysql.TINYINT(display_width=1), - nullable=False, - existing_server_default=sa.text("'0'")) + op.alter_column('password_reset_tokens', 'used', existing_type=mysql.TINYINT(display_width=1), nullable=False, existing_server_default=sa.text("'0'")) op.drop_index('password_reset_tokens_token', table_name='password_reset_tokens') op.drop_index('password_reset_tokens_user_id', table_name='password_reset_tokens') op.drop_index('token', table_name='password_reset_tokens') @@ -68,11 +54,7 @@ def upgrade() -> None: op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True) op.drop_constraint('password_reset_tokens_ibfk_1', 'password_reset_tokens', type_='foreignkey') op.create_foreign_key(None, 'password_reset_tokens', 'users', ['user_id'], ['id']) - op.alter_column('payments', 'deposit_percentage', - existing_type=mysql.INTEGER(), - comment=None, - existing_comment='Percentage of deposit (e.g., 20, 30, 50)', - existing_nullable=True) + op.alter_column('payments', 'deposit_percentage', existing_type=mysql.INTEGER(), comment=None, existing_comment='Percentage of deposit (e.g., 20, 30, 50)', existing_nullable=True) op.drop_index('payments_booking_id', table_name='payments') op.drop_index('payments_payment_status', table_name='payments') op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False) @@ -128,11 +110,8 @@ def upgrade() -> None: op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) op.drop_constraint('users_ibfk_1', 'users', type_='foreignkey') op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id']) - # ### end Alembic commands ### - def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint(None, 'users', type_='foreignkey') op.create_foreign_key('users_ibfk_1', 'users', 'roles', ['role_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') op.drop_index(op.f('ix_users_id'), table_name='users') @@ -188,10 +167,7 @@ def downgrade() -> None: op.drop_index(op.f('ix_payments_id'), table_name='payments') op.create_index('payments_payment_status', 'payments', ['payment_status'], unique=False) op.create_index('payments_booking_id', 'payments', ['booking_id'], unique=False) - op.alter_column('payments', 'deposit_percentage', - existing_type=mysql.INTEGER(), - comment='Percentage of deposit (e.g., 20, 30, 50)', - existing_nullable=True) + op.alter_column('payments', 'deposit_percentage', existing_type=mysql.INTEGER(), comment='Percentage of deposit (e.g., 20, 30, 50)', existing_nullable=True) op.drop_constraint(None, 'password_reset_tokens', type_='foreignkey') op.create_foreign_key('password_reset_tokens_ibfk_1', 'password_reset_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens') @@ -199,10 +175,7 @@ def downgrade() -> None: op.create_index('token', 'password_reset_tokens', ['token'], unique=False) op.create_index('password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'], unique=False) op.create_index('password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=False) - op.alter_column('password_reset_tokens', 'used', - existing_type=mysql.TINYINT(display_width=1), - nullable=True, - existing_server_default=sa.text("'0'")) + op.alter_column('password_reset_tokens', 'used', existing_type=mysql.TINYINT(display_width=1), nullable=True, existing_server_default=sa.text("'0'")) op.drop_column('page_contents', 'partners_section_subtitle') op.drop_column('page_contents', 'partners_section_title') op.drop_column('page_contents', 'awards_section_subtitle') @@ -241,6 +214,4 @@ def downgrade() -> None: op.create_index('ix_bookings_promotion_code', 'bookings', ['promotion_code'], unique=False) op.create_index('bookings_user_id', 'bookings', ['user_id'], unique=False) op.create_index('bookings_status', 'bookings', ['status'], unique=False) - op.create_index('bookings_room_id', 'bookings', ['room_id'], unique=False) - # ### end Alembic commands ### - + op.create_index('bookings_room_id', 'bookings', ['room_id'], unique=False) \ No newline at end of file diff --git a/Backend/alembic/versions/163657e72e93_add_page_content_table.py b/Backend/alembic/versions/163657e72e93_add_page_content_table.py index 08c3a32b..4ff81685 100644 --- a/Backend/alembic/versions/163657e72e93_add_page_content_table.py +++ b/Backend/alembic/versions/163657e72e93_add_page_content_table.py @@ -1,62 +1,18 @@ -"""add_page_content_table - -Revision ID: 163657e72e93 -Revises: 6a126cc5b23c -Create Date: 2025-11-18 18:02:03.480951 - -""" from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. revision = '163657e72e93' down_revision = '6a126cc5b23c' branch_labels = None depends_on = None - def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # Only create the page_contents table, skip other schema changes - op.create_table('page_contents', - sa.Column('id', sa.Integer(), nullable=False), - sa.Column('page_type', sa.Enum('home', 'contact', 'about', 'footer', 'seo', name='pagetype'), nullable=False), - sa.Column('title', sa.String(length=500), nullable=True), - sa.Column('subtitle', sa.String(length=1000), nullable=True), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('content', sa.Text(), nullable=True), - sa.Column('meta_title', sa.String(length=500), nullable=True), - sa.Column('meta_description', sa.Text(), nullable=True), - sa.Column('meta_keywords', sa.String(length=1000), nullable=True), - sa.Column('og_title', sa.String(length=500), nullable=True), - sa.Column('og_description', sa.Text(), nullable=True), - sa.Column('og_image', sa.String(length=1000), nullable=True), - sa.Column('canonical_url', sa.String(length=1000), nullable=True), - sa.Column('contact_info', sa.Text(), nullable=True), - sa.Column('social_links', sa.Text(), nullable=True), - sa.Column('footer_links', sa.Text(), nullable=True), - sa.Column('hero_title', sa.String(length=500), nullable=True), - sa.Column('hero_subtitle', sa.String(length=1000), nullable=True), - sa.Column('hero_image', sa.String(length=1000), nullable=True), - sa.Column('story_content', sa.Text(), nullable=True), - sa.Column('values', sa.Text(), nullable=True), - sa.Column('features', sa.Text(), nullable=True), - sa.Column('is_active', sa.Boolean(), nullable=False), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint('id') - ) + op.create_table('page_contents', sa.Column('id', sa.Integer(), nullable=False), sa.Column('page_type', sa.Enum('home', 'contact', 'about', 'footer', 'seo', name='pagetype'), nullable=False), sa.Column('title', sa.String(length=500), nullable=True), sa.Column('subtitle', sa.String(length=1000), nullable=True), sa.Column('description', sa.Text(), nullable=True), sa.Column('content', sa.Text(), nullable=True), sa.Column('meta_title', sa.String(length=500), nullable=True), sa.Column('meta_description', sa.Text(), nullable=True), sa.Column('meta_keywords', sa.String(length=1000), nullable=True), sa.Column('og_title', sa.String(length=500), nullable=True), sa.Column('og_description', sa.Text(), nullable=True), sa.Column('og_image', sa.String(length=1000), nullable=True), sa.Column('canonical_url', sa.String(length=1000), nullable=True), sa.Column('contact_info', sa.Text(), nullable=True), sa.Column('social_links', sa.Text(), nullable=True), sa.Column('footer_links', sa.Text(), nullable=True), sa.Column('hero_title', sa.String(length=500), nullable=True), sa.Column('hero_subtitle', sa.String(length=1000), nullable=True), sa.Column('hero_image', sa.String(length=1000), nullable=True), sa.Column('story_content', sa.Text(), nullable=True), sa.Column('values', sa.Text(), nullable=True), sa.Column('features', sa.Text(), nullable=True), sa.Column('is_active', sa.Boolean(), nullable=False), sa.Column('created_at', sa.DateTime(), nullable=False), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.PrimaryKeyConstraint('id')) op.create_index(op.f('ix_page_contents_id'), 'page_contents', ['id'], unique=False) op.create_index(op.f('ix_page_contents_page_type'), 'page_contents', ['page_type'], unique=True) - # ### end Alembic commands ### - def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.drop_index(op.f('ix_page_contents_page_type'), table_name='page_contents') op.drop_index(op.f('ix_page_contents_id'), table_name='page_contents') op.drop_table('page_contents') - op.execute("DROP TYPE IF EXISTS pagetype") - # ### end Alembic commands ### - + op.execute('DROP TYPE IF EXISTS pagetype') \ No newline at end of file diff --git a/Backend/alembic/versions/17efc6439cc3_add_luxury_section_fields_to_page_.py b/Backend/alembic/versions/17efc6439cc3_add_luxury_section_fields_to_page_.py index 6c47ef81..9ddc5f8e 100644 --- a/Backend/alembic/versions/17efc6439cc3_add_luxury_section_fields_to_page_.py +++ b/Backend/alembic/versions/17efc6439cc3_add_luxury_section_fields_to_page_.py @@ -1,38 +1,22 @@ -"""add_luxury_section_fields_to_page_content - -Revision ID: 17efc6439cc3 -Revises: bfa74be4b256 -Create Date: 2025-11-20 13:37:20.015422 - -""" from alembic import op import sqlalchemy as sa - - -# revision identifiers, used by Alembic. revision = '17efc6439cc3' down_revision = 'bfa74be4b256' branch_labels = None depends_on = None - def upgrade() -> None: - # Add luxury section fields to page_contents table - # Use TEXT instead of VARCHAR to avoid MySQL row size limits op.add_column('page_contents', sa.Column('luxury_section_title', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('luxury_section_subtitle', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('luxury_section_image', sa.Text(), nullable=True)) - op.add_column('page_contents', sa.Column('luxury_features', sa.Text(), nullable=True)) # JSON array - op.add_column('page_contents', sa.Column('luxury_gallery', sa.Text(), nullable=True)) # JSON array of image URLs - op.add_column('page_contents', sa.Column('luxury_testimonials', sa.Text(), nullable=True)) # JSON array of testimonials - + op.add_column('page_contents', sa.Column('luxury_features', sa.Text(), nullable=True)) + op.add_column('page_contents', sa.Column('luxury_gallery', sa.Text(), nullable=True)) + op.add_column('page_contents', sa.Column('luxury_testimonials', sa.Text(), nullable=True)) def downgrade() -> None: - # Remove luxury section fields op.drop_column('page_contents', 'luxury_testimonials') op.drop_column('page_contents', 'luxury_gallery') op.drop_column('page_contents', 'luxury_features') op.drop_column('page_contents', 'luxury_section_image') op.drop_column('page_contents', 'luxury_section_subtitle') - op.drop_column('page_contents', 'luxury_section_title') - + op.drop_column('page_contents', 'luxury_section_title') \ No newline at end of file diff --git a/Backend/alembic/versions/59baf2338f8a_initial_migration_create_all_tables_.py b/Backend/alembic/versions/59baf2338f8a_initial_migration_create_all_tables_.py index fcb3e0d3..dc2ee503 100644 --- a/Backend/alembic/versions/59baf2338f8a_initial_migration_create_all_tables_.py +++ b/Backend/alembic/versions/59baf2338f8a_initial_migration_create_all_tables_.py @@ -1,39 +1,13 @@ -"""Initial migration: create all tables with indexes - -Revision ID: 59baf2338f8a -Revises: -Create Date: 2025-11-16 16:03:26.313117 - -""" from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. revision = '59baf2338f8a' down_revision = None branch_labels = None depends_on = None - def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('audit_logs', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('user_id', sa.Integer(), nullable=True), - sa.Column('action', sa.String(length=100), nullable=False), - sa.Column('resource_type', sa.String(length=50), nullable=False), - sa.Column('resource_id', sa.Integer(), nullable=True), - sa.Column('ip_address', sa.String(length=45), nullable=True), - sa.Column('user_agent', sa.String(length=255), nullable=True), - sa.Column('request_id', sa.String(length=36), nullable=True), - sa.Column('details', sa.JSON(), nullable=True), - sa.Column('status', sa.String(length=20), nullable=False), - sa.Column('error_message', sa.Text(), nullable=True), - sa.Column('created_at', sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) + op.create_table('audit_logs', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('action', sa.String(length=100), nullable=False), sa.Column('resource_type', sa.String(length=50), nullable=False), sa.Column('resource_id', sa.Integer(), nullable=True), sa.Column('ip_address', sa.String(length=45), nullable=True), sa.Column('user_agent', sa.String(length=255), nullable=True), sa.Column('request_id', sa.String(length=36), nullable=True), sa.Column('details', sa.JSON(), nullable=True), sa.Column('status', sa.String(length=20), nullable=False), sa.Column('error_message', sa.Text(), nullable=True), sa.Column('created_at', sa.DateTime(), nullable=False), sa.ForeignKeyConstraint(['user_id'], ['users.id']), sa.PrimaryKeyConstraint('id')) op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False) op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False) op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False) @@ -46,7 +20,6 @@ def upgrade() -> None: op.drop_index('banners_is_active', table_name='banners') op.drop_index('banners_position', table_name='banners') op.create_index(op.f('ix_banners_id'), 'banners', ['id'], unique=False) - # Drop foreign keys first, then indexes op.drop_constraint('bookings_ibfk_2', 'bookings', type_='foreignkey') op.drop_constraint('bookings_ibfk_1', 'bookings', type_='foreignkey') op.drop_index('booking_number', table_name='bookings') @@ -60,7 +33,6 @@ def upgrade() -> None: op.create_index(op.f('ix_bookings_id'), 'bookings', ['id'], unique=False) op.create_foreign_key(None, 'bookings', 'users', ['user_id'], ['id']) op.create_foreign_key(None, 'bookings', 'rooms', ['room_id'], ['id']) - # Drop foreign keys first, then indexes op.drop_constraint('checkin_checkout_ibfk_1', 'checkin_checkout', type_='foreignkey') op.drop_constraint('checkin_checkout_ibfk_2', 'checkin_checkout', type_='foreignkey') op.drop_constraint('checkin_checkout_ibfk_3', 'checkin_checkout', type_='foreignkey') @@ -70,7 +42,6 @@ def upgrade() -> None: op.create_foreign_key(None, 'checkin_checkout', 'bookings', ['booking_id'], ['id']) op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkout_by'], ['id']) op.create_foreign_key(None, 'checkin_checkout', 'users', ['checkin_by'], ['id']) - # Drop foreign keys first, then indexes op.drop_constraint('favorites_ibfk_2', 'favorites', type_='foreignkey') op.drop_constraint('favorites_ibfk_1', 'favorites', type_='foreignkey') op.drop_index('favorites_room_id', table_name='favorites') @@ -79,11 +50,7 @@ def upgrade() -> None: op.create_index(op.f('ix_favorites_id'), 'favorites', ['id'], unique=False) op.create_foreign_key(None, 'favorites', 'users', ['user_id'], ['id']) op.create_foreign_key(None, 'favorites', 'rooms', ['room_id'], ['id']) - op.alter_column('password_reset_tokens', 'used', - existing_type=mysql.TINYINT(display_width=1), - nullable=False, - existing_server_default=sa.text("'0'")) - # Drop foreign key first, then indexes + op.alter_column('password_reset_tokens', 'used', existing_type=mysql.TINYINT(display_width=1), nullable=False, existing_server_default=sa.text("'0'")) op.drop_constraint('password_reset_tokens_ibfk_1', 'password_reset_tokens', type_='foreignkey') op.drop_index('password_reset_tokens_token', table_name='password_reset_tokens') op.drop_index('password_reset_tokens_user_id', table_name='password_reset_tokens') @@ -91,12 +58,7 @@ def upgrade() -> None: op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False) op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True) op.create_foreign_key(None, 'password_reset_tokens', 'users', ['user_id'], ['id']) - op.alter_column('payments', 'deposit_percentage', - existing_type=mysql.INTEGER(), - comment=None, - existing_comment='Percentage of deposit (e.g., 20, 30, 50)', - existing_nullable=True) - # Drop foreign keys first, then indexes + op.alter_column('payments', 'deposit_percentage', existing_type=mysql.INTEGER(), comment=None, existing_comment='Percentage of deposit (e.g., 20, 30, 50)', existing_nullable=True) op.drop_constraint('payments_related_payment_id_foreign_idx', 'payments', type_='foreignkey') op.drop_constraint('payments_ibfk_1', 'payments', type_='foreignkey') op.drop_index('payments_booking_id', table_name='payments') @@ -109,7 +71,6 @@ def upgrade() -> None: op.drop_index('promotions_is_active', table_name='promotions') op.create_index(op.f('ix_promotions_code'), 'promotions', ['code'], unique=True) op.create_index(op.f('ix_promotions_id'), 'promotions', ['id'], unique=False) - # Drop foreign key first, then indexes op.drop_constraint('refresh_tokens_ibfk_1', 'refresh_tokens', type_='foreignkey') op.drop_index('refresh_tokens_token', table_name='refresh_tokens') op.drop_index('refresh_tokens_user_id', table_name='refresh_tokens') @@ -117,7 +78,6 @@ def upgrade() -> None: op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False) op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True) op.create_foreign_key(None, 'refresh_tokens', 'users', ['user_id'], ['id']) - # Drop foreign keys first, then indexes op.drop_constraint('reviews_ibfk_2', 'reviews', type_='foreignkey') op.drop_constraint('reviews_ibfk_1', 'reviews', type_='foreignkey') op.drop_index('reviews_room_id', table_name='reviews') @@ -130,7 +90,6 @@ def upgrade() -> None: op.create_index(op.f('ix_roles_id'), 'roles', ['id'], unique=False) op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True) op.create_index(op.f('ix_room_types_id'), 'room_types', ['id'], unique=False) - # Drop foreign key first, then indexes op.drop_constraint('rooms_ibfk_1', 'rooms', type_='foreignkey') op.drop_index('room_number', table_name='rooms') op.drop_index('rooms_featured', table_name='rooms') @@ -139,7 +98,6 @@ def upgrade() -> None: op.create_index(op.f('ix_rooms_id'), 'rooms', ['id'], unique=False) op.create_index(op.f('ix_rooms_room_number'), 'rooms', ['room_number'], unique=True) op.create_foreign_key(None, 'rooms', 'room_types', ['room_type_id'], ['id']) - # Drop foreign keys first, then indexes op.drop_constraint('service_usages_ibfk_1', 'service_usages', type_='foreignkey') op.drop_constraint('service_usages_ibfk_2', 'service_usages', type_='foreignkey') op.drop_index('service_usages_booking_id', table_name='service_usages') @@ -149,7 +107,6 @@ def upgrade() -> None: op.create_foreign_key(None, 'service_usages', 'services', ['service_id'], ['id']) op.drop_index('services_category', table_name='services') op.create_index(op.f('ix_services_id'), 'services', ['id'], unique=False) - # Drop foreign key first, then indexes op.drop_constraint('users_ibfk_1', 'users', type_='foreignkey') op.drop_index('email', table_name='users') op.drop_index('users_email', table_name='users') @@ -157,11 +114,8 @@ def upgrade() -> None: op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False) op.create_foreign_key(None, 'users', 'roles', ['role_id'], ['id']) - # ### end Alembic commands ### - def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### op.drop_constraint(None, 'users', type_='foreignkey') op.create_foreign_key('users_ibfk_1', 'users', 'roles', ['role_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT') op.drop_index(op.f('ix_users_id'), table_name='users') @@ -217,10 +171,7 @@ def downgrade() -> None: op.drop_index(op.f('ix_payments_id'), table_name='payments') op.create_index('payments_payment_status', 'payments', ['payment_status'], unique=False) op.create_index('payments_booking_id', 'payments', ['booking_id'], unique=False) - op.alter_column('payments', 'deposit_percentage', - existing_type=mysql.INTEGER(), - comment='Percentage of deposit (e.g., 20, 30, 50)', - existing_nullable=True) + op.alter_column('payments', 'deposit_percentage', existing_type=mysql.INTEGER(), comment='Percentage of deposit (e.g., 20, 30, 50)', existing_nullable=True) op.drop_constraint(None, 'password_reset_tokens', type_='foreignkey') op.create_foreign_key('password_reset_tokens_ibfk_1', 'password_reset_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens') @@ -228,10 +179,7 @@ def downgrade() -> None: op.create_index('token', 'password_reset_tokens', ['token'], unique=False) op.create_index('password_reset_tokens_user_id', 'password_reset_tokens', ['user_id'], unique=False) op.create_index('password_reset_tokens_token', 'password_reset_tokens', ['token'], unique=False) - op.alter_column('password_reset_tokens', 'used', - existing_type=mysql.TINYINT(display_width=1), - nullable=True, - existing_server_default=sa.text("'0'")) + op.alter_column('password_reset_tokens', 'used', existing_type=mysql.TINYINT(display_width=1), nullable=True, existing_server_default=sa.text("'0'")) op.drop_constraint(None, 'favorites', type_='foreignkey') op.drop_constraint(None, 'favorites', type_='foreignkey') op.create_foreign_key('favorites_ibfk_1', 'favorites', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE') @@ -265,13 +213,7 @@ def downgrade() -> None: op.drop_index(op.f('ix_banners_id'), table_name='banners') op.create_index('banners_position', 'banners', ['position'], unique=False) op.create_index('banners_is_active', 'banners', ['is_active'], unique=False) - op.create_table('SequelizeMeta', - sa.Column('name', mysql.VARCHAR(collation='utf8mb3_unicode_ci', length=255), nullable=False), - sa.PrimaryKeyConstraint('name'), - mysql_collate='utf8mb3_unicode_ci', - mysql_default_charset='utf8mb3', - mysql_engine='InnoDB' - ) + op.create_table('SequelizeMeta', sa.Column('name', mysql.VARCHAR(collation='utf8mb3_unicode_ci', length=255), nullable=False), sa.PrimaryKeyConstraint('name'), mysql_collate='utf8mb3_unicode_ci', mysql_default_charset='utf8mb3', mysql_engine='InnoDB') op.create_index('name', 'SequelizeMeta', ['name'], unique=False) op.drop_index(op.f('ix_audit_logs_user_id'), table_name='audit_logs') op.drop_index(op.f('ix_audit_logs_resource_type'), table_name='audit_logs') @@ -280,6 +222,4 @@ def downgrade() -> None: op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs') op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs') op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs') - op.drop_table('audit_logs') - # ### end Alembic commands ### - + op.drop_table('audit_logs') \ No newline at end of file diff --git a/Backend/alembic/versions/6a126cc5b23c_add_capacity_room_size_view_to_rooms.py b/Backend/alembic/versions/6a126cc5b23c_add_capacity_room_size_view_to_rooms.py index 28ff8e19..a8c1e3ae 100644 --- a/Backend/alembic/versions/6a126cc5b23c_add_capacity_room_size_view_to_rooms.py +++ b/Backend/alembic/versions/6a126cc5b23c_add_capacity_room_size_view_to_rooms.py @@ -1,29 +1,16 @@ -"""add_capacity_room_size_view_to_rooms - -Revision ID: 6a126cc5b23c -Revises: add_stripe_payment_method -Create Date: 2025-11-17 16:25:09.581786 - -""" from alembic import op import sqlalchemy as sa - -# revision identifiers, used by Alembic. revision = '6a126cc5b23c' down_revision = 'add_stripe_payment_method' branch_labels = None depends_on = None - def upgrade() -> None: - # Add the three new columns to rooms table op.add_column('rooms', sa.Column('capacity', sa.Integer(), nullable=True)) op.add_column('rooms', sa.Column('room_size', sa.String(length=50), nullable=True)) op.add_column('rooms', sa.Column('view', sa.String(length=100), nullable=True)) - def downgrade() -> None: - # Remove the three columns from rooms table op.drop_column('rooms', 'view') op.drop_column('rooms', 'room_size') - op.drop_column('rooms', 'capacity') + op.drop_column('rooms', 'capacity') \ No newline at end of file diff --git a/Backend/alembic/versions/96c23dad405d_add_system_settings_table.py b/Backend/alembic/versions/96c23dad405d_add_system_settings_table.py index 348e7209..61b6a0a2 100644 --- a/Backend/alembic/versions/96c23dad405d_add_system_settings_table.py +++ b/Backend/alembic/versions/96c23dad405d_add_system_settings_table.py @@ -1,60 +1,29 @@ -"""add_system_settings_table - -Revision ID: 96c23dad405d -Revises: 59baf2338f8a -Create Date: 2025-11-17 11:51:28.369031 - -""" from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. revision = '96c23dad405d' down_revision = '59baf2338f8a' branch_labels = None depends_on = None - def upgrade() -> None: - # Create system_settings table (if it doesn't exist) from sqlalchemy import inspect bind = op.get_bind() inspector = inspect(bind) tables = inspector.get_table_names() - if 'system_settings' not in tables: - op.create_table('system_settings', - sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), - sa.Column('key', sa.String(length=100), nullable=False), - sa.Column('value', sa.Text(), nullable=False), - sa.Column('description', sa.Text(), nullable=True), - sa.Column('updated_at', sa.DateTime(), nullable=False), - sa.Column('updated_by_id', sa.Integer(), nullable=True), - sa.ForeignKeyConstraint(['updated_by_id'], ['users.id'], ), - sa.PrimaryKeyConstraint('id') - ) + op.create_table('system_settings', sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), sa.Column('key', sa.String(length=100), nullable=False), sa.Column('value', sa.Text(), nullable=False), sa.Column('description', sa.Text(), nullable=True), sa.Column('updated_at', sa.DateTime(), nullable=False), sa.Column('updated_by_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['updated_by_id'], ['users.id']), sa.PrimaryKeyConstraint('id')) op.create_index(op.f('ix_system_settings_id'), 'system_settings', ['id'], unique=False) op.create_index(op.f('ix_system_settings_key'), 'system_settings', ['key'], unique=True) - - # Add currency column to users table (if it doesn't exist) columns = [col['name'] for col in inspector.get_columns('users')] if 'currency' not in columns: op.add_column('users', sa.Column('currency', sa.String(length=3), nullable=False, server_default='VND')) - # ### end Alembic commands ### - def downgrade() -> None: - # Drop currency column from users table try: op.drop_column('users', 'currency') except Exception: - # Column might not exist, skip pass - - # Drop system_settings table op.drop_index(op.f('ix_system_settings_key'), table_name='system_settings') op.drop_index(op.f('ix_system_settings_id'), table_name='system_settings') - op.drop_table('system_settings') - # ### end Alembic commands ### - + op.drop_table('system_settings') \ No newline at end of file diff --git a/Backend/alembic/versions/add_about_page_fields.py b/Backend/alembic/versions/add_about_page_fields.py index fba405ad..a491823a 100644 --- a/Backend/alembic/versions/add_about_page_fields.py +++ b/Backend/alembic/versions/add_about_page_fields.py @@ -1,22 +1,11 @@ -"""add_about_page_fields - -Revision ID: f2a3b4c5d6e7 -Revises: a1b2c3d4e5f6 -Create Date: 2025-11-20 17:00:00.000000 - -""" from alembic import op import sqlalchemy as sa - -# revision identifiers, used by Alembic. revision = 'f2a3b4c5d6e7' down_revision = 'a1b2c3d4e5f6' branch_labels = None depends_on = None - def upgrade() -> None: - # Add about page specific fields (all as TEXT to avoid row size issues) op.add_column('page_contents', sa.Column('about_hero_image', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('mission', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('vision', sa.Text(), nullable=True)) @@ -24,13 +13,10 @@ def upgrade() -> None: op.add_column('page_contents', sa.Column('timeline', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('achievements', sa.Text(), nullable=True)) - def downgrade() -> None: - # Remove about page specific fields op.drop_column('page_contents', 'achievements') op.drop_column('page_contents', 'timeline') op.drop_column('page_contents', 'team') op.drop_column('page_contents', 'vision') op.drop_column('page_contents', 'mission') - op.drop_column('page_contents', 'about_hero_image') - + op.drop_column('page_contents', 'about_hero_image') \ No newline at end of file diff --git a/Backend/alembic/versions/add_badges_to_page_content.py b/Backend/alembic/versions/add_badges_to_page_content.py index 54f7cb0c..d2a50692 100644 --- a/Backend/alembic/versions/add_badges_to_page_content.py +++ b/Backend/alembic/versions/add_badges_to_page_content.py @@ -1,26 +1,12 @@ -"""add_badges_to_page_content - -Revision ID: add_badges_to_page_content -Revises: cce764ef7a50 -Create Date: 2025-01-14 10:00:00.000000 - -""" from alembic import op import sqlalchemy as sa - -# revision identifiers, used by Alembic. revision = 'add_badges_to_page_content' down_revision = 'cce764ef7a50' branch_labels = None depends_on = None - def upgrade() -> None: - # Add badges column to page_contents table op.add_column('page_contents', sa.Column('badges', sa.Text(), nullable=True)) - def downgrade() -> None: - # Remove badges column from page_contents table - op.drop_column('page_contents', 'badges') - + op.drop_column('page_contents', 'badges') \ No newline at end of file diff --git a/Backend/alembic/versions/add_copyright_text_to_page_content.py b/Backend/alembic/versions/add_copyright_text_to_page_content.py index 24de9b6a..cf815d69 100644 --- a/Backend/alembic/versions/add_copyright_text_to_page_content.py +++ b/Backend/alembic/versions/add_copyright_text_to_page_content.py @@ -1,26 +1,12 @@ -"""add_copyright_text_to_page_content - -Revision ID: a1b2c3d4e5f6 -Revises: ff515d77abbe -Create Date: 2025-11-20 16:00:00.000000 - -""" from alembic import op import sqlalchemy as sa - -# revision identifiers, used by Alembic. revision = 'a1b2c3d4e5f6' down_revision = '1444eb61188e' branch_labels = None depends_on = None - def upgrade() -> None: - # Add copyright_text column to page_contents table op.add_column('page_contents', sa.Column('copyright_text', sa.Text(), nullable=True)) - def downgrade() -> None: - # Remove copyright_text column from page_contents table - op.drop_column('page_contents', 'copyright_text') - + op.drop_column('page_contents', 'copyright_text') \ No newline at end of file diff --git a/Backend/alembic/versions/add_stripe_payment_method.py b/Backend/alembic/versions/add_stripe_payment_method.py index a442b6a3..44fd5a16 100644 --- a/Backend/alembic/versions/add_stripe_payment_method.py +++ b/Backend/alembic/versions/add_stripe_payment_method.py @@ -1,50 +1,19 @@ -"""add_stripe_payment_method - -Revision ID: add_stripe_payment_method -Revises: 96c23dad405d -Create Date: 2025-01-17 12:00:00.000000 - -""" from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql - -# revision identifiers, used by Alembic. revision = 'add_stripe_payment_method' down_revision = '96c23dad405d' branch_labels = None depends_on = None - def upgrade() -> None: - # Note: MySQL ENUM modifications can be tricky. - # If payments table already has data with existing enum values, - # we need to preserve them when adding 'stripe' - - # For MySQL, we need to alter the ENUM column to include the new value - # Check if we're using MySQL bind = op.get_bind() if bind.dialect.name == 'mysql': - # Alter the ENUM column to include 'stripe' - # This preserves existing values and adds 'stripe' - op.execute( - "ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe') NOT NULL" - ) + op.execute("ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe') NOT NULL") else: - # For other databases (PostgreSQL, SQLite), enum changes are handled differently - # For SQLite, this might not be needed as it doesn't enforce enum constraints pass - # ### end Alembic commands ### - def downgrade() -> None: - # Remove 'stripe' from the ENUM (be careful if there are existing stripe payments) bind = op.get_bind() if bind.dialect.name == 'mysql': - # First, check if there are any stripe payments - if so, this will fail - # In production, you'd want to migrate existing stripe payments first - op.execute( - "ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet') NOT NULL" - ) - # ### end Alembic commands ### - + op.execute("ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet') NOT NULL") \ No newline at end of file diff --git a/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py b/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py index 3c6a31e4..d96b49d2 100644 --- a/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py +++ b/Backend/alembic/versions/bd309b0742c1_add_promotion_fields_to_bookings.py @@ -1,34 +1,18 @@ -"""add_promotion_fields_to_bookings - -Revision ID: bd309b0742c1 -Revises: f1a2b3c4d5e6 -Create Date: 2025-11-20 02:16:09.496685 - -""" from alembic import op import sqlalchemy as sa - - -# revision identifiers, used by Alembic. revision = 'bd309b0742c1' down_revision = 'f1a2b3c4d5e6' branch_labels = None depends_on = None - def upgrade() -> None: - # Add promotion-related columns to bookings table op.add_column('bookings', sa.Column('original_price', sa.Numeric(10, 2), nullable=True)) op.add_column('bookings', sa.Column('discount_amount', sa.Numeric(10, 2), nullable=True, server_default='0')) op.add_column('bookings', sa.Column('promotion_code', sa.String(50), nullable=True)) - # Add index on promotion_code for faster lookups op.create_index(op.f('ix_bookings_promotion_code'), 'bookings', ['promotion_code'], unique=False) - def downgrade() -> None: - # Remove promotion-related columns op.drop_index(op.f('ix_bookings_promotion_code'), table_name='bookings') op.drop_column('bookings', 'promotion_code') op.drop_column('bookings', 'discount_amount') - op.drop_column('bookings', 'original_price') - + op.drop_column('bookings', 'original_price') \ No newline at end of file diff --git a/Backend/alembic/versions/bfa74be4b256_add_luxury_content_fields_to_page_.py b/Backend/alembic/versions/bfa74be4b256_add_luxury_content_fields_to_page_.py index 66496e19..090bc9af 100644 --- a/Backend/alembic/versions/bfa74be4b256_add_luxury_content_fields_to_page_.py +++ b/Backend/alembic/versions/bfa74be4b256_add_luxury_content_fields_to_page_.py @@ -1,23 +1,11 @@ -"""add_luxury_content_fields_to_page_content - -Revision ID: bfa74be4b256 -Revises: bd309b0742c1 -Create Date: 2025-11-20 13:27:52.106013 - -""" from alembic import op import sqlalchemy as sa - - -# revision identifiers, used by Alembic. revision = 'bfa74be4b256' down_revision = 'bd309b0742c1' branch_labels = None depends_on = None - def upgrade() -> None: - # Add luxury content fields to page_contents table op.add_column('page_contents', sa.Column('amenities_section_title', sa.String(500), nullable=True)) op.add_column('page_contents', sa.Column('amenities_section_subtitle', sa.String(1000), nullable=True)) op.add_column('page_contents', sa.Column('amenities', sa.Text(), nullable=True)) @@ -33,9 +21,7 @@ def upgrade() -> None: op.add_column('page_contents', sa.Column('about_preview_image', sa.String(1000), nullable=True)) op.add_column('page_contents', sa.Column('stats', sa.Text(), nullable=True)) - def downgrade() -> None: - # Remove luxury content fields op.drop_column('page_contents', 'stats') op.drop_column('page_contents', 'about_preview_image') op.drop_column('page_contents', 'about_preview_content') @@ -49,5 +35,4 @@ def downgrade() -> None: op.drop_column('page_contents', 'testimonials_section_title') op.drop_column('page_contents', 'amenities') op.drop_column('page_contents', 'amenities_section_subtitle') - op.drop_column('page_contents', 'amenities_section_title') - + op.drop_column('page_contents', 'amenities_section_title') \ No newline at end of file diff --git a/Backend/alembic/versions/cce764ef7a50_add_map_url_to_page_content.py b/Backend/alembic/versions/cce764ef7a50_add_map_url_to_page_content.py index 112cf9eb..e07d723e 100644 --- a/Backend/alembic/versions/cce764ef7a50_add_map_url_to_page_content.py +++ b/Backend/alembic/versions/cce764ef7a50_add_map_url_to_page_content.py @@ -1,28 +1,12 @@ -"""add_map_url_to_page_content - -Revision ID: cce764ef7a50 -Revises: 163657e72e93 -Create Date: 2025-11-18 18:11:41.071053 - -""" from alembic import op import sqlalchemy as sa - -# revision identifiers, used by Alembic. revision = 'cce764ef7a50' down_revision = '163657e72e93' branch_labels = None depends_on = None - def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - # Only add the map_url column to page_contents table op.add_column('page_contents', sa.Column('map_url', sa.String(length=1000), nullable=True)) - # ### end Alembic commands ### - def downgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.drop_column('page_contents', 'map_url') - # ### end Alembic commands ### + op.drop_column('page_contents', 'map_url') \ No newline at end of file diff --git a/Backend/alembic/versions/d9aff6c5f0d4_add_paypal_payment_method.py b/Backend/alembic/versions/d9aff6c5f0d4_add_paypal_payment_method.py index b3798634..8eb38b6b 100644 --- a/Backend/alembic/versions/d9aff6c5f0d4_add_paypal_payment_method.py +++ b/Backend/alembic/versions/d9aff6c5f0d4_add_paypal_payment_method.py @@ -1,51 +1,19 @@ -"""add_paypal_payment_method - -Revision ID: d9aff6c5f0d4 -Revises: 08e2f866e131 -Create Date: 2025-11-19 12:07:50.703320 - -""" from alembic import op import sqlalchemy as sa from sqlalchemy.dialects import mysql - - -# revision identifiers, used by Alembic. revision = 'd9aff6c5f0d4' down_revision = '08e2f866e131' branch_labels = None depends_on = None - def upgrade() -> None: - # Note: MySQL ENUM modifications can be tricky. - # If payments table already has data with existing enum values, - # we need to preserve them when adding 'paypal' - - # For MySQL, we need to alter the ENUM column to include the new value - # Check if we're using MySQL bind = op.get_bind() if bind.dialect.name == 'mysql': - # Alter the ENUM column to include 'paypal' - # This preserves existing values and adds 'paypal' - op.execute( - "ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe', 'paypal') NOT NULL" - ) + op.execute("ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe', 'paypal') NOT NULL") else: - # For other databases (PostgreSQL, SQLite), enum changes are handled differently - # For SQLite, this might not be needed as it doesn't enforce enum constraints pass - # ### end Alembic commands ### - def downgrade() -> None: - # Remove 'paypal' from the ENUM (be careful if there are existing paypal payments) bind = op.get_bind() if bind.dialect.name == 'mysql': - # First, check if there are any paypal payments - if so, this will fail - # In production, you'd want to migrate existing paypal payments first - op.execute( - "ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe') NOT NULL" - ) - # ### end Alembic commands ### - + op.execute("ALTER TABLE payments MODIFY COLUMN payment_method ENUM('cash', 'credit_card', 'debit_card', 'bank_transfer', 'e_wallet', 'stripe') NOT NULL") \ No newline at end of file diff --git a/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py b/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py index b265142e..5b2657cc 100644 --- a/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py +++ b/Backend/alembic/versions/f1a2b3c4d5e6_add_is_proforma_to_invoices.py @@ -1,27 +1,12 @@ -"""add_is_proforma_to_invoices - -Revision ID: f1a2b3c4d5e6 -Revises: d9aff6c5f0d4 -Create Date: 2025-11-20 00:20:00.000000 - -""" from alembic import op import sqlalchemy as sa - - -# revision identifiers, used by Alembic. revision = 'f1a2b3c4d5e6' down_revision = 'd9aff6c5f0d4' branch_labels = None depends_on = None - def upgrade() -> None: - # Add is_proforma column to invoices table op.add_column('invoices', sa.Column('is_proforma', sa.Boolean(), nullable=False, server_default='0')) - def downgrade() -> None: - # Remove is_proforma column - op.drop_column('invoices', 'is_proforma') - + op.drop_column('invoices', 'is_proforma') \ No newline at end of file diff --git a/Backend/alembic/versions/ff515d77abbe_add_more_luxury_sections_to_page_content.py b/Backend/alembic/versions/ff515d77abbe_add_more_luxury_sections_to_page_content.py index 743e69d1..8569ff46 100644 --- a/Backend/alembic/versions/ff515d77abbe_add_more_luxury_sections_to_page_content.py +++ b/Backend/alembic/versions/ff515d77abbe_add_more_luxury_sections_to_page_content.py @@ -1,36 +1,22 @@ -"""add_more_luxury_sections_to_page_content - -Revision ID: ff515d77abbe -Revises: 17efc6439cc3 -Create Date: 2025-11-20 15:17:50.977961 - -""" from alembic import op import sqlalchemy as sa - - -# revision identifiers, used by Alembic. revision = 'ff515d77abbe' down_revision = '17efc6439cc3' branch_labels = None depends_on = None - def upgrade() -> None: - # Add more luxury sections to page_contents table - op.add_column('page_contents', sa.Column('luxury_services', sa.Text(), nullable=True)) # JSON array of services - op.add_column('page_contents', sa.Column('luxury_experiences', sa.Text(), nullable=True)) # JSON array of experiences - op.add_column('page_contents', sa.Column('awards', sa.Text(), nullable=True)) # JSON array of awards + op.add_column('page_contents', sa.Column('luxury_services', sa.Text(), nullable=True)) + op.add_column('page_contents', sa.Column('luxury_experiences', sa.Text(), nullable=True)) + op.add_column('page_contents', sa.Column('awards', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('cta_title', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('cta_subtitle', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('cta_button_text', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('cta_button_link', sa.Text(), nullable=True)) op.add_column('page_contents', sa.Column('cta_image', sa.Text(), nullable=True)) - op.add_column('page_contents', sa.Column('partners', sa.Text(), nullable=True)) # JSON array of partners - + op.add_column('page_contents', sa.Column('partners', sa.Text(), nullable=True)) def downgrade() -> None: - # Remove luxury sections fields op.drop_column('page_contents', 'partners') op.drop_column('page_contents', 'cta_image') op.drop_column('page_contents', 'cta_button_link') @@ -39,5 +25,4 @@ def downgrade() -> None: op.drop_column('page_contents', 'cta_title') op.drop_column('page_contents', 'awards') op.drop_column('page_contents', 'luxury_experiences') - op.drop_column('page_contents', 'luxury_services') - + op.drop_column('page_contents', 'luxury_services') \ No newline at end of file diff --git a/Backend/reset_user_passwords.py b/Backend/reset_user_passwords.py index c317d3f2..aeabe583 100644 --- a/Backend/reset_user_passwords.py +++ b/Backend/reset_user_passwords.py @@ -1,13 +1,9 @@ #!/usr/bin/env python3 -""" -Script to reset passwords for test users -""" import sys import os import bcrypt -# Add the src directory to the path sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) from sqlalchemy.orm import Session @@ -19,7 +15,6 @@ logger = setup_logging() def hash_password(password: str) -> str: - """Hash password using bcrypt""" password_bytes = password.encode('utf-8') salt = bcrypt.gensalt() hashed = bcrypt.hashpw(password_bytes, salt) @@ -27,17 +22,14 @@ def hash_password(password: str) -> str: def reset_password(db: Session, email: str, new_password: str) -> bool: - """Reset password for a user""" user = db.query(User).filter(User.email == email).first() if not user: print(f"❌ User with email '{email}' not found") return False - # Hash new password hashed_password = hash_password(new_password) - # Update password user.password = hashed_password db.commit() db.refresh(user) @@ -51,7 +43,6 @@ def reset_password(db: Session, email: str, new_password: str) -> bool: def main(): - """Reset passwords for all test users""" db = SessionLocal() try: @@ -60,23 +51,6 @@ def main(): print("="*80) print() - test_users = [ - {"email": "admin@hotel.com", "password": "admin123"}, - {"email": "staff@hotel.com", "password": "staff123"}, - {"email": "customer@hotel.com", "password": "customer123"}, - ] - - for user_data in test_users: - reset_password(db, user_data["email"], user_data["password"]) - - print("="*80) - print("SUMMARY") - print("="*80) - print("All test user passwords have been reset.") - print("\nYou can now login with:") - for user_data in test_users: - print(f" {user_data['email']:<25} Password: {user_data['password']}") - print() except Exception as e: logger.error(f"Error: {e}", exc_info=True) @@ -88,4 +62,3 @@ def main(): if __name__ == "__main__": main() - diff --git a/Backend/run.py b/Backend/run.py index 77a5dd1e..7413afa3 100644 --- a/Backend/run.py +++ b/Backend/run.py @@ -1,48 +1,13 @@ -#!/usr/bin/env python3 -""" -Main entry point for the FastAPI server -""" import uvicorn from src.config.settings import settings from src.config.logging_config import setup_logging, get_logger - -# Setup logging setup_logging() logger = get_logger(__name__) - -if __name__ == "__main__": - logger.info(f"Starting {settings.APP_NAME} on {settings.HOST}:{settings.PORT}") - +if __name__ == '__main__': + logger.info(f'Starting {settings.APP_NAME} on {settings.HOST}:{settings.PORT}') import os from pathlib import Path - - # Only watch the src directory to avoid watching logs, uploads, etc. base_dir = Path(__file__).parent - src_dir = str(base_dir / "src") - - # Temporarily disable reload to stop constant "1 change detected" messages - # The file watcher is detecting changes that cause a loop - # TODO: Investigate what's causing constant file changes - use_reload = False # Disabled until we identify the source of constant changes - - 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 # Increase delay to reduce false positives - ) - + src_dir = str(base_dir / 'src') + use_reload = False + uvicorn.run('src.main:app', host=settings.HOST, port=8000, reload=use_reload, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if use_reload else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=1.0) \ No newline at end of file diff --git a/Backend/seed_about_page.py b/Backend/seed_about_page.py index 02fbdd0f..3fb59e7d 100644 --- a/Backend/seed_about_page.py +++ b/Backend/seed_about_page.py @@ -1,14 +1,10 @@ #!/usr/bin/env python3 -""" -Seed sample data for the About page -""" import sys import os from pathlib import Path import json -# Add the parent directory to the path so we can import from src sys.path.insert(0, str(Path(__file__).parent)) from sqlalchemy.orm import Session @@ -17,7 +13,6 @@ from src.models.page_content import PageContent, PageType from datetime import datetime def get_db(): - """Get database session""" db = SessionLocal() try: return db @@ -25,23 +20,16 @@ def get_db(): pass def seed_about_page(db: Session): - """Seed about page content""" print("=" * 80) print("SEEDING ABOUT PAGE CONTENT") print("=" * 80) - # Sample data about_data = { "title": "About Luxury Hotel", "subtitle": "Where Excellence Meets Unforgettable Experiences", "description": "Discover the story behind our commitment to luxury hospitality and exceptional service.", - "story_content": """Welcome to Luxury Hotel, where timeless elegance meets modern sophistication. Since our founding in 2010, we have been dedicated to providing exceptional hospitality and creating unforgettable memories for our guests. - -Nestled in the heart of the city, our hotel combines classic architecture with contemporary amenities, offering a perfect blend of comfort and luxury. Every detail has been carefully curated to ensure your stay exceeds expectations. - -Our commitment to excellence extends beyond our beautiful rooms and facilities. We believe in creating meaningful connections with our guests, understanding their needs, and delivering personalized service that makes each visit special. - -Over the years, we have hosted thousands of guests from around the world, each leaving with cherished memories and a desire to return. Our team of dedicated professionals works tirelessly to ensure that every moment of your stay is perfect.""", + "story_content": +, "mission": "To provide unparalleled luxury hospitality experiences that exceed expectations, creating lasting memories for our guests through exceptional service, attention to detail, and genuine care.", "vision": "To be recognized as the world's premier luxury hotel brand, setting the standard for excellence in hospitality while maintaining our commitment to sustainability and community engagement.", "about_hero_image": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1920&h=1080&fit=crop", @@ -181,17 +169,14 @@ Over the years, we have hosted thousands of guests from around the world, each l "meta_description": "Learn about Luxury Hotel's commitment to excellence, our story, values, and the dedicated team that makes every stay unforgettable." } - # Check if about page content exists existing = db.query(PageContent).filter(PageContent.page_type == PageType.ABOUT).first() if existing: - # Update existing for key, value in about_data.items(): setattr(existing, key, value) existing.updated_at = datetime.utcnow() print("✓ Updated existing about page content") else: - # Create new new_content = PageContent( page_type=PageType.ABOUT, **about_data diff --git a/Backend/seed_banners_company.py b/Backend/seed_banners_company.py index f839c652..af7d5486 100644 --- a/Backend/seed_banners_company.py +++ b/Backend/seed_banners_company.py @@ -1,14 +1,7 @@ -""" -Seed script to populate banners and company information with sample data. -Run this script to add default banners and company settings. -""" import sys import os from datetime import datetime, timedelta - -# Add the src directory to the path sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - from sqlalchemy.orm import Session from src.config.database import SessionLocal from src.models.banner import Banner @@ -16,199 +9,62 @@ from src.models.system_settings import SystemSettings from src.models.user import User def seed_banners(db: Session): - """Seed sample banners""" - print("Seeding banners...") - - # Get admin user for updated_by_id (if exists) - admin_user = db.query(User).filter(User.email == "admin@hotel.com").first() + print('Seeding banners...') + admin_user = db.query(User).filter(User.email == 'admin@hotel.com').first() admin_id = admin_user.id if admin_user else None - - # Delete all existing banners existing_banners = db.query(Banner).all() if existing_banners: for banner in existing_banners: db.delete(banner) db.commit() - print(f" ✓ Removed {len(existing_banners)} existing banner(s)") - - # New luxury banners with premium content - banners_data = [ - { - "title": "Welcome to Unparalleled Luxury", - "description": "Where timeless elegance meets modern sophistication. Experience the pinnacle of hospitality in our award-winning luxury hotel.", - "image_url": "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1920", - "link_url": "/rooms", - "position": "home", - "display_order": 1, - "is_active": True, - "start_date": datetime.utcnow() - timedelta(days=30), - "end_date": datetime.utcnow() + timedelta(days=365), - }, - { - "title": "Exclusive Presidential Suites", - "description": "Indulge in our most opulent accommodations. Spacious suites with panoramic views, private terraces, and personalized butler service.", - "image_url": "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1920", - "link_url": "/rooms", - "position": "home", - "display_order": 2, - "is_active": True, - "start_date": datetime.utcnow() - timedelta(days=7), - "end_date": datetime.utcnow() + timedelta(days=365), - }, - { - "title": "World-Class Spa & Wellness", - "description": "Rejuvenate your mind, body, and soul. Our award-winning spa offers bespoke treatments using the finest luxury products.", - "image_url": "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=1920", - "link_url": "/services", - "position": "home", - "display_order": 3, - "is_active": True, - "start_date": datetime.utcnow() - timedelta(days=1), - "end_date": datetime.utcnow() + timedelta(days=365), - }, - { - "title": "Michelin-Starred Culinary Excellence", - "description": "Savor extraordinary flavors crafted by world-renowned chefs. Our fine dining restaurants offer an unforgettable gastronomic journey.", - "image_url": "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1920", - "link_url": "/services", - "position": "home", - "display_order": 4, - "is_active": True, - "start_date": datetime.utcnow(), - "end_date": datetime.utcnow() + timedelta(days=365), - }, - { - "title": "Private Yacht & Exclusive Experiences", - "description": "Create unforgettable memories with our curated luxury experiences. From private yacht charters to exclusive cultural tours.", - "image_url": "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1920", - "link_url": "/services", - "position": "home", - "display_order": 5, - "is_active": True, - "start_date": datetime.utcnow() - timedelta(days=15), - "end_date": datetime.utcnow() + timedelta(days=365), - } - ] - + print(f' ✓ Removed {len(existing_banners)} existing banner(s)') + banners_data = [{'title': 'Welcome to Unparalleled Luxury', 'description': 'Where timeless elegance meets modern sophistication. Experience the pinnacle of hospitality in our award-winning luxury hotel.', 'image_url': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1920', 'link_url': '/rooms', 'position': 'home', 'display_order': 1, 'is_active': True, 'start_date': datetime.utcnow() - timedelta(days=30), 'end_date': datetime.utcnow() + timedelta(days=365)}, {'title': 'Exclusive Presidential Suites', 'description': 'Indulge in our most opulent accommodations. Spacious suites with panoramic views, private terraces, and personalized butler service.', 'image_url': 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1920', 'link_url': '/rooms', 'position': 'home', 'display_order': 2, 'is_active': True, 'start_date': datetime.utcnow() - timedelta(days=7), 'end_date': datetime.utcnow() + timedelta(days=365)}, {'title': 'World-Class Spa & Wellness', 'description': 'Rejuvenate your mind, body, and soul. Our award-winning spa offers bespoke treatments using the finest luxury products.', 'image_url': 'https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=1920', 'link_url': '/services', 'position': 'home', 'display_order': 3, 'is_active': True, 'start_date': datetime.utcnow() - timedelta(days=1), 'end_date': datetime.utcnow() + timedelta(days=365)}, {'title': 'Michelin-Starred Culinary Excellence', 'description': 'Savor extraordinary flavors crafted by world-renowned chefs. Our fine dining restaurants offer an unforgettable gastronomic journey.', 'image_url': 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=1920', 'link_url': '/services', 'position': 'home', 'display_order': 4, 'is_active': True, 'start_date': datetime.utcnow(), 'end_date': datetime.utcnow() + timedelta(days=365)}, {'title': 'Private Yacht & Exclusive Experiences', 'description': 'Create unforgettable memories with our curated luxury experiences. From private yacht charters to exclusive cultural tours.', 'image_url': 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=1920', 'link_url': '/services', 'position': 'home', 'display_order': 5, 'is_active': True, 'start_date': datetime.utcnow() - timedelta(days=15), 'end_date': datetime.utcnow() + timedelta(days=365)}] for banner_data in banners_data: - # Create new banner new_banner = Banner(**banner_data) db.add(new_banner) - print(f" ✓ Created banner: {banner_data['title']}") - + print(f' ✓ Created banner: {banner_data['title']}') db.commit() - print("✓ Banners seeded successfully!\n") + print('✓ Banners seeded successfully!\n') def seed_company_info(db: Session): - """Seed company information""" - print("Seeding company information...") - - # Get admin user for updated_by_id (if exists) - admin_user = db.query(User).filter(User.email == "admin@hotel.com").first() + print('Seeding company information...') + admin_user = db.query(User).filter(User.email == 'admin@hotel.com').first() admin_id = admin_user.id if admin_user else None - - # Company settings - company_settings = [ - { - "key": "company_name", - "value": "Luxury Hotel", - "description": "Company name displayed throughout the application" - }, - { - "key": "company_tagline", - "value": "Experience Unparalleled Elegance", - "description": "Company tagline or slogan" - }, - { - "key": "company_logo_url", - "value": "", - "description": "URL to company logo image (upload via admin dashboard)" - }, - { - "key": "company_favicon_url", - "value": "", - "description": "URL to company favicon image (upload via admin dashboard)" - }, - { - "key": "company_phone", - "value": "+1 (555) 123-4567", - "description": "Company contact phone number" - }, - { - "key": "company_email", - "value": "info@luxuryhotel.com", - "description": "Company contact email address" - }, - { - "key": "company_address", - "value": "123 Luxury Avenue, Premium District, City 12345, Country", - "description": "Company physical address" - }, - { - "key": "tax_rate", - "value": "10.0", - "description": "Default tax rate percentage (e.g., 10.0 for 10%)" - }, - { - "key": "platform_currency", - "value": "EUR", - "description": "Platform-wide currency setting for displaying prices" - } - ] - + company_settings = [{'key': 'company_name', 'value': 'Luxury Hotel', 'description': 'Company name displayed throughout the application'}, {'key': 'company_tagline', 'value': 'Experience Unparalleled Elegance', 'description': 'Company tagline or slogan'}, {'key': 'company_logo_url', 'value': '', 'description': 'URL to company logo image (upload via admin dashboard)'}, {'key': 'company_favicon_url', 'value': '', 'description': 'URL to company favicon image (upload via admin dashboard)'}, {'key': 'company_phone', 'value': '+1 (555) 123-4567', 'description': 'Company contact phone number'}, {'key': 'company_email', 'value': 'info@luxuryhotel.com', 'description': 'Company contact email address'}, {'key': 'company_address', 'value': '123 Luxury Avenue, Premium District, City 12345, Country', 'description': 'Company physical address'}, {'key': 'tax_rate', 'value': '10.0', 'description': 'Default tax rate percentage (e.g., 10.0 for 10%)'}, {'key': 'platform_currency', 'value': 'EUR', 'description': 'Platform-wide currency setting for displaying prices'}] for setting_data in company_settings: - # Check if setting exists - existing = db.query(SystemSettings).filter( - SystemSettings.key == setting_data["key"] - ).first() - + existing = db.query(SystemSettings).filter(SystemSettings.key == setting_data['key']).first() if existing: - # Update existing setting - existing.value = setting_data["value"] - existing.description = setting_data["description"] + existing.value = setting_data['value'] + existing.description = setting_data['description'] if admin_id: existing.updated_by_id = admin_id - print(f" ✓ Updated setting: {setting_data['key']}") + print(f' ✓ Updated setting: {setting_data['key']}') else: - # Create new setting - new_setting = SystemSettings( - key=setting_data["key"], - value=setting_data["value"], - description=setting_data["description"], - updated_by_id=admin_id - ) + new_setting = SystemSettings(key=setting_data['key'], value=setting_data['value'], description=setting_data['description'], updated_by_id=admin_id) db.add(new_setting) - print(f" ✓ Created setting: {setting_data['key']}") - + print(f' ✓ Created setting: {setting_data['key']}') db.commit() - print("✓ Company information seeded successfully!\n") + print('✓ Company information seeded successfully!\n') def main(): - """Main seed function""" db: Session = SessionLocal() - try: - print("=" * 80) - print("SEEDING BANNERS AND COMPANY INFORMATION") - print("=" * 80) + print('=' * 80) + print('SEEDING BANNERS AND COMPANY INFORMATION') + print('=' * 80) print() - seed_banners(db) seed_company_info(db) - - print("=" * 80) - print("✓ All data seeded successfully!") - print("=" * 80) - + print('=' * 80) + print('✓ All data seeded successfully!') + print('=' * 80) except Exception as e: db.rollback() - print(f"\n✗ Error seeding data: {e}") + print(f'\n✗ Error seeding data: {e}') import traceback traceback.print_exc() raise finally: db.close() - -if __name__ == "__main__": - main() - +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/Backend/seed_homepage_footer.py b/Backend/seed_homepage_footer.py index 3b29ad26..b74d1bf1 100644 --- a/Backend/seed_homepage_footer.py +++ b/Backend/seed_homepage_footer.py @@ -1,456 +1,75 @@ -""" -Comprehensive seed script to populate homepage and footer with sample luxury content. -Run this script to add default content to the page_content table. -""" import sys import os import json - -# Add the src directory to the path sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - from sqlalchemy.orm import Session from src.config.database import SessionLocal from src.models.page_content import PageContent, PageType def seed_homepage_content(db: Session): - """Seed comprehensive homepage content""" existing = db.query(PageContent).filter(PageContent.page_type == PageType.HOME).first() - - # Luxury Features - luxury_features = [ - { - "icon": "Sparkles", - "title": "Premium Amenities", - "description": "World-class facilities designed for your comfort and relaxation" - }, - { - "icon": "Crown", - "title": "Royal Service", - "description": "Dedicated concierge service available 24/7 for all your needs" - }, - { - "icon": "Award", - "title": "Award-Winning", - "description": "Recognized for excellence in hospitality and guest satisfaction" - }, - { - "icon": "Shield", - "title": "Secure & Private", - "description": "Your privacy and security are our top priorities" - }, - { - "icon": "Heart", - "title": "Personalized Care", - "description": "Tailored experiences crafted just for you" - }, - { - "icon": "Gem", - "title": "Luxury Design", - "description": "Elegantly designed spaces with attention to every detail" - } - ] - - # Luxury Gallery - luxury_gallery = [ - "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800", - "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800", - "https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800", - "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800", - "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800", - "https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800" - ] - - # Luxury Testimonials - luxury_testimonials = [ - { - "name": "Sarah Johnson", - "title": "Business Executive", - "quote": "An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.", - "image": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200" - }, - { - "name": "Michael Chen", - "title": "Travel Enthusiast", - "quote": "The epitome of luxury. Every moment was perfect, from check-in to check-out.", - "image": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200" - }, - { - "name": "Emma Williams", - "title": "Luxury Traveler", - "quote": "This hotel redefines what luxury means. I will definitely return.", - "image": "https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200" - } - ] - - # Luxury Services - luxury_services = [ - { - "icon": "UtensilsCrossed", - "title": "Fine Dining", - "description": "Michelin-starred restaurants offering world-class cuisine", - "image": "https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=600" - }, - { - "icon": "Wine", - "title": "Premium Bar", - "description": "Extensive wine collection and craft cocktails in elegant settings", - "image": "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=600" - }, - { - "icon": "Dumbbell", - "title": "Spa & Wellness", - "description": "Rejuvenating spa treatments and state-of-the-art fitness center", - "image": "https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=600" - }, - { - "icon": "Car", - "title": "Concierge Services", - "description": "Personalized assistance for all your travel and entertainment needs", - "image": "https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600" - } - ] - - # Luxury Experiences - luxury_experiences = [ - { - "icon": "Sunset", - "title": "Sunset Rooftop", - "description": "Breathtaking views and exclusive rooftop experiences", - "image": "https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=600" - }, - { - "icon": "Ship", - "title": "Yacht Excursions", - "description": "Private yacht charters for unforgettable sea adventures", - "image": "https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=600" - }, - { - "icon": "Music", - "title": "Live Entertainment", - "description": "World-class performances and exclusive events", - "image": "https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=600" - }, - { - "icon": "Palette", - "title": "Art & Culture", - "description": "Curated art collections and cultural experiences", - "image": "https://images.unsplash.com/photo-1578301978018-3005759f48f7?w=600" - } - ] - - # Awards - awards = [ - { - "icon": "Trophy", - "title": "Best Luxury Hotel 2024", - "description": "Awarded by International Luxury Travel Association", - "image": "https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=400", - "year": "2024" - }, - { - "icon": "Star", - "title": "5-Star Excellence", - "description": "Consistently rated 5 stars by leading travel publications", - "image": "https://images.unsplash.com/photo-1606761568499-6d2451b23c66?w=400", - "year": "2023" - }, - { - "icon": "Award", - "title": "Sustainable Luxury", - "description": "Recognized for environmental responsibility and sustainability", - "image": "https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=400", - "year": "2024" - } - ] - - # Partners - partners = [ - { - "name": "Luxury Travel Group", - "logo": "https://images.unsplash.com/photo-1599305445671-ac291c95aaa9?w=200", - "link": "#" - }, - { - "name": "Premium Airlines", - "logo": "https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=200", - "link": "#" - }, - { - "name": "Exclusive Events", - "logo": "https://images.unsplash.com/photo-1511578314322-379afb476865?w=200", - "link": "#" - }, - { - "name": "Fine Dining Network", - "logo": "https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=200", - "link": "#" - } - ] - - # Stats - stats = [ - { - "icon": "Users", - "number": "50,000+", - "label": "Happy Guests" - }, - { - "icon": "Award", - "number": "25+", - "label": "Awards Won" - }, - { - "icon": "Star", - "number": "4.9", - "label": "Average Rating" - }, - { - "icon": "Globe", - "number": "100+", - "label": "Countries Served" - } - ] - - # Amenities - amenities = [ - { - "icon": "Wifi", - "title": "High-Speed WiFi", - "description": "Complimentary high-speed internet throughout the property", - "image": "" - }, - { - "icon": "Coffee", - "title": "24/7 Room Service", - "description": "Round-the-clock dining and beverage service", - "image": "" - }, - { - "icon": "Car", - "title": "Valet Parking", - "description": "Complimentary valet parking for all guests", - "image": "" - }, - { - "icon": "Plane", - "title": "Airport Transfer", - "description": "Luxury airport transfer service available", - "image": "" - } - ] - - # Testimonials - testimonials = [ - { - "name": "Robert Martinez", - "role": "CEO, Tech Corp", - "image": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200", - "rating": 5, - "comment": "Exceptional service and attention to detail. The staff went above and beyond to make our stay memorable." - }, - { - "name": "Lisa Anderson", - "role": "Travel Blogger", - "image": "https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=200", - "rating": 5, - "comment": "The most luxurious hotel experience I've ever had. Every detail was perfect." - }, - { - "name": "David Thompson", - "role": "Investment Banker", - "image": "https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200", - "rating": 5, - "comment": "Outstanding facilities and impeccable service. Highly recommend for business travelers." - } - ] - - # Gallery Images - gallery_images = [ - "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800", - "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800", - "https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800", - "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800", - "https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800", - "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800" - ] - - homepage_data = { - "page_type": PageType.HOME, - "title": "Luxury Hotel - Experience Unparalleled Elegance", - "subtitle": "Where timeless luxury meets modern sophistication", - "description": "Discover a world of refined elegance and exceptional service", - "hero_title": "Welcome to Luxury", - "hero_subtitle": "Experience the pinnacle of hospitality", - "hero_image": "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200", - "luxury_section_title": "Experience Unparalleled Luxury", - "luxury_section_subtitle": "Where elegance meets comfort in every detail", - "luxury_section_image": "https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=1200", - "luxury_features": json.dumps(luxury_features), - "luxury_gallery_section_title": "Our Luxury Gallery", - "luxury_gallery_section_subtitle": "A glimpse into our world of elegance", - "luxury_gallery": json.dumps(luxury_gallery), - "luxury_testimonials_section_title": "What Our Guests Say", - "luxury_testimonials_section_subtitle": "Testimonials from our valued guests", - "luxury_testimonials": json.dumps(luxury_testimonials), - "luxury_services_section_title": "Premium Services", - "luxury_services_section_subtitle": "Indulge in our world-class amenities", - "luxury_services": json.dumps(luxury_services), - "luxury_experiences_section_title": "Exclusive Experiences", - "luxury_experiences_section_subtitle": "Create unforgettable memories", - "luxury_experiences": json.dumps(luxury_experiences), - "awards_section_title": "Awards & Recognition", - "awards_section_subtitle": "Recognized for excellence worldwide", - "awards": json.dumps(awards), - "partners_section_title": "Our Partners", - "partners_section_subtitle": "Trusted by leading brands", - "partners": json.dumps(partners), - "amenities_section_title": "Premium Amenities", - "amenities_section_subtitle": "Everything you need for a perfect stay", - "amenities": json.dumps(amenities), - "testimonials_section_title": "Guest Reviews", - "testimonials_section_subtitle": "Hear from our satisfied guests", - "testimonials": json.dumps(testimonials), - "gallery_section_title": "Photo Gallery", - "gallery_section_subtitle": "Explore our beautiful spaces", - "gallery_images": json.dumps(gallery_images), - "about_preview_title": "About Our Luxury Hotel", - "about_preview_subtitle": "A legacy of excellence", - "about_preview_content": "Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience. With over 50,000 satisfied guests and numerous awards, we continue to set the standard for luxury hospitality.", - "about_preview_image": "https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800", - "stats": json.dumps(stats), - "cta_title": "Ready to Experience Luxury?", - "cta_subtitle": "Book your stay today and discover the difference", - "cta_button_text": "Book Now", - "cta_button_link": "/rooms", - "cta_image": "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200", - "is_active": True - } - + luxury_features = [{'icon': 'Sparkles', 'title': 'Premium Amenities', 'description': 'World-class facilities designed for your comfort and relaxation'}, {'icon': 'Crown', 'title': 'Royal Service', 'description': 'Dedicated concierge service available 24/7 for all your needs'}, {'icon': 'Award', 'title': 'Award-Winning', 'description': 'Recognized for excellence in hospitality and guest satisfaction'}, {'icon': 'Shield', 'title': 'Secure & Private', 'description': 'Your privacy and security are our top priorities'}, {'icon': 'Heart', 'title': 'Personalized Care', 'description': 'Tailored experiences crafted just for you'}, {'icon': 'Gem', 'title': 'Luxury Design', 'description': 'Elegantly designed spaces with attention to every detail'}] + luxury_gallery = ['https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800', 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800', 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800', 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800', 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800', 'https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800'] + luxury_testimonials = [{'name': 'Sarah Johnson', 'title': 'Business Executive', 'quote': 'An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.', 'image': 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200'}, {'name': 'Michael Chen', 'title': 'Travel Enthusiast', 'quote': 'The epitome of luxury. Every moment was perfect, from check-in to check-out.', 'image': 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=200'}, {'name': 'Emma Williams', 'title': 'Luxury Traveler', 'quote': 'This hotel redefines what luxury means. I will definitely return.', 'image': 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=200'}] + luxury_services = [{'icon': 'UtensilsCrossed', 'title': 'Fine Dining', 'description': 'Michelin-starred restaurants offering world-class cuisine', 'image': 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=600'}, {'icon': 'Wine', 'title': 'Premium Bar', 'description': 'Extensive wine collection and craft cocktails in elegant settings', 'image': 'https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?w=600'}, {'icon': 'Dumbbell', 'title': 'Spa & Wellness', 'description': 'Rejuvenating spa treatments and state-of-the-art fitness center', 'image': 'https://images.unsplash.com/photo-1544161515-4ab6ce6db874?w=600'}, {'icon': 'Car', 'title': 'Concierge Services', 'description': 'Personalized assistance for all your travel and entertainment needs', 'image': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=600'}] + luxury_experiences = [{'icon': 'Sunset', 'title': 'Sunset Rooftop', 'description': 'Breathtaking views and exclusive rooftop experiences', 'image': 'https://images.unsplash.com/photo-1514933651103-005eec06c04b?w=600'}, {'icon': 'Ship', 'title': 'Yacht Excursions', 'description': 'Private yacht charters for unforgettable sea adventures', 'image': 'https://images.unsplash.com/photo-1544551763-46a013bb70d5?w=600'}, {'icon': 'Music', 'title': 'Live Entertainment', 'description': 'World-class performances and exclusive events', 'image': 'https://images.unsplash.com/photo-1470229722913-7c0e2dbbafd3?w=600'}, {'icon': 'Palette', 'title': 'Art & Culture', 'description': 'Curated art collections and cultural experiences', 'image': 'https://images.unsplash.com/photo-1578301978018-3005759f48f7?w=600'}] + awards = [{'icon': 'Trophy', 'title': 'Best Luxury Hotel 2024', 'description': 'Awarded by International Luxury Travel Association', 'image': 'https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?w=400', 'year': '2024'}, {'icon': 'Star', 'title': '5-Star Excellence', 'description': 'Consistently rated 5 stars by leading travel publications', 'image': 'https://images.unsplash.com/photo-1606761568499-6d2451b23c66?w=400', 'year': '2023'}, {'icon': 'Award', 'title': 'Sustainable Luxury', 'description': 'Recognized for environmental responsibility and sustainability', 'image': 'https://images.unsplash.com/photo-1473341304170-971dccb5ac1e?w=400', 'year': '2024'}] + partners = [{'name': 'Luxury Travel Group', 'logo': 'https://images.unsplash.com/photo-1599305445671-ac291c95aaa9?w=200', 'link': '#'}, {'name': 'Premium Airlines', 'logo': 'https://images.unsplash.com/photo-1436491865332-7a61a109cc05?w=200', 'link': '#'}, {'name': 'Exclusive Events', 'logo': 'https://images.unsplash.com/photo-1511578314322-379afb476865?w=200', 'link': '#'}, {'name': 'Fine Dining Network', 'logo': 'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?w=200', 'link': '#'}] + stats = [{'icon': 'Users', 'number': '50,000+', 'label': 'Happy Guests'}, {'icon': 'Award', 'number': '25+', 'label': 'Awards Won'}, {'icon': 'Star', 'number': '4.9', 'label': 'Average Rating'}, {'icon': 'Globe', 'number': '100+', 'label': 'Countries Served'}] + amenities = [{'icon': 'Wifi', 'title': 'High-Speed WiFi', 'description': 'Complimentary high-speed internet throughout the property', 'image': ''}, {'icon': 'Coffee', 'title': '24/7 Room Service', 'description': 'Round-the-clock dining and beverage service', 'image': ''}, {'icon': 'Car', 'title': 'Valet Parking', 'description': 'Complimentary valet parking for all guests', 'image': ''}, {'icon': 'Plane', 'title': 'Airport Transfer', 'description': 'Luxury airport transfer service available', 'image': ''}] + testimonials = [{'name': 'Robert Martinez', 'role': 'CEO, Tech Corp', 'image': 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?w=200', 'rating': 5, 'comment': 'Exceptional service and attention to detail. The staff went above and beyond to make our stay memorable.'}, {'name': 'Lisa Anderson', 'role': 'Travel Blogger', 'image': 'https://images.unsplash.com/photo-1487412720507-e7ab37603c6f?w=200', 'rating': 5, 'comment': "The most luxurious hotel experience I've ever had. Every detail was perfect."}, {'name': 'David Thompson', 'role': 'Investment Banker', 'image': 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=200', 'rating': 5, 'comment': 'Outstanding facilities and impeccable service. Highly recommend for business travelers.'}] + gallery_images = ['https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=800', 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=800', 'https://images.unsplash.com/photo-1551882547-ff40c63fe5fa?w=800', 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=800', 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800', 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=800'] + homepage_data = {'page_type': PageType.HOME, 'title': 'Luxury Hotel - Experience Unparalleled Elegance', 'subtitle': 'Where timeless luxury meets modern sophistication', 'description': 'Discover a world of refined elegance and exceptional service', 'hero_title': 'Welcome to Luxury', 'hero_subtitle': 'Experience the pinnacle of hospitality', 'hero_image': 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200', 'luxury_section_title': 'Experience Unparalleled Luxury', 'luxury_section_subtitle': 'Where elegance meets comfort in every detail', 'luxury_section_image': 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?w=1200', 'luxury_features': json.dumps(luxury_features), 'luxury_gallery_section_title': 'Our Luxury Gallery', 'luxury_gallery_section_subtitle': 'A glimpse into our world of elegance', 'luxury_gallery': json.dumps(luxury_gallery), 'luxury_testimonials_section_title': 'What Our Guests Say', 'luxury_testimonials_section_subtitle': 'Testimonials from our valued guests', 'luxury_testimonials': json.dumps(luxury_testimonials), 'luxury_services_section_title': 'Premium Services', 'luxury_services_section_subtitle': 'Indulge in our world-class amenities', 'luxury_services': json.dumps(luxury_services), 'luxury_experiences_section_title': 'Exclusive Experiences', 'luxury_experiences_section_subtitle': 'Create unforgettable memories', 'luxury_experiences': json.dumps(luxury_experiences), 'awards_section_title': 'Awards & Recognition', 'awards_section_subtitle': 'Recognized for excellence worldwide', 'awards': json.dumps(awards), 'partners_section_title': 'Our Partners', 'partners_section_subtitle': 'Trusted by leading brands', 'partners': json.dumps(partners), 'amenities_section_title': 'Premium Amenities', 'amenities_section_subtitle': 'Everything you need for a perfect stay', 'amenities': json.dumps(amenities), 'testimonials_section_title': 'Guest Reviews', 'testimonials_section_subtitle': 'Hear from our satisfied guests', 'testimonials': json.dumps(testimonials), 'gallery_section_title': 'Photo Gallery', 'gallery_section_subtitle': 'Explore our beautiful spaces', 'gallery_images': json.dumps(gallery_images), 'about_preview_title': 'About Our Luxury Hotel', 'about_preview_subtitle': 'A legacy of excellence', 'about_preview_content': 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience. With over 50,000 satisfied guests and numerous awards, we continue to set the standard for luxury hospitality.', 'about_preview_image': 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=800', 'stats': json.dumps(stats), 'cta_title': 'Ready to Experience Luxury?', 'cta_subtitle': 'Book your stay today and discover the difference', 'cta_button_text': 'Book Now', 'cta_button_link': '/rooms', 'cta_image': 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200', 'is_active': True} if existing: for key, value in homepage_data.items(): - if key != "page_type": + if key != 'page_type': setattr(existing, key, value) - print("✓ Updated existing homepage content") + print('✓ Updated existing homepage content') else: new_content = PageContent(**homepage_data) db.add(new_content) - print("✓ Created new homepage content") - + print('✓ Created new homepage content') db.commit() def seed_footer_content(db: Session): - """Seed comprehensive footer content""" existing = db.query(PageContent).filter(PageContent.page_type == PageType.FOOTER).first() - - # Contact Info - contact_info = { - "phone": "+1 (555) 123-4567", - "email": "info@luxuryhotel.com", - "address": "123 Luxury Avenue, Premium District, City 12345" - } - - # Social Links - social_links = { - "facebook": "https://facebook.com/luxuryhotel", - "twitter": "https://twitter.com/luxuryhotel", - "instagram": "https://instagram.com/luxuryhotel", - "linkedin": "https://linkedin.com/company/luxuryhotel", - "youtube": "https://youtube.com/luxuryhotel" - } - - # Footer Links - footer_links = { - "quick_links": [ - {"label": "Home", "url": "/"}, - {"label": "Rooms & Suites", "url": "/rooms"}, - {"label": "About Us", "url": "/about"}, - {"label": "Contact", "url": "/contact"}, - {"label": "Gallery", "url": "/gallery"} - ], - "support_links": [ - {"label": "FAQ", "url": "/faq"}, - {"label": "Privacy Policy", "url": "/privacy"}, - {"label": "Terms of Service", "url": "/terms"}, - {"label": "Cancellation Policy", "url": "/cancellation"}, - {"label": "Accessibility", "url": "/accessibility"} - ] - } - - # Badges - badges = [ - { - "text": "5-Star Rated", - "icon": "Star" - }, - { - "text": "Award Winning", - "icon": "Award" - }, - { - "text": "Eco Certified", - "icon": "Leaf" - }, - { - "text": "Luxury Collection", - "icon": "Crown" - } - ] - - footer_data = { - "page_type": PageType.FOOTER, - "title": "Luxury Hotel", - "subtitle": "Experience Unparalleled Elegance", - "description": "Your gateway to luxury hospitality and exceptional service", - "contact_info": json.dumps(contact_info), - "social_links": json.dumps(social_links), - "footer_links": json.dumps(footer_links), - "badges": json.dumps(badges), - "copyright_text": "© {YEAR} Luxury Hotel. All rights reserved.", - "is_active": True - } - + contact_info = {'phone': '+1 (555) 123-4567', 'email': 'info@luxuryhotel.com', 'address': '123 Luxury Avenue, Premium District, City 12345'} + social_links = {'facebook': 'https://facebook.com/luxuryhotel', 'twitter': 'https://twitter.com/luxuryhotel', 'instagram': 'https://instagram.com/luxuryhotel', 'linkedin': 'https://linkedin.com/company/luxuryhotel', 'youtube': 'https://youtube.com/luxuryhotel'} + footer_links = {'quick_links': [{'label': 'Home', 'url': '/'}, {'label': 'Rooms & Suites', 'url': '/rooms'}, {'label': 'About Us', 'url': '/about'}, {'label': 'Contact', 'url': '/contact'}, {'label': 'Gallery', 'url': '/gallery'}], 'support_links': [{'label': 'FAQ', 'url': '/faq'}, {'label': 'Privacy Policy', 'url': '/privacy'}, {'label': 'Terms of Service', 'url': '/terms'}, {'label': 'Cancellation Policy', 'url': '/cancellation'}, {'label': 'Accessibility', 'url': '/accessibility'}]} + badges = [{'text': '5-Star Rated', 'icon': 'Star'}, {'text': 'Award Winning', 'icon': 'Award'}, {'text': 'Eco Certified', 'icon': 'Leaf'}, {'text': 'Luxury Collection', 'icon': 'Crown'}] + footer_data = {'page_type': PageType.FOOTER, 'title': 'Luxury Hotel', 'subtitle': 'Experience Unparalleled Elegance', 'description': 'Your gateway to luxury hospitality and exceptional service', 'contact_info': json.dumps(contact_info), 'social_links': json.dumps(social_links), 'footer_links': json.dumps(footer_links), 'badges': json.dumps(badges), 'copyright_text': '© {YEAR} Luxury Hotel. All rights reserved.', 'is_active': True} if existing: for key, value in footer_data.items(): - if key != "page_type": + if key != 'page_type': setattr(existing, key, value) - print("✓ Updated existing footer content") + print('✓ Updated existing footer content') else: new_content = PageContent(**footer_data) db.add(new_content) - print("✓ Created new footer content") - + print('✓ Created new footer content') db.commit() def main(): - """Main seed function""" db: Session = SessionLocal() - try: - print("=" * 80) - print("SEEDING HOMEPAGE AND FOOTER CONTENT") - print("=" * 80) + print('=' * 80) + print('SEEDING HOMEPAGE AND FOOTER CONTENT') + print('=' * 80) print() - - print("Seeding homepage content...") + print('Seeding homepage content...') seed_homepage_content(db) - - print("\nSeeding footer content...") + print('\nSeeding footer content...') seed_footer_content(db) - - print("\n" + "=" * 80) - print("✓ All content seeded successfully!") - print("=" * 80) - + print('\n' + '=' * 80) + print('✓ All content seeded successfully!') + print('=' * 80) except Exception as e: db.rollback() - print(f"\n✗ Error seeding content: {e}") + print(f'\n✗ Error seeding content: {e}') import traceback traceback.print_exc() raise finally: db.close() - -if __name__ == "__main__": - main() - +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/Backend/seed_luxury_content.py b/Backend/seed_luxury_content.py index e84a0ffc..699698aa 100644 --- a/Backend/seed_luxury_content.py +++ b/Backend/seed_luxury_content.py @@ -1,123 +1,42 @@ -""" -Seed script to populate initial luxury content for the homepage. -Run this script to add default luxury content to the page_content table. -""" import sys import os import json - -# Add the src directory to the path sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) - from sqlalchemy.orm import Session from src.config.database import SessionLocal, engine from src.models.page_content import PageContent from src.models.user import User def seed_luxury_content(): - """Seed luxury content for the homepage""" db: Session = SessionLocal() - try: - # Check if home page content already exists existing = db.query(PageContent).filter(PageContent.page_type == 'home').first() - - luxury_features = [ - { - "icon": "Sparkles", - "title": "Premium Amenities", - "description": "World-class facilities designed for your comfort and relaxation" - }, - { - "icon": "Crown", - "title": "Royal Service", - "description": "Dedicated concierge service available 24/7 for all your needs" - }, - { - "icon": "Award", - "title": "Award-Winning", - "description": "Recognized for excellence in hospitality and guest satisfaction" - }, - { - "icon": "Shield", - "title": "Secure & Private", - "description": "Your privacy and security are our top priorities" - }, - { - "icon": "Heart", - "title": "Personalized Care", - "description": "Tailored experiences crafted just for you" - }, - { - "icon": "Gem", - "title": "Luxury Design", - "description": "Elegantly designed spaces with attention to every detail" - } - ] - - luxury_testimonials = [ - { - "name": "Sarah Johnson", - "title": "Business Executive", - "quote": "An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.", - "image": "" - }, - { - "name": "Michael Chen", - "title": "Travel Enthusiast", - "quote": "The epitome of luxury. Every moment was perfect, from check-in to check-out.", - "image": "" - }, - { - "name": "Emma Williams", - "title": "Luxury Traveler", - "quote": "This hotel redefines what luxury means. I will definitely return.", - "image": "" - } - ] - + luxury_features = [{'icon': 'Sparkles', 'title': 'Premium Amenities', 'description': 'World-class facilities designed for your comfort and relaxation'}, {'icon': 'Crown', 'title': 'Royal Service', 'description': 'Dedicated concierge service available 24/7 for all your needs'}, {'icon': 'Award', 'title': 'Award-Winning', 'description': 'Recognized for excellence in hospitality and guest satisfaction'}, {'icon': 'Shield', 'title': 'Secure & Private', 'description': 'Your privacy and security are our top priorities'}, {'icon': 'Heart', 'title': 'Personalized Care', 'description': 'Tailored experiences crafted just for you'}, {'icon': 'Gem', 'title': 'Luxury Design', 'description': 'Elegantly designed spaces with attention to every detail'}] + luxury_testimonials = [{'name': 'Sarah Johnson', 'title': 'Business Executive', 'quote': 'An absolutely stunning experience. The attention to detail and level of service exceeded all expectations.', 'image': ''}, {'name': 'Michael Chen', 'title': 'Travel Enthusiast', 'quote': 'The epitome of luxury. Every moment was perfect, from check-in to check-out.', 'image': ''}, {'name': 'Emma Williams', 'title': 'Luxury Traveler', 'quote': 'This hotel redefines what luxury means. I will definitely return.', 'image': ''}] if existing: - # Update existing content - existing.luxury_section_title = "Experience Unparalleled Luxury" - existing.luxury_section_subtitle = "Where elegance meets comfort in every detail" + existing.luxury_section_title = 'Experience Unparalleled Luxury' + existing.luxury_section_subtitle = 'Where elegance meets comfort in every detail' existing.luxury_section_image = None existing.luxury_features = json.dumps(luxury_features) existing.luxury_gallery = json.dumps([]) existing.luxury_testimonials = json.dumps(luxury_testimonials) - existing.about_preview_title = "About Our Luxury Hotel" - existing.about_preview_content = "Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience." + existing.about_preview_title = 'About Our Luxury Hotel' + existing.about_preview_content = 'Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.' existing.about_preview_image = None - print("✓ Updated existing home page content with luxury sections") + print('✓ Updated existing home page content with luxury sections') else: - # Create new content - new_content = PageContent( - page_type='home', - luxury_section_title="Experience Unparalleled Luxury", - luxury_section_subtitle="Where elegance meets comfort in every detail", - luxury_section_image=None, - luxury_features=json.dumps(luxury_features), - luxury_gallery=json.dumps([]), - luxury_testimonials=json.dumps(luxury_testimonials), - about_preview_title="About Our Luxury Hotel", - about_preview_content="Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.", - about_preview_image=None, - is_active=True - ) + new_content = PageContent(page_type='home', luxury_section_title='Experience Unparalleled Luxury', luxury_section_subtitle='Where elegance meets comfort in every detail', luxury_section_image=None, luxury_features=json.dumps(luxury_features), luxury_gallery=json.dumps([]), luxury_testimonials=json.dumps(luxury_testimonials), about_preview_title='About Our Luxury Hotel', about_preview_content='Discover a world of refined elegance and exceptional service. Our hotel combines timeless luxury with modern amenities to create an unforgettable experience.', about_preview_image=None, is_active=True) db.add(new_content) - print("✓ Created new home page content with luxury sections") - + print('✓ Created new home page content with luxury sections') db.commit() - print("✓ Luxury content seeded successfully!") - + print('✓ Luxury content seeded successfully!') except Exception as e: db.rollback() - print(f"✗ Error seeding luxury content: {e}") + print(f'✗ Error seeding luxury content: {e}') raise finally: db.close() - -if __name__ == "__main__": - print("Seeding luxury content...") +if __name__ == '__main__': + print('Seeding luxury content...') seed_luxury_content() - print("Done!") - + print('Done!') \ No newline at end of file diff --git a/Backend/seed_rooms.py b/Backend/seed_rooms.py index 277dafae..f2e1bf42 100644 --- a/Backend/seed_rooms.py +++ b/Backend/seed_rooms.py @@ -1,15 +1,7 @@ -#!/usr/bin/env python3 -""" -Seed script to delete all existing rooms and create 50 sample luxury hotel rooms -""" - import sys import os from pathlib import Path - -# Add the parent directory to the path so we can import from src sys.path.insert(0, str(Path(__file__).parent)) - from sqlalchemy.orm import Session from src.config.database import SessionLocal, engine from src.models.room import Room, RoomStatus @@ -19,7 +11,6 @@ import json import random def get_db(): - """Get database session""" db = SessionLocal() try: return db @@ -27,169 +18,60 @@ def get_db(): pass def seed_rooms(db: Session): - """Delete all existing rooms and create 50 sample luxury rooms""" - print("=" * 80) - print("SEEDING ROOMS - DELETING EXISTING AND CREATING 50 NEW LUXURY ROOMS") - print("=" * 80) - - # Get all room types + print('=' * 80) + print('SEEDING ROOMS - DELETING EXISTING AND CREATING 50 NEW LUXURY ROOMS') + print('=' * 80) room_types = db.query(RoomType).all() if not room_types: - print("❌ No room types found! Please create room types first.") + print('❌ No room types found! Please create room types first.') return - - print(f"\n✓ Found {len(room_types)} room type(s)") + print(f'\n✓ Found {len(room_types)} room type(s)') for rt in room_types: - print(f" - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})") - - # Delete all existing rooms - # First, we need to handle related records that reference these rooms + print(f' - {rt.name} (ID: {rt.id}, Base Price: {rt.base_price})') from src.models.booking import Booking from src.models.review import Review from src.models.favorite import Favorite - existing_rooms = db.query(Room).all() if existing_rooms: - print(f"\n🗑️ Deleting {len(existing_rooms)} existing room(s)...") - - # Get all room IDs + print(f'\n🗑️ Deleting {len(existing_rooms)} existing room(s)...') room_ids = [room.id for room in existing_rooms] - - # Delete bookings that reference these rooms bookings_with_rooms = db.query(Booking).filter(Booking.room_id.in_(room_ids)).all() if bookings_with_rooms: - print(f" ⚠️ Found {len(bookings_with_rooms)} booking(s) referencing these rooms") + print(f' ⚠️ Found {len(bookings_with_rooms)} booking(s) referencing these rooms') for booking in bookings_with_rooms: db.delete(booking) - print(f" ✓ Deleted {len(bookings_with_rooms)} booking(s)") - - # Delete reviews that reference these rooms + print(f' ✓ Deleted {len(bookings_with_rooms)} booking(s)') reviews_with_rooms = db.query(Review).filter(Review.room_id.in_(room_ids)).all() if reviews_with_rooms: - print(f" ⚠️ Found {len(reviews_with_rooms)} review(s) referencing these rooms") + print(f' ⚠️ Found {len(reviews_with_rooms)} review(s) referencing these rooms') for review in reviews_with_rooms: db.delete(review) - print(f" ✓ Deleted {len(reviews_with_rooms)} review(s)") - - # Delete favorites that reference these rooms + print(f' ✓ Deleted {len(reviews_with_rooms)} review(s)') favorites_with_rooms = db.query(Favorite).filter(Favorite.room_id.in_(room_ids)).all() if favorites_with_rooms: - print(f" ⚠️ Found {len(favorites_with_rooms)} favorite(s) referencing these rooms") + print(f' ⚠️ Found {len(favorites_with_rooms)} favorite(s) referencing these rooms') for favorite in favorites_with_rooms: db.delete(favorite) - print(f" ✓ Deleted {len(favorites_with_rooms)} favorite(s)") - - # Now delete the rooms + print(f' ✓ Deleted {len(favorites_with_rooms)} favorite(s)') for room in existing_rooms: db.delete(room) db.commit() - print(f"✓ Deleted {len(existing_rooms)} room(s)") - - # Luxury room configurations - views = [ - "Ocean View", "City View", "Garden View", "Mountain View", - "Pool View", "Beach View", "Panoramic View", "Sea View" - ] - - room_sizes = [ - "35 sqm", "40 sqm", "45 sqm", "50 sqm", "55 sqm", - "60 sqm", "70 sqm", "80 sqm", "90 sqm", "100 sqm", - "120 sqm", "150 sqm", "180 sqm", "200 sqm", "250 sqm" - ] - - # Real luxury hotel room images from Unsplash (defined once, used for all rooms) - luxury_room_images = [ - # Luxury hotel rooms - "https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1618221195710-dd6b41faaea8?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200&h=800&fit=crop", - # Suite images - "https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1582719508461-905c673771fd?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1595576508898-0ad5c879a061?w=1200&h=800&fit=crop", - # Additional luxury rooms - "https://images.unsplash.com/photo-1571003123894-1f0594d2b5d9?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1618221195710-dd6b41faaea8?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1582719508461-905c673771fd?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1595576508898-0ad5c879a061?w=1200&h=800&fit=crop", - "https://images.unsplash.com/photo-1571003123894-1f0594d2b5d9?w=1200&h=800&fit=crop", - ] - - # Comprehensive luxury amenities - all_amenities = [ - "Free WiFi", "High-Speed Internet", "Smart TV", "Netflix", - "Air Conditioning", "Climate Control", "Private Balcony", - "Ocean View", "City View", "Minibar", "Coffee Maker", - "Espresso Machine", "Refrigerator", "Safe", "Iron & Ironing Board", - "Hair Dryer", "Premium Toiletries", "Bathrobes", "Slippers", - "Work Desk", "Ergonomic Chair", "USB Charging Ports", - "Bluetooth Speaker", "Sound System", "Blackout Curtains", - "Pillow Menu", "Turndown Service", "Room Service", "24/7 Concierge" - ] - - premium_amenities = [ - "Jacuzzi Bathtub", "Steam Shower", "Rain Shower", "Bidet", - "Private Pool", "Outdoor Terrace", "Fireplace", "Wine Cellar", - "Private Bar", "Butler Service", "Private Elevator", "Helipad Access" - ] - - # Room configurations: (floor_range, room_numbers_per_floor, view, size_range, amenities_count, featured) - room_configs = [ - # Standard Rooms (Floors 1-3) - ((1, 3), 8, "Garden View", (35, 45), 8, False), - ((1, 3), 8, "City View", (40, 50), 9, False), - - # Superior Rooms (Floors 4-6) - ((4, 6), 6, "City View", (45, 55), 10, False), - ((4, 6), 6, "Pool View", (50, 60), 11, True), - - # Deluxe Rooms (Floors 7-9) - ((7, 9), 5, "Ocean View", (55, 70), 12, True), - ((7, 9), 5, "Mountain View", (60, 75), 12, False), - - # Executive Suites (Floors 10-12) - ((10, 12), 4, "Panoramic View", (80, 100), 14, True), - ((10, 12), 4, "Sea View", (90, 110), 15, True), - - # Luxury Suites (Floors 13-15) - ((13, 15), 3, "Ocean View", (120, 150), 16, True), - ((13, 15), 3, "Beach View", (130, 160), 17, True), - - # Presidential Suites (Floor 16) - ((16, 16), 2, "Panoramic View", (200, 250), 20, True), - ] - + print(f'✓ Deleted {len(existing_rooms)} room(s)') + views = ['Ocean View', 'City View', 'Garden View', 'Mountain View', 'Pool View', 'Beach View', 'Panoramic View', 'Sea View'] + room_sizes = ['35 sqm', '40 sqm', '45 sqm', '50 sqm', '55 sqm', '60 sqm', '70 sqm', '80 sqm', '90 sqm', '100 sqm', '120 sqm', '150 sqm', '180 sqm', '200 sqm', '250 sqm'] + luxury_room_images = ['https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea8?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1582719508461-905c673771fd?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1595576508898-0ad5c879a061?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1571003123894-1f0594d2b5d9?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1566073771259-6a8506099945?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1578683010236-d716f9a3f461?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1596394516093-501ba68a0ba6?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1631049307264-da0ec9d70304?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1611892440504-42a792e24d32?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1590490360182-c33d57733427?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1564501049412-61c2a3083791?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1582719478250-c89cae4dc85b?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1618221195710-dd6b41faaea8?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1566665797739-1674de7a421a?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1618773928121-c32242e63f39?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1582719508461-905c673771fd?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1595576508898-0ad5c879a061?w=1200&h=800&fit=crop', 'https://images.unsplash.com/photo-1571003123894-1f0594d2b5d9?w=1200&h=800&fit=crop'] + all_amenities = ['Free WiFi', 'High-Speed Internet', 'Smart TV', 'Netflix', 'Air Conditioning', 'Climate Control', 'Private Balcony', 'Ocean View', 'City View', 'Minibar', 'Coffee Maker', 'Espresso Machine', 'Refrigerator', 'Safe', 'Iron & Ironing Board', 'Hair Dryer', 'Premium Toiletries', 'Bathrobes', 'Slippers', 'Work Desk', 'Ergonomic Chair', 'USB Charging Ports', 'Bluetooth Speaker', 'Sound System', 'Blackout Curtains', 'Pillow Menu', 'Turndown Service', 'Room Service', '24/7 Concierge'] + premium_amenities = ['Jacuzzi Bathtub', 'Steam Shower', 'Rain Shower', 'Bidet', 'Private Pool', 'Outdoor Terrace', 'Fireplace', 'Wine Cellar', 'Private Bar', 'Butler Service', 'Private Elevator', 'Helipad Access'] + room_configs = [((1, 3), 8, 'Garden View', (35, 45), 8, False), ((1, 3), 8, 'City View', (40, 50), 9, False), ((4, 6), 6, 'City View', (45, 55), 10, False), ((4, 6), 6, 'Pool View', (50, 60), 11, True), ((7, 9), 5, 'Ocean View', (55, 70), 12, True), ((7, 9), 5, 'Mountain View', (60, 75), 12, False), ((10, 12), 4, 'Panoramic View', (80, 100), 14, True), ((10, 12), 4, 'Sea View', (90, 110), 15, True), ((13, 15), 3, 'Ocean View', (120, 150), 16, True), ((13, 15), 3, 'Beach View', (130, 160), 17, True), ((16, 16), 2, 'Panoramic View', (200, 250), 20, True)] rooms_created = [] - room_counter = 101 # Starting room number - - print(f"\n🏨 Creating 50 luxury rooms...\n") - + room_counter = 101 + print(f'\n🏨 Creating 50 luxury rooms...\n') for config in room_configs: floor_range, rooms_per_floor, view_type, size_range, amenities_count, featured = config - for floor in range(floor_range[0], floor_range[1] + 1): for _ in range(rooms_per_floor): if len(rooms_created) >= 50: break - - # Select random room type based on floor if floor <= 3: room_type = random.choice([rt for rt in room_types if 'standard' in rt.name.lower() or 'superior' in rt.name.lower()] or room_types) elif floor <= 6: @@ -200,118 +82,58 @@ def seed_rooms(db: Session): room_type = random.choice([rt for rt in room_types if 'executive' in rt.name.lower() or 'suite' in rt.name.lower()] or room_types) else: room_type = random.choice([rt for rt in room_types if 'suite' in rt.name.lower() or 'presidential' in rt.name.lower()] or room_types) - - # If no matching room type, use random if not room_type: room_type = random.choice(room_types) - - # Calculate price (base price + floor premium + view premium + random variation) base_price = float(room_type.base_price) - floor_premium = (floor - 1) * 5 # +5 per floor - view_premium = 20 if "Ocean" in view_type or "Sea" in view_type or "Beach" in view_type else 0 - view_premium += 15 if "Panoramic" in view_type else 0 - view_premium += 10 if "Mountain" in view_type else 0 - view_premium += 5 if "Pool" in view_type else 0 - # Add random variation (-5% to +10% of base price) - random_variation = base_price * random.uniform(-0.05, 0.10) - # Size premium (larger rooms cost more) + floor_premium = (floor - 1) * 5 + view_premium = 20 if 'Ocean' in view_type or 'Sea' in view_type or 'Beach' in view_type else 0 + view_premium += 15 if 'Panoramic' in view_type else 0 + view_premium += 10 if 'Mountain' in view_type else 0 + view_premium += 5 if 'Pool' in view_type else 0 + random_variation = base_price * random.uniform(-0.05, 0.1) size_min, size_max = size_range - size_premium = (size_min + size_max) / 2 * 0.5 # ~0.5 per sqm + size_premium = (size_min + size_max) / 2 * 0.5 price = base_price + floor_premium + view_premium + random_variation + size_premium - # Ensure minimum price and round to 2 decimal places price = max(base_price * 0.95, price) price = round(price, 2) - - # Select amenities selected_amenities = random.sample(all_amenities, min(amenities_count, len(all_amenities))) - if floor >= 13: # Add premium amenities for luxury suites + if floor >= 13: premium_count = min(2, len(premium_amenities)) selected_amenities.extend(random.sample(premium_amenities, premium_count)) - - # Room size size_min, size_max = size_range - room_size = f"{random.randint(size_min, size_max)} sqm" - - # Capacity (based on room type, with some variation) + room_size = f'{random.randint(size_min, size_max)} sqm' capacity = room_type.capacity - if random.random() > 0.7: # 30% chance to have different capacity + if random.random() > 0.7: capacity = max(1, capacity + random.randint(-1, 1)) - - # Room number - room_number = f"{floor}{room_counter % 100:02d}" + room_number = f'{floor}{room_counter % 100:02d}' room_counter += 1 - - # Select 3 unique images for each room (ensure we always have images) - # Shuffle the list each time to get different combinations shuffled_images = luxury_room_images.copy() random.shuffle(shuffled_images) - image_urls = shuffled_images[:3] # Always take first 3 after shuffle - - # Description - descriptions = [ - f"Elegantly designed {view_type.lower()} room with modern luxury amenities and breathtaking views.", - f"Spacious {view_type.lower()} accommodation featuring premium furnishings and world-class comfort.", - f"Luxurious {view_type.lower()} room with sophisticated decor and exceptional attention to detail.", - f"Exquisite {view_type.lower()} suite offering unparalleled elegance and personalized service.", - f"Opulent {view_type.lower()} accommodation with bespoke interiors and premium amenities.", - ] + image_urls = shuffled_images[:3] + descriptions = [f'Elegantly designed {view_type.lower()} room with modern luxury amenities and breathtaking views.', f'Spacious {view_type.lower()} accommodation featuring premium furnishings and world-class comfort.', f'Luxurious {view_type.lower()} room with sophisticated decor and exceptional attention to detail.', f'Exquisite {view_type.lower()} suite offering unparalleled elegance and personalized service.', f'Opulent {view_type.lower()} accommodation with bespoke interiors and premium amenities.'] description = random.choice(descriptions) - - # Status (mostly available, some in maintenance/cleaning) - status_weights = [0.85, 0.05, 0.05, 0.05] # available, occupied, maintenance, cleaning - status = random.choices( - [RoomStatus.available, RoomStatus.occupied, RoomStatus.maintenance, RoomStatus.cleaning], - weights=status_weights - )[0] - - # Create room - room = Room( - room_type_id=room_type.id, - room_number=room_number, - floor=floor, - status=status, - price=price, - featured=featured, - capacity=capacity, - room_size=room_size, - view=view_type, - images=json.dumps(image_urls), - amenities=json.dumps(selected_amenities), - description=description - ) - + status_weights = [0.85, 0.05, 0.05, 0.05] + status = random.choices([RoomStatus.available, RoomStatus.occupied, RoomStatus.maintenance, RoomStatus.cleaning], weights=status_weights)[0] + room = Room(room_type_id=room_type.id, room_number=room_number, floor=floor, status=status, price=price, featured=featured, capacity=capacity, room_size=room_size, view=view_type, images=json.dumps(image_urls), amenities=json.dumps(selected_amenities), description=description) db.add(room) - rooms_created.append({ - 'number': room_number, - 'floor': floor, - 'type': room_type.name, - 'view': view_type, - 'price': price - }) - - print(f" ✓ Created Room {room_number} - Floor {floor}, {room_type.name}, {view_type}, {room_size}, €{price:.2f}") - + rooms_created.append({'number': room_number, 'floor': floor, 'type': room_type.name, 'view': view_type, 'price': price}) + print(f' ✓ Created Room {room_number} - Floor {floor}, {room_type.name}, {view_type}, {room_size}, €{price:.2f}') db.commit() - print(f"\n✅ Successfully created {len(rooms_created)} luxury rooms!") - print(f"\n📊 Summary:") - featured_count = sum(1 for r in rooms_created if any( - config[5] and r['floor'] >= config[0][0] and r['floor'] <= config[0][1] - for config in room_configs - )) - print(f" - Featured rooms: {featured_count}") - print(f" - Floors: {min(r['floor'] for r in rooms_created)} - {max(r['floor'] for r in rooms_created)}") - print(f" - Price range: €{min(r['price'] for r in rooms_created):.2f} - €{max(r['price'] for r in rooms_created):.2f}") - print("=" * 80) - -if __name__ == "__main__": + print(f'\n✅ Successfully created {len(rooms_created)} luxury rooms!') + print(f'\n📊 Summary:') + featured_count = sum((1 for r in rooms_created if any((config[5] and r['floor'] >= config[0][0] and (r['floor'] <= config[0][1]) for config in room_configs)))) + print(f' - Featured rooms: {featured_count}') + print(f' - Floors: {min((r['floor'] for r in rooms_created))} - {max((r['floor'] for r in rooms_created))}') + print(f' - Price range: €{min((r['price'] for r in rooms_created)):.2f} - €{max((r['price'] for r in rooms_created)):.2f}') + print('=' * 80) +if __name__ == '__main__': db = get_db() try: seed_rooms(db) except Exception as e: - print(f"\n❌ Error: {e}") + print(f'\n❌ Error: {e}') import traceback traceback.print_exc() db.rollback() finally: - db.close() - + db.close() \ No newline at end of file diff --git a/Backend/src/__init__.py b/Backend/src/__init__.py index acb07128..e69de29b 100644 --- a/Backend/src/__init__.py +++ b/Backend/src/__init__.py @@ -1,2 +0,0 @@ -# Hotel Booking Server Package - diff --git a/Backend/src/__pycache__/main.cpython-312.pyc b/Backend/src/__pycache__/main.cpython-312.pyc index adc77dfc..abd96a2b 100644 Binary files a/Backend/src/__pycache__/main.cpython-312.pyc and b/Backend/src/__pycache__/main.cpython-312.pyc differ diff --git a/Backend/src/config/database.py b/Backend/src/config/database.py index c0d64367..3ca3db2e 100644 --- a/Backend/src/config/database.py +++ b/Backend/src/config/database.py @@ -4,54 +4,25 @@ from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import QueuePool from .settings import settings from .logging_config import get_logger - logger = get_logger(__name__) - -# Database configuration using settings DATABASE_URL = settings.database_url +engine = create_engine(DATABASE_URL, poolclass=QueuePool, pool_pre_ping=True, pool_recycle=3600, pool_size=10, max_overflow=20, echo=settings.is_development, future=True, connect_args={'charset': 'utf8mb4', 'connect_timeout': 10}) -# Enhanced engine configuration for enterprise use -engine = create_engine( - DATABASE_URL, - poolclass=QueuePool, - pool_pre_ping=True, # Verify connections before using - pool_recycle=3600, # Recycle connections after 1 hour - pool_size=10, # Number of connections to maintain - max_overflow=20, # Additional connections beyond pool_size - echo=settings.is_development, # Log SQL queries in development - future=True, # Use SQLAlchemy 2.0 style - connect_args={ - "charset": "utf8mb4", - "connect_timeout": 10 - } -) - -# Event listeners for connection pool monitoring -@event.listens_for(engine, "connect") +@event.listens_for(engine, 'connect') def set_sqlite_pragma(dbapi_conn, connection_record): - """Set connection-level settings""" - logger.debug("New database connection established") + logger.debug('New database connection established') -@event.listens_for(engine, "checkout") +@event.listens_for(engine, 'checkout') def receive_checkout(dbapi_conn, connection_record, connection_proxy): - """Log connection checkout""" - logger.debug("Connection checked out from pool") + logger.debug('Connection checked out from pool') -@event.listens_for(engine, "checkin") +@event.listens_for(engine, 'checkin') def receive_checkin(dbapi_conn, connection_record): - """Log connection checkin""" - logger.debug("Connection returned to pool") - + logger.debug('Connection returned to pool') SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base = declarative_base() -# Dependency to get DB session def get_db(): - """ - Dependency for getting database session. - Automatically handles session lifecycle. - """ db = SessionLocal() try: yield db @@ -59,5 +30,4 @@ def get_db(): db.rollback() raise finally: - db.close() - + db.close() \ No newline at end of file diff --git a/Backend/src/config/logging_config.py b/Backend/src/config/logging_config.py index 89f585ba..bacd96d2 100644 --- a/Backend/src/config/logging_config.py +++ b/Backend/src/config/logging_config.py @@ -1,6 +1,3 @@ -""" -Enterprise-grade structured logging configuration -""" import logging import sys from logging.handlers import RotatingFileHandler @@ -8,89 +5,32 @@ from pathlib import Path from typing import Optional from .settings import settings - -def setup_logging( - log_level: Optional[str] = None, - log_file: Optional[str] = None, - enable_file_logging: bool = True -) -> logging.Logger: - """ - Setup structured logging with file and console handlers - - Args: - log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL) - log_file: Path to log file - enable_file_logging: Whether to enable file logging - - Returns: - Configured root logger - """ - # Get configuration from settings +def setup_logging(log_level: Optional[str]=None, log_file: Optional[str]=None, enable_file_logging: bool=True) -> logging.Logger: level = log_level or settings.LOG_LEVEL log_file_path = log_file or settings.LOG_FILE - - # Convert string level to logging constant numeric_level = getattr(logging, level.upper(), logging.INFO) - - # Create logs directory if it doesn't exist if enable_file_logging and log_file_path: log_path = Path(log_file_path) log_path.parent.mkdir(parents=True, exist_ok=True) - - # Create formatter with structured format - detailed_formatter = logging.Formatter( - fmt='%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' - ) - - simple_formatter = logging.Formatter( - fmt='%(asctime)s | %(levelname)-8s | %(message)s', - datefmt='%H:%M:%S' - ) - - # Configure root logger + detailed_formatter = logging.Formatter(fmt='%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s', datefmt='%Y-%m-%d %H:%M:%S') + simple_formatter = logging.Formatter(fmt='%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%H:%M:%S') root_logger = logging.getLogger() root_logger.setLevel(numeric_level) - - # Remove existing handlers root_logger.handlers.clear() - - # Console handler (always enabled) console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(numeric_level) console_handler.setFormatter(simple_formatter if settings.is_development else detailed_formatter) root_logger.addHandler(console_handler) - - # File handler (rotating) - Disabled in development to avoid file watcher issues - if enable_file_logging and log_file_path and not settings.is_development: - file_handler = RotatingFileHandler( - log_file_path, - maxBytes=settings.LOG_MAX_BYTES, - backupCount=settings.LOG_BACKUP_COUNT, - encoding='utf-8' - ) + if enable_file_logging and log_file_path and (not settings.is_development): + file_handler = RotatingFileHandler(log_file_path, maxBytes=settings.LOG_MAX_BYTES, backupCount=settings.LOG_BACKUP_COUNT, encoding='utf-8') file_handler.setLevel(numeric_level) file_handler.setFormatter(detailed_formatter) root_logger.addHandler(file_handler) - - # Set levels for third-party loggers - logging.getLogger("uvicorn").setLevel(logging.INFO) - logging.getLogger("uvicorn.access").setLevel(logging.INFO if settings.is_development else logging.WARNING) - logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) - logging.getLogger("slowapi").setLevel(logging.WARNING) - + logging.getLogger('uvicorn').setLevel(logging.INFO) + logging.getLogger('uvicorn.access').setLevel(logging.INFO if settings.is_development else logging.WARNING) + logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING) + logging.getLogger('slowapi').setLevel(logging.WARNING) return root_logger - def get_logger(name: str) -> logging.Logger: - """ - Get a logger instance with the given name - - Args: - name: Logger name (typically __name__) - - Returns: - Logger instance - """ - return logging.getLogger(name) - + return logging.getLogger(name) \ No newline at end of file diff --git a/Backend/src/config/settings.py b/Backend/src/config/settings.py index 733779c8..a5e00ed4 100644 --- a/Backend/src/config/settings.py +++ b/Backend/src/config/settings.py @@ -1,129 +1,72 @@ -""" -Enterprise-grade configuration management using Pydantic Settings -""" from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic import Field from typing import List import os - class Settings(BaseSettings): - """Application settings with environment variable support""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore" - ) - - # Application - APP_NAME: str = Field(default="Hotel Booking API", description="Application name") - APP_VERSION: str = Field(default="1.0.0", description="Application version") - ENVIRONMENT: str = Field(default="development", description="Environment: development, staging, production") - DEBUG: bool = Field(default=False, description="Debug mode") - API_V1_PREFIX: str = Field(default="/api/v1", description="API v1 prefix") - - # Server - HOST: str = Field(default="0.0.0.0", description="Server host") - PORT: int = Field(default=8000, description="Server port") - - # Database - DB_USER: str = Field(default="root", description="Database user") - DB_PASS: str = Field(default="", description="Database password") - DB_NAME: str = Field(default="hotel_db", description="Database name") - DB_HOST: str = Field(default="localhost", description="Database host") - DB_PORT: str = Field(default="3306", description="Database port") - - # Security - JWT_SECRET: str = Field(default="dev-secret-key-change-in-production-12345", description="JWT secret key") - JWT_ALGORITHM: str = Field(default="HS256", description="JWT algorithm") - JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description="JWT access token expiration in minutes") - JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description="JWT refresh token expiration in days") - - # CORS - CLIENT_URL: str = Field(default="http://localhost:5173", description="Frontend client URL") - CORS_ORIGINS: List[str] = Field( - default_factory=lambda: [ - "http://localhost:5173", - "http://localhost:3000", - "http://127.0.0.1:5173" - ], - description="Allowed CORS origins" - ) - - # Rate Limiting - RATE_LIMIT_ENABLED: bool = Field(default=True, description="Enable rate limiting") - RATE_LIMIT_PER_MINUTE: int = Field(default=60, description="Requests per minute per IP") - - # Logging - LOG_LEVEL: str = Field(default="INFO", description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL") - LOG_FILE: str = Field(default="logs/app.log", description="Log file path") - LOG_MAX_BYTES: int = Field(default=10485760, description="Max log file size (10MB)") - LOG_BACKUP_COUNT: int = Field(default=5, description="Number of backup log files") - - # Email - SMTP_HOST: str = Field(default="smtp.gmail.com", description="SMTP host") - SMTP_PORT: int = Field(default=587, description="SMTP port") - SMTP_USER: str = Field(default="", description="SMTP username") - SMTP_PASSWORD: str = Field(default="", description="SMTP password") - SMTP_FROM_EMAIL: str = Field(default="", description="From email address") - SMTP_FROM_NAME: str = Field(default="Hotel Booking", description="From name") - - # File Upload - UPLOAD_DIR: str = Field(default="uploads", description="Upload directory") - MAX_UPLOAD_SIZE: int = Field(default=5242880, description="Max upload size in bytes (5MB)") - ALLOWED_EXTENSIONS: List[str] = Field( - default_factory=lambda: ["jpg", "jpeg", "png", "gif", "webp"], - description="Allowed file extensions" - ) - - # Redis (for caching) - REDIS_ENABLED: bool = Field(default=False, description="Enable Redis caching") - REDIS_HOST: str = Field(default="localhost", description="Redis host") - REDIS_PORT: int = Field(default=6379, description="Redis port") - REDIS_DB: int = Field(default=0, description="Redis database number") - REDIS_PASSWORD: str = Field(default="", description="Redis password") - - # Request Timeout - REQUEST_TIMEOUT: int = Field(default=30, description="Request timeout in seconds") - - # Health Check - HEALTH_CHECK_INTERVAL: int = Field(default=30, description="Health check interval in seconds") - - # Stripe Payment Gateway - STRIPE_SECRET_KEY: str = Field(default="", description="Stripe secret key") - STRIPE_PUBLISHABLE_KEY: str = Field(default="", description="Stripe publishable key") - STRIPE_WEBHOOK_SECRET: str = Field(default="", description="Stripe webhook secret") - - # PayPal Payment Gateway - PAYPAL_CLIENT_ID: str = Field(default="", description="PayPal client ID") - PAYPAL_CLIENT_SECRET: str = Field(default="", description="PayPal client secret") - PAYPAL_MODE: str = Field(default="sandbox", description="PayPal mode: sandbox or live") - + model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', case_sensitive=False, extra='ignore') + APP_NAME: str = Field(default='Hotel Booking API', description='Application name') + APP_VERSION: str = Field(default='1.0.0', description='Application version') + ENVIRONMENT: str = Field(default='development', description='Environment: development, staging, production') + DEBUG: bool = Field(default=False, description='Debug mode') + API_V1_PREFIX: str = Field(default='/api/v1', description='API v1 prefix') + HOST: str = Field(default='0.0.0.0', description='Server host') + PORT: int = Field(default=8000, description='Server port') + DB_USER: str = Field(default='root', description='Database user') + DB_PASS: str = Field(default='', description='Database password') + DB_NAME: str = Field(default='hotel_db', description='Database name') + DB_HOST: str = Field(default='localhost', description='Database host') + DB_PORT: str = Field(default='3306', description='Database port') + JWT_SECRET: str = Field(default='dev-secret-key-change-in-production-12345', description='JWT secret key') + JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm') + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes') + JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description='JWT refresh token expiration in days') + CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL') + CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins') + RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting') + RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP') + LOG_LEVEL: str = Field(default='INFO', description='Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL') + LOG_FILE: str = Field(default='logs/app.log', description='Log file path') + LOG_MAX_BYTES: int = Field(default=10485760, description='Max log file size (10MB)') + LOG_BACKUP_COUNT: int = Field(default=5, description='Number of backup log files') + SMTP_HOST: str = Field(default='smtp.gmail.com', description='SMTP host') + SMTP_PORT: int = Field(default=587, description='SMTP port') + SMTP_USER: str = Field(default='', description='SMTP username') + SMTP_PASSWORD: str = Field(default='', description='SMTP password') + SMTP_FROM_EMAIL: str = Field(default='', description='From email address') + SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name') + UPLOAD_DIR: str = Field(default='uploads', description='Upload directory') + MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)') + ALLOWED_EXTENSIONS: List[str] = Field(default_factory=lambda: ['jpg', 'jpeg', 'png', 'gif', 'webp'], description='Allowed file extensions') + REDIS_ENABLED: bool = Field(default=False, description='Enable Redis caching') + REDIS_HOST: str = Field(default='localhost', description='Redis host') + REDIS_PORT: int = Field(default=6379, description='Redis port') + REDIS_DB: int = Field(default=0, description='Redis database number') + REDIS_PASSWORD: str = Field(default='', description='Redis password') + REQUEST_TIMEOUT: int = Field(default=30, description='Request timeout in seconds') + HEALTH_CHECK_INTERVAL: int = Field(default=30, description='Health check interval in seconds') + STRIPE_SECRET_KEY: str = Field(default='', description='Stripe secret key') + STRIPE_PUBLISHABLE_KEY: str = Field(default='', description='Stripe publishable key') + STRIPE_WEBHOOK_SECRET: str = Field(default='', description='Stripe webhook secret') + PAYPAL_CLIENT_ID: str = Field(default='', description='PayPal client ID') + PAYPAL_CLIENT_SECRET: str = Field(default='', description='PayPal client secret') + PAYPAL_MODE: str = Field(default='sandbox', description='PayPal mode: sandbox or live') + @property def database_url(self) -> str: - """Construct database URL""" - return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" - + return f'mysql+pymysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}' + @property def is_production(self) -> bool: - """Check if running in production""" - return self.ENVIRONMENT.lower() == "production" - + return self.ENVIRONMENT.lower() == 'production' + @property def is_development(self) -> bool: - """Check if running in development""" - return self.ENVIRONMENT.lower() == "development" - + return self.ENVIRONMENT.lower() == 'development' + @property def redis_url(self) -> str: - """Construct Redis URL""" if self.REDIS_PASSWORD: - return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" - return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" - - -# Global settings instance -settings = Settings() - + return f'redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' + return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' +settings = Settings() \ No newline at end of file diff --git a/Backend/src/main.py b/Backend/src/main.py index c4ecefaa..23ee2837 100644 --- a/Backend/src/main.py +++ b/Backend/src/main.py @@ -11,222 +11,111 @@ from slowapi.errors import RateLimitExceeded from pathlib import Path from datetime import datetime import sys - -# Import configuration and logging FIRST from .config.settings import settings from .config.logging_config import setup_logging, get_logger from .config.database import engine, Base, get_db -from . import models # noqa: F401 - ensure models are imported so tables are created +from . import models from sqlalchemy.orm import Session - -# Setup logging before anything else logger = setup_logging() - -logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode") - -# Import middleware -from .middleware.error_handler import ( - validation_exception_handler, - integrity_error_handler, - jwt_error_handler, - http_exception_handler, - general_exception_handler -) +logger.info(f'Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode') +from .middleware.error_handler import validation_exception_handler, integrity_error_handler, jwt_error_handler, http_exception_handler, general_exception_handler from .middleware.request_id import RequestIDMiddleware from .middleware.security import SecurityHeadersMiddleware from .middleware.timeout import TimeoutMiddleware from .middleware.cookie_consent import CookieConsentMiddleware - -# Create database tables (for development, migrations should be used in production) if settings.is_development: - logger.info("Creating database tables (development mode)") + logger.info('Creating database tables (development mode)') Base.metadata.create_all(bind=engine) else: - # Ensure new tables exist even if full migrations haven't been run yet. try: from .models.cookie_policy import CookiePolicy from .models.cookie_integration_config import CookieIntegrationConfig from .models.page_content import PageContent - logger.info("Ensuring required tables exist") + logger.info('Ensuring required tables exist') CookiePolicy.__table__.create(bind=engine, checkfirst=True) CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True) PageContent.__table__.create(bind=engine, checkfirst=True) except Exception as e: - logger.error(f"Failed to ensure required tables exist: {e}") - + logger.error(f'Failed to ensure required tables exist: {e}') from .routes import auth_routes from .routes import privacy_routes - -# Initialize FastAPI app -app = FastAPI( - title=settings.APP_NAME, - description="Enterprise-grade Hotel Booking API", - version=settings.APP_VERSION, - docs_url="/api/docs" if not settings.is_production else None, - redoc_url="/api/redoc" if not settings.is_production else None, - openapi_url="/api/openapi.json" if not settings.is_production else None -) - -# Add middleware in order (order matters!) -# 1. Request ID middleware (first to add request ID) +app = FastAPI(title=settings.APP_NAME, description='Enterprise-grade Hotel Booking API', version=settings.APP_VERSION, docs_url='/api/docs' if not settings.is_production else None, redoc_url='/api/redoc' if not settings.is_production else None, openapi_url='/api/openapi.json' if not settings.is_production else None) app.add_middleware(RequestIDMiddleware) - -# 2. Cookie consent middleware (makes consent available on request.state) app.add_middleware(CookieConsentMiddleware) - -# 3. Timeout middleware if settings.REQUEST_TIMEOUT > 0: app.add_middleware(TimeoutMiddleware) - -# 4. Security headers middleware app.add_middleware(SecurityHeadersMiddleware) - -# Rate limiting if settings.RATE_LIMIT_ENABLED: - limiter = Limiter( - key_func=get_remote_address, - default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"] - ) + limiter = Limiter(key_func=get_remote_address, default_limits=[f'{settings.RATE_LIMIT_PER_MINUTE}/minute']) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - logger.info(f"Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute") - -# CORS configuration + logger.info(f'Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute') if settings.is_development: - # For development, use regex to allow any localhost port - app.add_middleware( - CORSMiddleware, - allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?", - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], - ) - logger.info("CORS configured for development (allowing localhost)") + app.add_middleware(CORSMiddleware, allow_origin_regex='http://(localhost|127\\.0\\.0\\.1)(:\\d+)?', allow_credentials=True, allow_methods=['*'], allow_headers=['*']) + logger.info('CORS configured for development (allowing localhost)') else: - # Production: use specific origins - app.add_middleware( - CORSMiddleware, - allow_origins=settings.CORS_ORIGINS, - allow_credentials=True, - allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], - allow_headers=["*"], - ) - logger.info(f"CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins") - -# Serve static files (uploads) + app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*']) + logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins') uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR uploads_dir.mkdir(exist_ok=True) -app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads") - -# Exception handlers +app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads') app.add_exception_handler(HTTPException, http_exception_handler) app.add_exception_handler(RequestValidationError, validation_exception_handler) app.add_exception_handler(IntegrityError, integrity_error_handler) app.add_exception_handler(JWTError, jwt_error_handler) app.add_exception_handler(Exception, general_exception_handler) -# Enhanced Health check with database connectivity -@app.get("/health", tags=["health"]) -@app.get("/api/health", tags=["health"]) -async def health_check(db: Session = Depends(get_db)): - """ - Enhanced health check endpoint with database connectivity test - Available at both /health and /api/health for consistency - """ - health_status = { - "status": "healthy", - "timestamp": datetime.utcnow().isoformat(), - "service": settings.APP_NAME, - "version": settings.APP_VERSION, - "environment": settings.ENVIRONMENT, - "checks": { - "api": "ok", - "database": "unknown" - } - } - - # Check database connectivity +@app.get('/health', tags=['health']) +@app.get('/api/health', tags=['health']) +async def health_check(db: Session=Depends(get_db)): + health_status = {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'checks': {'api': 'ok', 'database': 'unknown'}} try: from sqlalchemy import text - db.execute(text("SELECT 1")) - health_status["checks"]["database"] = "ok" + db.execute(text('SELECT 1')) + health_status['checks']['database'] = 'ok' except OperationalError as e: - health_status["status"] = "unhealthy" - health_status["checks"]["database"] = "error" - health_status["error"] = str(e) - logger.error(f"Database health check failed: {str(e)}") - return JSONResponse( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - content=health_status - ) + health_status['status'] = 'unhealthy' + health_status['checks']['database'] = 'error' + health_status['error'] = str(e) + logger.error(f'Database health check failed: {str(e)}') + return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status) except Exception as e: - health_status["status"] = "unhealthy" - health_status["checks"]["database"] = "error" - health_status["error"] = str(e) - logger.error(f"Health check failed: {str(e)}") - return JSONResponse( - status_code=status.HTTP_503_SERVICE_UNAVAILABLE, - content=health_status - ) - + health_status['status'] = 'unhealthy' + health_status['checks']['database'] = 'error' + health_status['error'] = str(e) + logger.error(f'Health check failed: {str(e)}') + return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status) return health_status - -# Metrics endpoint (basic) -@app.get("/metrics", tags=["monitoring"]) +@app.get('/metrics', tags=['monitoring']) async def metrics(): - """ - Basic metrics endpoint (can be extended with Prometheus or similar) - """ - return { - "status": "success", - "service": settings.APP_NAME, - "version": settings.APP_VERSION, - "environment": settings.ENVIRONMENT, - "timestamp": datetime.utcnow().isoformat() - } - -# API Routes with versioning -# Legacy routes (maintain backward compatibility) -app.include_router(auth_routes.router, prefix="/api") -app.include_router(privacy_routes.router, prefix="/api") - -# Versioned API routes (v1) + 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(privacy_routes.router, prefix='/api') app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX) - -# Import and include other 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 -) - -# Legacy routes (maintain backward compatibility) -app.include_router(room_routes.router, prefix="/api") -app.include_router(booking_routes.router, prefix="/api") -app.include_router(payment_routes.router, prefix="/api") -app.include_router(invoice_routes.router, prefix="/api") -app.include_router(banner_routes.router, prefix="/api") -app.include_router(favorite_routes.router, prefix="/api") -app.include_router(service_routes.router, prefix="/api") -app.include_router(service_booking_routes.router, prefix="/api") -app.include_router(promotion_routes.router, prefix="/api") -app.include_router(report_routes.router, prefix="/api") -app.include_router(review_routes.router, prefix="/api") -app.include_router(user_routes.router, prefix="/api") -app.include_router(audit_routes.router, prefix="/api") -app.include_router(admin_privacy_routes.router, prefix="/api") -app.include_router(system_settings_routes.router, prefix="/api") -app.include_router(contact_routes.router, prefix="/api") -app.include_router(home_routes.router, prefix="/api") -app.include_router(about_routes.router, prefix="/api") -app.include_router(contact_content_routes.router, prefix="/api") -app.include_router(footer_routes.router, prefix="/api") - -# Versioned routes (v1) +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 +app.include_router(room_routes.router, prefix='/api') +app.include_router(booking_routes.router, prefix='/api') +app.include_router(payment_routes.router, prefix='/api') +app.include_router(invoice_routes.router, prefix='/api') +app.include_router(banner_routes.router, prefix='/api') +app.include_router(favorite_routes.router, prefix='/api') +app.include_router(service_routes.router, prefix='/api') +app.include_router(service_booking_routes.router, prefix='/api') +app.include_router(promotion_routes.router, prefix='/api') +app.include_router(report_routes.router, prefix='/api') +app.include_router(review_routes.router, prefix='/api') +app.include_router(user_routes.router, prefix='/api') +app.include_router(audit_routes.router, prefix='/api') +app.include_router(admin_privacy_routes.router, prefix='/api') +app.include_router(system_settings_routes.router, prefix='/api') +app.include_router(contact_routes.router, prefix='/api') +app.include_router(home_routes.router, prefix='/api') +app.include_router(about_routes.router, prefix='/api') +app.include_router(contact_content_routes.router, prefix='/api') +app.include_router(footer_routes.router, prefix='/api') +app.include_router(chat_routes.router, prefix='/api') app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX) @@ -247,52 +136,24 @@ app.include_router(home_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(about_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(contact_content_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(footer_routes.router, prefix=settings.API_V1_PREFIX) -app.include_router(page_content_routes.router, prefix="/api") +app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX) +app.include_router(page_content_routes.router, prefix='/api') app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX) +logger.info('All routes registered successfully') -logger.info("All routes registered successfully") - -# Startup event -@app.on_event("startup") +@app.on_event('startup') async def startup_event(): - """Run on application startup""" - logger.info(f"{settings.APP_NAME} started successfully") - logger.info(f"Environment: {settings.ENVIRONMENT}") - logger.info(f"Debug mode: {settings.DEBUG}") - logger.info(f"API version: {settings.API_V1_PREFIX}") + logger.info(f'{settings.APP_NAME} started successfully') + logger.info(f'Environment: {settings.ENVIRONMENT}') + logger.info(f'Debug mode: {settings.DEBUG}') + logger.info(f'API version: {settings.API_V1_PREFIX}') -# Shutdown event -@app.on_event("shutdown") +@app.on_event('shutdown') async def shutdown_event(): - """Run on application shutdown""" - logger.info(f"{settings.APP_NAME} shutting down gracefully") - -if __name__ == "__main__": + logger.info(f'{settings.APP_NAME} shutting down gracefully') +if __name__ == '__main__': import uvicorn from pathlib import Path - - # Only watch the src directory to avoid watching logs, uploads, etc. base_dir = Path(__file__).parent.parent - src_dir = str(base_dir / "src") - - uvicorn.run( - "src.main:app", - host=settings.HOST, - port=settings.PORT, - reload=settings.is_development, - log_level=settings.LOG_LEVEL.lower(), - reload_dirs=[src_dir] if settings.is_development else None, - reload_excludes=[ - "*.log", - "*.pyc", - "*.pyo", - "*.pyd", - "__pycache__", - "**/__pycache__/**", - "*.db", - "*.sqlite", - "*.sqlite3" - ], - reload_delay=0.5 # Increase delay to reduce false positives - ) - + src_dir = str(base_dir / 'src') + uvicorn.run('src.main:app', host=settings.HOST, port=settings.PORT, reload=settings.is_development, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if settings.is_development else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=0.5) \ No newline at end of file diff --git a/Backend/src/middleware/__pycache__/auth.cpython-312.pyc b/Backend/src/middleware/__pycache__/auth.cpython-312.pyc index 8e66e788..2ec0e5f2 100644 Binary files a/Backend/src/middleware/__pycache__/auth.cpython-312.pyc and b/Backend/src/middleware/__pycache__/auth.cpython-312.pyc differ diff --git a/Backend/src/middleware/auth.py b/Backend/src/middleware/auth.py index 6eecf1bf..fe895692 100644 --- a/Backend/src/middleware/auth.py +++ b/Backend/src/middleware/auth.py @@ -4,71 +4,56 @@ from jose import JWTError, jwt from sqlalchemy.orm import Session from typing import Optional import os - from ..config.database import get_db from ..config.settings import settings from ..models.user import User from ..models.role import Role - security = HTTPBearer() - -def get_current_user( - credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db) -) -> User: - """ - Verify JWT token and return current user - """ +def get_current_user(credentials: HTTPAuthorizationCredentials=Depends(security), db: Session=Depends(get_db)) -> User: token = credentials.credentials - credentials_exception = HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Could not validate credentials", - headers={"WWW-Authenticate": "Bearer"}, - ) - + credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Could not validate credentials', headers={'WWW-Authenticate': 'Bearer'}) try: - jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345") - payload = jwt.decode(token, jwt_secret, algorithms=["HS256"]) - user_id: int = payload.get("userId") + jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) + user_id: int = payload.get('userId') if user_id is None: raise credentials_exception except JWTError: raise credentials_exception - user = db.query(User).filter(User.id == user_id).first() if user is None: raise credentials_exception - return user - def authorize_roles(*allowed_roles: str): - """ - Check if user has required role - """ - def role_checker( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) - ) -> User: - # Query the role from database instead of using hardcoded IDs + + def role_checker(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)) -> User: role = db.query(Role).filter(Role.id == current_user.role_id).first() - if not role: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="User role not found" - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found') user_role_name = role.name - if user_role_name not in allowed_roles: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="You do not have permission to access this resource" - ) - + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='You do not have permission to access this resource') return current_user - return role_checker +def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials]=Depends(HTTPBearer(auto_error=False)), db: Session=Depends(get_db)) -> Optional[User]: + if not credentials: + return None + token = credentials.credentials + try: + jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) + user_id: int = payload.get('userId') + if user_id is None: + return None + except JWTError: + return None + user = db.query(User).filter(User.id == user_id).first() + return user + +def verify_token(token: str) -> dict: + jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') + payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) + return payload \ No newline at end of file diff --git a/Backend/src/middleware/cookie_consent.py b/Backend/src/middleware/cookie_consent.py index a93f6b6e..6f9a072e 100644 --- a/Backend/src/middleware/cookie_consent.py +++ b/Backend/src/middleware/cookie_consent.py @@ -1,89 +1,52 @@ import json from typing import Callable, Awaitable - from fastapi import Request, Response from starlette.middleware.base import BaseHTTPMiddleware - from ..schemas.privacy import CookieConsent, CookieCategoryPreferences from ..config.settings import settings from ..config.logging_config import get_logger - - logger = get_logger(__name__) - - -COOKIE_CONSENT_COOKIE_NAME = "cookieConsent" - +COOKIE_CONSENT_COOKIE_NAME = 'cookieConsent' def _parse_consent_cookie(raw_value: str | None) -> CookieConsent: if not raw_value: - return CookieConsent() # Defaults: only necessary = True - + return CookieConsent() try: data = json.loads(raw_value) - # Pydantic will validate and coerce as needed return CookieConsent(**data) - except Exception as exc: # pragma: no cover - defensive - logger.warning(f"Failed to parse cookie consent cookie: {exc}") + except Exception as exc: + logger.warning(f'Failed to parse cookie consent cookie: {exc}') return CookieConsent() - class CookieConsentMiddleware(BaseHTTPMiddleware): - """ - Middleware that parses the cookie consent cookie (if present) and attaches it - to `request.state.cookie_consent` for downstream handlers. - """ - async def dispatch( - self, request: Request, call_next: Callable[[Request], Awaitable[Response]] - ) -> Response: + async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response: raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME) consent = _parse_consent_cookie(raw_cookie) - - # Ensure 'necessary' is always true regardless of stored value consent.categories.necessary = True - request.state.cookie_consent = consent - response = await call_next(request) - - # If there's no cookie yet, set a minimal default consent cookie - # so that the banner can be rendered based on server-side knowledge. if COOKIE_CONSENT_COOKIE_NAME not in request.cookies: try: - response.set_cookie( - key=COOKIE_CONSENT_COOKIE_NAME, - value=consent.model_dump_json(), - httponly=True, - secure=settings.is_production, - samesite="lax", - max_age=365 * 24 * 60 * 60, # 1 year - path="/", - ) - except Exception as exc: # pragma: no cover - defensive - logger.warning(f"Failed to set default cookie consent cookie: {exc}") - + response.set_cookie(key=COOKIE_CONSENT_COOKIE_NAME, value=consent.model_dump_json(), httponly=True, secure=settings.is_production, samesite='lax', max_age=365 * 24 * 60 * 60, path='/') + except Exception as exc: + logger.warning(f'Failed to set default cookie consent cookie: {exc}') return response - def is_analytics_allowed(request: Request) -> bool: - consent: CookieConsent | None = getattr(request.state, "cookie_consent", None) + consent: CookieConsent | None = getattr(request.state, 'cookie_consent', None) if not consent: return False return consent.categories.analytics - def is_marketing_allowed(request: Request) -> bool: - consent: CookieConsent | None = getattr(request.state, "cookie_consent", None) + consent: CookieConsent | None = getattr(request.state, 'cookie_consent', None) if not consent: return False return consent.categories.marketing - def is_preferences_allowed(request: Request) -> bool: - consent: CookieConsent | None = getattr(request.state, "cookie_consent", None) + consent: CookieConsent | None = getattr(request.state, 'cookie_consent', None) if not consent: return False - return consent.categories.preferences - - + return consent.categories.preferences \ No newline at end of file diff --git a/Backend/src/middleware/error_handler.py b/Backend/src/middleware/error_handler.py index 41fa3be9..c05e3ec8 100644 --- a/Backend/src/middleware/error_handler.py +++ b/Backend/src/middleware/error_handler.py @@ -5,140 +5,47 @@ from sqlalchemy.exc import IntegrityError from jose.exceptions import JWTError import traceback - async def validation_exception_handler(request: Request, exc: RequestValidationError): - """ - Handle validation errors - """ errors = [] for error in exc.errors(): - field = ".".join(str(loc) for loc in error["loc"] if loc != "body") - errors.append({ - "field": field, - "message": error["msg"] - }) - - # Get the first error message for the main message - first_error = errors[0]["message"] if errors else "Validation error" - - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "status": "error", - "message": first_error, - "errors": errors - } - ) - + field = '.'.join((str(loc) for loc in error['loc'] if loc != 'body')) + errors.append({'field': field, 'message': error['msg']}) + first_error = errors[0]['message'] if errors else 'Validation error' + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': first_error, 'errors': errors}) async def integrity_error_handler(request: Request, exc: IntegrityError): - """ - Handle database integrity errors (unique constraints, etc.) - """ error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc) - - # Check for duplicate entry - if "Duplicate entry" in error_msg or "UNIQUE constraint" in error_msg: - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "status": "error", - "message": "Duplicate entry", - "errors": [{"message": "This record already exists"}] - } - ) - - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "status": "error", - "message": "Database integrity error" - } - ) - + if 'Duplicate entry' in error_msg or 'UNIQUE constraint' in error_msg: + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Duplicate entry', 'errors': [{'message': 'This record already exists'}]}) + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Database integrity error'}) async def jwt_error_handler(request: Request, exc: JWTError): - """ - Handle JWT errors - """ - return JSONResponse( - status_code=status.HTTP_401_UNAUTHORIZED, - content={ - "status": "error", - "message": "Invalid token" - } - ) - + return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={'status': 'error', 'message': 'Invalid token'}) async def http_exception_handler(request: Request, exc: HTTPException): - """ - Handle HTTPException errors - """ - # If detail is already a dict with status/message, return it directly if isinstance(exc.detail, dict): - return JSONResponse( - status_code=exc.status_code, - content=exc.detail - ) - - # Otherwise format as standard error response - return JSONResponse( - status_code=exc.status_code, - content={ - "status": "error", - "message": str(exc.detail) if exc.detail else "An error occurred" - } - ) - + return JSONResponse(status_code=exc.status_code, content=exc.detail) + return JSONResponse(status_code=exc.status_code, content={'status': 'error', 'message': str(exc.detail) if exc.detail else 'An error occurred'}) async def general_exception_handler(request: Request, exc: Exception): - """ - Handle all other exceptions - """ from ..config.logging_config import get_logger from ..config.settings import settings - logger = get_logger(__name__) - request_id = getattr(request.state, "request_id", None) - - # Log error with context - logger.error( - f"Unhandled exception: {type(exc).__name__}: {str(exc)}", - extra={ - "request_id": request_id, - "path": request.url.path, - "method": request.method, - "exception_type": type(exc).__name__ - }, - exc_info=True - ) - - # Handle HTTPException with dict detail - if isinstance(exc, Exception) and hasattr(exc, "status_code"): + request_id = getattr(request.state, 'request_id', None) + logger.error(f'Unhandled exception: {type(exc).__name__}: {str(exc)}', extra={'request_id': request_id, 'path': request.url.path, 'method': request.method, 'exception_type': type(exc).__name__}, exc_info=True) + if isinstance(exc, Exception) and hasattr(exc, 'status_code'): status_code = exc.status_code - if hasattr(exc, "detail"): + if hasattr(exc, 'detail'): detail = exc.detail if isinstance(detail, dict): - # If detail is already a dict with status/message, return it directly return JSONResponse(status_code=status_code, content=detail) - message = str(detail) if detail else "An error occurred" + message = str(detail) if detail else 'An error occurred' else: - message = str(exc) if str(exc) else "Internal server error" + message = str(exc) if str(exc) else 'Internal server error' else: status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - message = str(exc) if str(exc) else "Internal server error" - - response_content = { - "status": "error", - "message": message - } - - # Add stack trace in development + message = str(exc) if str(exc) else 'Internal server error' + response_content = {'status': 'error', 'message': message} if settings.is_development: - response_content["stack"] = traceback.format_exc() - - return JSONResponse( - status_code=status_code, - content=response_content - ) - + response_content['stack'] = traceback.format_exc() + return JSONResponse(status_code=status_code, content=response_content) \ No newline at end of file diff --git a/Backend/src/middleware/request_id.py b/Backend/src/middleware/request_id.py index 6fe0732c..57937d48 100644 --- a/Backend/src/middleware/request_id.py +++ b/Backend/src/middleware/request_id.py @@ -1,65 +1,21 @@ -""" -Request ID middleware for tracking requests across services -""" import uuid from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response from ..config.logging_config import get_logger - logger = get_logger(__name__) - class RequestIDMiddleware(BaseHTTPMiddleware): - """Add unique request ID to each request for tracing""" - + async def dispatch(self, request: Request, call_next): - # Generate or get request ID - request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) - - # Add request ID to request state + request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4()) request.state.request_id = request_id - - # Log request - logger.info( - f"Request started: {request.method} {request.url.path}", - extra={ - "request_id": request_id, - "method": request.method, - "path": request.url.path, - "client_ip": request.client.host if request.client else None - } - ) - - # Process request + logger.info(f'Request started: {request.method} {request.url.path}', extra={'request_id': request_id, 'method': request.method, 'path': request.url.path, 'client_ip': request.client.host if request.client else None}) try: response = await call_next(request) - - # Add request ID to response headers - response.headers["X-Request-ID"] = request_id - - # Log response - logger.info( - f"Request completed: {request.method} {request.url.path} - {response.status_code}", - extra={ - "request_id": request_id, - "method": request.method, - "path": request.url.path, - "status_code": response.status_code - } - ) - + response.headers['X-Request-ID'] = request_id + logger.info(f'Request completed: {request.method} {request.url.path} - {response.status_code}', extra={'request_id': request_id, 'method': request.method, 'path': request.url.path, 'status_code': response.status_code}) return response except Exception as e: - logger.error( - f"Request failed: {request.method} {request.url.path} - {str(e)}", - extra={ - "request_id": request_id, - "method": request.method, - "path": request.url.path, - "error": str(e) - }, - exc_info=True - ) - raise - + logger.error(f'Request failed: {request.method} {request.url.path} - {str(e)}', extra={'request_id': request_id, 'method': request.method, 'path': request.url.path, 'error': str(e)}, exc_info=True) + raise \ No newline at end of file diff --git a/Backend/src/middleware/security.py b/Backend/src/middleware/security.py index e575fd31..9dfe8e2a 100644 --- a/Backend/src/middleware/security.py +++ b/Backend/src/middleware/security.py @@ -1,57 +1,20 @@ -""" -Security middleware for adding security headers -""" from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware from starlette.responses import Response from ..config.logging_config import get_logger from ..config.settings import settings - logger = get_logger(__name__) - class SecurityHeadersMiddleware(BaseHTTPMiddleware): - """Add security headers to all responses""" - + async def dispatch(self, request: Request, call_next): response = await call_next(request) - - # Security headers - security_headers = { - "X-Content-Type-Options": "nosniff", - "X-Frame-Options": "DENY", - "X-XSS-Protection": "1; mode=block", - "Referrer-Policy": "strict-origin-when-cross-origin", - "Permissions-Policy": "geolocation=(), microphone=(), camera=()", - } - - # Allow resources (like banner images) to be loaded cross-origin by the frontend. - # This helps avoid Firefox's OpaqueResponseBlocking when the frontend runs - # on a different origin (e.g. Vite dev server on :5173) and loads images - # from the API origin (e.g. :8000). - # - # In production you may want a stricter policy (e.g. "same-site") depending - # on your deployment topology. - security_headers.setdefault("Cross-Origin-Resource-Policy", "cross-origin") - - # Add Content-Security-Policy + security_headers = {'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()'} + security_headers.setdefault('Cross-Origin-Resource-Policy', 'cross-origin') if settings.is_production: - security_headers["Content-Security-Policy"] = ( - "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " - "style-src 'self' 'unsafe-inline'; " - "img-src 'self' data: https:; " - "font-src 'self' data:; " - "connect-src 'self'" - ) - - # Add Strict-Transport-Security in production with HTTPS + security_headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'" if settings.is_production: - security_headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" - - # Apply headers + security_headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' for header, value in security_headers.items(): response.headers[header] = value - - return response - + return response \ No newline at end of file diff --git a/Backend/src/middleware/timeout.py b/Backend/src/middleware/timeout.py index db0c5ada..ff0319ee 100644 --- a/Backend/src/middleware/timeout.py +++ b/Backend/src/middleware/timeout.py @@ -1,41 +1,16 @@ -""" -Request timeout middleware -""" import asyncio from fastapi import Request, HTTPException, status from starlette.middleware.base import BaseHTTPMiddleware from ..config.logging_config import get_logger from ..config.settings import settings - logger = get_logger(__name__) - class TimeoutMiddleware(BaseHTTPMiddleware): - """Add timeout to requests""" - + async def dispatch(self, request: Request, call_next): try: - # Use asyncio.wait_for to add timeout - response = await asyncio.wait_for( - call_next(request), - timeout=settings.REQUEST_TIMEOUT - ) + response = await asyncio.wait_for(call_next(request), timeout=settings.REQUEST_TIMEOUT) return response except asyncio.TimeoutError: - logger.warning( - f"Request timeout: {request.method} {request.url.path}", - extra={ - "request_id": getattr(request.state, "request_id", None), - "method": request.method, - "path": request.url.path, - "timeout": settings.REQUEST_TIMEOUT - } - ) - raise HTTPException( - status_code=status.HTTP_504_GATEWAY_TIMEOUT, - detail={ - "status": "error", - "message": "Request timeout. Please try again." - } - ) - + logger.warning(f'Request timeout: {request.method} {request.url.path}', extra={'request_id': getattr(request.state, 'request_id', None), 'method': request.method, 'path': request.url.path, 'timeout': settings.REQUEST_TIMEOUT}) + raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail={'status': 'error', 'message': 'Request timeout. Please try again.'}) \ No newline at end of file diff --git a/Backend/src/models/__init__.py b/Backend/src/models/__init__.py index 27187c9a..dd121095 100644 --- a/Backend/src/models/__init__.py +++ b/Backend/src/models/__init__.py @@ -20,36 +20,5 @@ from .cookie_integration_config import CookieIntegrationConfig from .system_settings import SystemSettings from .invoice import Invoice, InvoiceItem from .page_content import PageContent, PageType - -__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", -] - +from .chat import Chat, ChatMessage, ChatStatus +__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'] \ No newline at end of file diff --git a/Backend/src/models/__pycache__/__init__.cpython-312.pyc b/Backend/src/models/__pycache__/__init__.cpython-312.pyc index 97f64fe3..8664a45a 100644 Binary files a/Backend/src/models/__pycache__/__init__.cpython-312.pyc and b/Backend/src/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/chat.cpython-312.pyc b/Backend/src/models/__pycache__/chat.cpython-312.pyc new file mode 100644 index 00000000..499d5196 Binary files /dev/null and b/Backend/src/models/__pycache__/chat.cpython-312.pyc differ diff --git a/Backend/src/models/__pycache__/user.cpython-312.pyc b/Backend/src/models/__pycache__/user.cpython-312.pyc index 9ca05695..f7c5f6e1 100644 Binary files a/Backend/src/models/__pycache__/user.cpython-312.pyc and b/Backend/src/models/__pycache__/user.cpython-312.pyc differ diff --git a/Backend/src/models/audit_log.py b/Backend/src/models/audit_log.py index b566e76a..d0d4d187 100644 --- a/Backend/src/models/audit_log.py +++ b/Backend/src/models/audit_log.py @@ -1,28 +1,20 @@ -""" -Audit log model for tracking important actions -""" from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class AuditLog(Base): - __tablename__ = "audit_logs" - + __tablename__ = 'audit_logs' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True) - action = Column(String(100), nullable=False, index=True) # e.g., "user.created", "booking.cancelled" - resource_type = Column(String(50), nullable=False, index=True) # e.g., "user", "booking" + user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True) + action = Column(String(100), nullable=False, index=True) + resource_type = Column(String(50), nullable=False, index=True) resource_id = Column(Integer, nullable=True, index=True) - ip_address = Column(String(45), nullable=True) # IPv6 compatible + ip_address = Column(String(45), nullable=True) user_agent = Column(String(255), nullable=True) - request_id = Column(String(36), nullable=True, index=True) # UUID - details = Column(JSON, nullable=True) # Additional context - status = Column(String(20), nullable=False, default="success") # success, failed, error + request_id = Column(String(36), nullable=True, index=True) + details = Column(JSON, nullable=True) + status = Column(String(20), nullable=False, default='success') error_message = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) - - # Relationships - user = relationship("User", foreign_keys=[user_id]) - + user = relationship('User', foreign_keys=[user_id]) \ No newline at end of file diff --git a/Backend/src/models/banner.py b/Backend/src/models/banner.py index d580bd0a..d417509e 100644 --- a/Backend/src/models/banner.py +++ b/Backend/src/models/banner.py @@ -3,16 +3,14 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class Banner(Base): - __tablename__ = "banners" - + __tablename__ = 'banners' id = Column(Integer, primary_key=True, index=True, autoincrement=True) title = Column(String(100), nullable=False) description = Column(Text, nullable=True) image_url = Column(String(255), nullable=False) link_url = Column(String(255), nullable=True) - position = Column(String(50), nullable=False, default="home") + position = Column(String(50), nullable=False, default='home') display_order = Column(Integer, nullable=False, default=0) is_active = Column(Boolean, nullable=False, default=True) start_date = Column(DateTime, nullable=True) @@ -27,5 +25,4 @@ class Banner(Base): return False if not self.start_date or not self.end_date: return self.is_active - return self.start_date <= now <= self.end_date - + return self.start_date <= now <= self.end_date \ No newline at end of file diff --git a/Backend/src/models/booking.py b/Backend/src/models/booking.py index bfe7bc60..e36d1127 100644 --- a/Backend/src/models/booking.py +++ b/Backend/src/models/booking.py @@ -4,41 +4,35 @@ from datetime import datetime import enum from ..config.database import Base - class BookingStatus(str, enum.Enum): - pending = "pending" - confirmed = "confirmed" - checked_in = "checked_in" - checked_out = "checked_out" - cancelled = "cancelled" - + pending = 'pending' + confirmed = 'confirmed' + checked_in = 'checked_in' + checked_out = 'checked_out' + cancelled = 'cancelled' class Booking(Base): - __tablename__ = "bookings" - + __tablename__ = 'bookings' id = Column(Integer, primary_key=True, index=True, autoincrement=True) booking_number = Column(String(50), unique=True, nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) check_in_date = Column(DateTime, nullable=False) check_out_date = Column(DateTime, nullable=False) num_guests = Column(Integer, nullable=False, default=1) total_price = Column(Numeric(10, 2), nullable=False) - original_price = Column(Numeric(10, 2), nullable=True) # Price before discount - discount_amount = Column(Numeric(10, 2), nullable=True, default=0) # Discount amount applied - promotion_code = Column(String(50), nullable=True) # Promotion code used + original_price = Column(Numeric(10, 2), nullable=True) + discount_amount = Column(Numeric(10, 2), nullable=True, default=0) + promotion_code = Column(String(50), nullable=True) status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending) deposit_paid = Column(Boolean, nullable=False, default=False) requires_deposit = Column(Boolean, nullable=False, default=False) special_requests = 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 - user = relationship("User", back_populates="bookings") - room = relationship("Room", back_populates="bookings") - payments = relationship("Payment", 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") - checkin_checkout = relationship("CheckInCheckOut", back_populates="booking", uselist=False) - + user = relationship('User', back_populates='bookings') + room = relationship('Room', back_populates='bookings') + payments = relationship('Payment', 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') + checkin_checkout = relationship('CheckInCheckOut', back_populates='booking', uselist=False) \ No newline at end of file diff --git a/Backend/src/models/chat.py b/Backend/src/models/chat.py new file mode 100644 index 00000000..3562f01d --- /dev/null +++ b/Backend/src/models/chat.py @@ -0,0 +1,37 @@ +from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum, Boolean +from sqlalchemy.orm import relationship +from datetime import datetime +import enum +from ..config.database import Base + +class ChatStatus(str, enum.Enum): + pending = 'pending' + active = 'active' + closed = 'closed' + +class Chat(Base): + __tablename__ = 'chats' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + visitor_id = Column(Integer, ForeignKey('users.id'), nullable=True) + visitor_name = Column(String(100), nullable=True) + visitor_email = Column(String(100), nullable=True) + staff_id = Column(Integer, ForeignKey('users.id'), nullable=True) + status = Column(Enum(ChatStatus), nullable=False, default=ChatStatus.pending) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + closed_at = Column(DateTime, nullable=True) + visitor = relationship('User', foreign_keys=[visitor_id], back_populates='visitor_chats') + staff = relationship('User', foreign_keys=[staff_id], back_populates='staff_chats') + messages = relationship('ChatMessage', back_populates='chat', cascade='all, delete-orphan', order_by='ChatMessage.created_at') + +class ChatMessage(Base): + __tablename__ = 'chat_messages' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + chat_id = Column(Integer, ForeignKey('chats.id'), nullable=False) + sender_id = Column(Integer, ForeignKey('users.id'), nullable=True) + sender_type = Column(String(20), nullable=False) + message = Column(Text, nullable=False) + is_read = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + chat = relationship('Chat', back_populates='messages') + sender = relationship('User', foreign_keys=[sender_id]) \ No newline at end of file diff --git a/Backend/src/models/checkin_checkout.py b/Backend/src/models/checkin_checkout.py index 0f8291f7..0d9d7ff0 100644 --- a/Backend/src/models/checkin_checkout.py +++ b/Backend/src/models/checkin_checkout.py @@ -3,25 +3,20 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class CheckInCheckOut(Base): - __tablename__ = "checkin_checkout" - + __tablename__ = 'checkin_checkout' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False, unique=True) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, unique=True) checkin_time = Column(DateTime, nullable=True) checkout_time = Column(DateTime, nullable=True) - checkin_by = Column(Integer, ForeignKey("users.id"), nullable=True) - checkout_by = Column(Integer, ForeignKey("users.id"), nullable=True) + checkin_by = Column(Integer, ForeignKey('users.id'), nullable=True) + checkout_by = Column(Integer, ForeignKey('users.id'), nullable=True) room_condition_checkin = Column(Text, nullable=True) room_condition_checkout = Column(Text, nullable=True) additional_charges = Column(Numeric(10, 2), nullable=False, default=0.0) 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 - booking = relationship("Booking", back_populates="checkin_checkout") - checked_in_by = relationship("User", foreign_keys=[checkin_by], back_populates="checkins_processed") - checked_out_by = relationship("User", foreign_keys=[checkout_by], back_populates="checkouts_processed") - + booking = relationship('Booking', back_populates='checkin_checkout') + checked_in_by = relationship('User', foreign_keys=[checkin_by], back_populates='checkins_processed') + checked_out_by = relationship('User', foreign_keys=[checkout_by], back_populates='checkouts_processed') \ No newline at end of file diff --git a/Backend/src/models/cookie_integration_config.py b/Backend/src/models/cookie_integration_config.py index 2d2b1a20..660883b0 100644 --- a/Backend/src/models/cookie_integration_config.py +++ b/Backend/src/models/cookie_integration_config.py @@ -1,30 +1,14 @@ from datetime import datetime - from sqlalchemy import Column, DateTime, ForeignKey, Integer, String from sqlalchemy.orm import relationship - from ..config.database import Base - class CookieIntegrationConfig(Base): - """ - Stores IDs for well-known integrations (e.g., Google Analytics, Meta Pixel). - Does NOT allow arbitrary script injection from the dashboard. - """ - - __tablename__ = "cookie_integration_configs" - + __tablename__ = 'cookie_integration_configs' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - - ga_measurement_id = Column(String(64), nullable=True) # e.g. G-XXXXXXXXXX - fb_pixel_id = Column(String(64), nullable=True) # e.g. 1234567890 - + ga_measurement_id = Column(String(64), nullable=True) + fb_pixel_id = Column(String(64), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column( - DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False - ) - - updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) - updated_by = relationship("User", lazy="joined") - - + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) + updated_by = relationship('User', lazy='joined') \ No newline at end of file diff --git a/Backend/src/models/cookie_policy.py b/Backend/src/models/cookie_policy.py index 8395b0fc..3a5b253a 100644 --- a/Backend/src/models/cookie_policy.py +++ b/Backend/src/models/cookie_policy.py @@ -1,31 +1,15 @@ from datetime import datetime - from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer from sqlalchemy.orm import relationship - from ..config.database import Base - class CookiePolicy(Base): - """ - Global cookie policy controlled by administrators. - - This does NOT store per-user consent; it controls which cookie categories - are available to be requested from users (e.g., disable analytics entirely). - """ - - __tablename__ = "cookie_policies" - - id = Column(Integer, primary_key=True, index=True, autoincrement=True) - - analytics_enabled = Column(Boolean, default=True, nullable=False) - marketing_enabled = Column(Boolean, default=True, nullable=False) - preferences_enabled = Column(Boolean, default=True, nullable=False) - - created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) - updated_by = relationship("User", lazy="joined") - - + __tablename__ = 'cookie_policies' + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + analytics_enabled = Column(Boolean, default=True, nullable=False) + marketing_enabled = Column(Boolean, default=True, nullable=False) + preferences_enabled = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) + updated_by = relationship('User', lazy='joined') \ No newline at end of file diff --git a/Backend/src/models/favorite.py b/Backend/src/models/favorite.py index f36a6f1d..fe795288 100644 --- a/Backend/src/models/favorite.py +++ b/Backend/src/models/favorite.py @@ -3,17 +3,12 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class Favorite(Base): - __tablename__ = "favorites" - + __tablename__ = 'favorites' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) 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", back_populates="favorites") - room = relationship("Room", back_populates="favorites") - + user = relationship('User', back_populates='favorites') + room = relationship('Room', back_populates='favorites') \ No newline at end of file diff --git a/Backend/src/models/invoice.py b/Backend/src/models/invoice.py index 2311f2a8..0372c629 100644 --- a/Backend/src/models/invoice.py +++ b/Backend/src/models/invoice.py @@ -4,98 +4,68 @@ from datetime import datetime import enum from ..config.database import Base - class InvoiceStatus(str, enum.Enum): - draft = "draft" - sent = "sent" - paid = "paid" - overdue = "overdue" - cancelled = "cancelled" - + draft = 'draft' + sent = 'sent' + paid = 'paid' + overdue = 'overdue' + cancelled = 'cancelled' class Invoice(Base): - __tablename__ = "invoices" - + __tablename__ = 'invoices' id = Column(Integer, primary_key=True, index=True, autoincrement=True) invoice_number = Column(String(50), unique=True, nullable=False, index=True) - booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - - # Invoice details + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) issue_date = Column(DateTime, default=datetime.utcnow, nullable=False) due_date = Column(DateTime, nullable=False) paid_date = Column(DateTime, nullable=True) - - # Amounts - subtotal = Column(Numeric(10, 2), nullable=False, default=0.00) - tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00) # Tax percentage - tax_amount = Column(Numeric(10, 2), nullable=False, default=0.00) - discount_amount = Column(Numeric(10, 2), nullable=False, default=0.00) + subtotal = Column(Numeric(10, 2), nullable=False, default=0.0) + tax_rate = Column(Numeric(5, 2), nullable=False, default=0.0) + tax_amount = Column(Numeric(10, 2), nullable=False, default=0.0) + discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0) total_amount = Column(Numeric(10, 2), nullable=False) - amount_paid = Column(Numeric(10, 2), nullable=False, default=0.00) + amount_paid = Column(Numeric(10, 2), nullable=False, default=0.0) balance_due = Column(Numeric(10, 2), nullable=False) - - # Status status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft) - is_proforma = Column(Boolean, nullable=False, default=False) # True for proforma invoices - - # Company/Organization information (for admin to manage) + is_proforma = Column(Boolean, nullable=False, default=False) company_name = Column(String(200), nullable=True) company_address = Column(Text, nullable=True) company_phone = Column(String(50), nullable=True) company_email = Column(String(100), nullable=True) company_tax_id = Column(String(100), nullable=True) company_logo_url = Column(String(500), nullable=True) - - # Customer information (snapshot at invoice creation) customer_name = Column(String(200), nullable=False) customer_email = Column(String(100), nullable=False) customer_address = Column(Text, nullable=True) customer_phone = Column(String(50), nullable=True) customer_tax_id = Column(String(100), nullable=True) - - # Additional information notes = Column(Text, nullable=True) terms_and_conditions = Column(Text, nullable=True) payment_instructions = Column(Text, nullable=True) - - # Metadata - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) - updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) + created_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) + updated_by_id = 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 - booking = relationship("Booking", back_populates="invoices") - user = relationship("User", foreign_keys=[user_id], backref="invoices") - created_by = relationship("User", foreign_keys=[created_by_id]) - updated_by = relationship("User", foreign_keys=[updated_by_id]) - items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan") - + booking = relationship('Booking', back_populates='invoices') + user = relationship('User', foreign_keys=[user_id], backref='invoices') + created_by = relationship('User', foreign_keys=[created_by_id]) + updated_by = relationship('User', foreign_keys=[updated_by_id]) + items = relationship('InvoiceItem', back_populates='invoice', cascade='all, delete-orphan') class InvoiceItem(Base): - __tablename__ = "invoice_items" - + __tablename__ = 'invoice_items' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - invoice_id = Column(Integer, ForeignKey("invoices.id"), nullable=False) - - # Item details + invoice_id = Column(Integer, ForeignKey('invoices.id'), nullable=False) description = Column(String(500), nullable=False) - quantity = Column(Numeric(10, 2), nullable=False, default=1.00) + quantity = Column(Numeric(10, 2), nullable=False, default=1.0) unit_price = Column(Numeric(10, 2), nullable=False) - tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00) - discount_amount = Column(Numeric(10, 2), nullable=False, default=0.00) + tax_rate = Column(Numeric(5, 2), nullable=False, default=0.0) + discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0) line_total = Column(Numeric(10, 2), nullable=False) - - # Optional reference to booking items - room_id = Column(Integer, ForeignKey("rooms.id"), nullable=True) - service_id = Column(Integer, ForeignKey("services.id"), nullable=True) - - # Metadata + room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True) + service_id = Column(Integer, ForeignKey('services.id'), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - - # Relationships - invoice = relationship("Invoice", back_populates="items") - room = relationship("Room") - service = relationship("Service") - + invoice = relationship('Invoice', back_populates='items') + room = relationship('Room') + service = relationship('Service') \ No newline at end of file diff --git a/Backend/src/models/page_content.py b/Backend/src/models/page_content.py index 4fe410df..e093ac6b 100644 --- a/Backend/src/models/page_content.py +++ b/Backend/src/models/page_content.py @@ -2,31 +2,23 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Enum as from sqlalchemy.orm import relationship from datetime import datetime import enum - from ..config.database import Base - class PageType(str, enum.Enum): - HOME = "home" - CONTACT = "contact" - ABOUT = "about" - FOOTER = "footer" - SEO = "seo" - + HOME = 'home' + CONTACT = 'contact' + ABOUT = 'about' + FOOTER = 'footer' + SEO = 'seo' class PageContent(Base): - __tablename__ = "page_contents" - + __tablename__ = 'page_contents' id = Column(Integer, primary_key=True, index=True) page_type = Column(SQLEnum(PageType), nullable=False, unique=True, index=True) - - # General content fields title = Column(String(500), nullable=True) subtitle = Column(String(1000), nullable=True) description = Column(Text, nullable=True) - content = Column(Text, nullable=True) # Rich text content - - # SEO fields + content = Column(Text, nullable=True) meta_title = Column(String(500), nullable=True) meta_description = Column(Text, nullable=True) meta_keywords = Column(String(1000), nullable=True) @@ -34,67 +26,57 @@ class PageContent(Base): og_description = Column(Text, nullable=True) og_image = Column(String(1000), nullable=True) canonical_url = Column(String(1000), nullable=True) - - # Contact/Footer specific fields (stored as JSON strings) - contact_info = Column(Text, nullable=True) # JSON: phone, email, address - map_url = Column(String(1000), nullable=True) # Google Maps embed URL - social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc. - footer_links = Column(Text, nullable=True) # JSON: quick links, support links - badges = Column(Text, nullable=True) # JSON: array of badges with text and icon - copyright_text = Column(Text, nullable=True) # Copyright text with {YEAR} placeholder for automatic year - - # Home page specific + contact_info = Column(Text, nullable=True) + map_url = Column(String(1000), nullable=True) + social_links = Column(Text, nullable=True) + footer_links = Column(Text, nullable=True) + badges = Column(Text, nullable=True) + copyright_text = Column(Text, nullable=True) hero_title = Column(String(500), nullable=True) hero_subtitle = Column(String(1000), nullable=True) hero_image = Column(String(1000), nullable=True) - - # About page specific story_content = Column(Text, nullable=True) - values = Column(Text, nullable=True) # JSON array of values - features = Column(Text, nullable=True) # JSON array of features - about_hero_image = Column(Text, nullable=True) # Hero image for about page - mission = Column(Text, nullable=True) # Mission statement - vision = Column(Text, nullable=True) # Vision statement - team = Column(Text, nullable=True) # JSON array of team members with name, role, image, bio, social_links - timeline = Column(Text, nullable=True) # JSON array of timeline events with year, title, description, image - achievements = Column(Text, nullable=True) # JSON array of achievements with icon, title, description, year, image - - # Home page luxury sections + values = Column(Text, nullable=True) + features = Column(Text, nullable=True) + about_hero_image = Column(Text, nullable=True) + mission = Column(Text, nullable=True) + vision = Column(Text, nullable=True) + team = Column(Text, nullable=True) + timeline = Column(Text, nullable=True) + achievements = Column(Text, nullable=True) luxury_section_title = Column(Text, nullable=True) luxury_section_subtitle = Column(Text, nullable=True) luxury_section_image = Column(Text, nullable=True) - luxury_features = Column(Text, nullable=True) # JSON array of features with icon, title, description + luxury_features = Column(Text, nullable=True) luxury_gallery_section_title = Column(Text, nullable=True) luxury_gallery_section_subtitle = Column(Text, nullable=True) - luxury_gallery = Column(Text, nullable=True) # JSON array of image URLs + luxury_gallery = Column(Text, nullable=True) luxury_testimonials_section_title = Column(Text, nullable=True) luxury_testimonials_section_subtitle = Column(Text, nullable=True) - luxury_testimonials = Column(Text, nullable=True) # JSON array of testimonials + luxury_testimonials = Column(Text, nullable=True) amenities_section_title = Column(String(500), nullable=True) amenities_section_subtitle = Column(String(1000), nullable=True) - amenities = Column(Text, nullable=True) # JSON array of amenities with icon, title, description, image + amenities = Column(Text, nullable=True) testimonials_section_title = Column(String(500), nullable=True) testimonials_section_subtitle = Column(String(1000), nullable=True) - testimonials = Column(Text, nullable=True) # JSON array of testimonials with name, role, image, rating, comment + testimonials = Column(Text, nullable=True) gallery_section_title = Column(String(500), nullable=True) gallery_section_subtitle = Column(String(1000), nullable=True) - gallery_images = Column(Text, nullable=True) # JSON array of image URLs + gallery_images = Column(Text, nullable=True) about_preview_title = Column(String(500), nullable=True) about_preview_subtitle = Column(String(1000), nullable=True) about_preview_content = Column(Text, nullable=True) about_preview_image = Column(String(1000), nullable=True) - stats = Column(Text, nullable=True) # JSON array of stats with number, label, icon - - # Additional luxury sections + stats = Column(Text, nullable=True) luxury_services_section_title = Column(Text, nullable=True) luxury_services_section_subtitle = Column(Text, nullable=True) - luxury_services = Column(Text, nullable=True) # JSON array of services with icon, title, description, image + luxury_services = Column(Text, nullable=True) luxury_experiences_section_title = Column(Text, nullable=True) luxury_experiences_section_subtitle = Column(Text, nullable=True) - luxury_experiences = Column(Text, nullable=True) # JSON array of experiences with icon, title, description, image + luxury_experiences = Column(Text, nullable=True) awards_section_title = Column(Text, nullable=True) awards_section_subtitle = Column(Text, nullable=True) - awards = Column(Text, nullable=True) # JSON array of awards with icon, title, description, image, year + awards = Column(Text, nullable=True) cta_title = Column(Text, nullable=True) cta_subtitle = Column(Text, nullable=True) cta_button_text = Column(Text, nullable=True) @@ -102,12 +84,7 @@ class PageContent(Base): cta_image = Column(Text, nullable=True) partners_section_title = Column(Text, nullable=True) partners_section_subtitle = Column(Text, nullable=True) - partners = Column(Text, nullable=True) # JSON array of partners with name, logo, link - - # Status + partners = Column(Text, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - - # Timestamps 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) \ No newline at end of file diff --git a/Backend/src/models/password_reset_token.py b/Backend/src/models/password_reset_token.py index 9a6cbda5..358eb7ee 100644 --- a/Backend/src/models/password_reset_token.py +++ b/Backend/src/models/password_reset_token.py @@ -3,18 +3,13 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class PasswordResetToken(Base): - __tablename__ = "password_reset_tokens" - + __tablename__ = 'password_reset_tokens' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) token = Column(String(255), unique=True, nullable=False, index=True) expires_at = Column(DateTime, nullable=False) used = Column(Boolean, default=False, nullable=False) 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") - + user = relationship('User') \ No newline at end of file diff --git a/Backend/src/models/payment.py b/Backend/src/models/payment.py index d06f1b26..f057b30a 100644 --- a/Backend/src/models/payment.py +++ b/Backend/src/models/payment.py @@ -4,48 +4,40 @@ from datetime import datetime import enum from ..config.database import Base - class PaymentMethod(str, enum.Enum): - cash = "cash" - credit_card = "credit_card" - debit_card = "debit_card" - bank_transfer = "bank_transfer" - e_wallet = "e_wallet" - stripe = "stripe" - paypal = "paypal" - + cash = 'cash' + credit_card = 'credit_card' + debit_card = 'debit_card' + bank_transfer = 'bank_transfer' + e_wallet = 'e_wallet' + stripe = 'stripe' + paypal = 'paypal' class PaymentType(str, enum.Enum): - full = "full" - deposit = "deposit" - remaining = "remaining" - + full = 'full' + deposit = 'deposit' + remaining = 'remaining' class PaymentStatus(str, enum.Enum): - pending = "pending" - completed = "completed" - failed = "failed" - refunded = "refunded" - + pending = 'pending' + completed = 'completed' + failed = 'failed' + refunded = 'refunded' class Payment(Base): - __tablename__ = "payments" - + __tablename__ = 'payments' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False) amount = Column(Numeric(10, 2), nullable=False) payment_method = Column(Enum(PaymentMethod), nullable=False) payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full) deposit_percentage = Column(Integer, nullable=True) - related_payment_id = Column(Integer, ForeignKey("payments.id"), nullable=True) + related_payment_id = Column(Integer, ForeignKey('payments.id'), nullable=True) payment_status = Column(Enum(PaymentStatus), nullable=False, default=PaymentStatus.pending) transaction_id = Column(String(100), nullable=True) payment_date = Column(DateTime, nullable=True) notes = Column(Text, nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - # Relationships - booking = relationship("Booking", back_populates="payments") - related_payment = relationship("Payment", remote_side=[id], backref="related_payments") - + booking = relationship('Booking', back_populates='payments') + related_payment = relationship('Payment', remote_side=[id], backref='related_payments') \ No newline at end of file diff --git a/Backend/src/models/promotion.py b/Backend/src/models/promotion.py index 9abb842b..ac499744 100644 --- a/Backend/src/models/promotion.py +++ b/Backend/src/models/promotion.py @@ -4,15 +4,12 @@ from datetime import datetime import enum from ..config.database import Base - class DiscountType(str, enum.Enum): - percentage = "percentage" - fixed_amount = "fixed_amount" - + percentage = 'percentage' + fixed_amount = 'fixed_amount' class Promotion(Base): - __tablename__ = "promotions" - + __tablename__ = 'promotions' id = Column(Integer, primary_key=True, index=True, autoincrement=True) code = Column(String(50), unique=True, nullable=False, index=True) name = Column(String(100), nullable=False) @@ -43,18 +40,13 @@ class Promotion(Base): def calculate_discount(self, booking_amount): if not self.is_valid(): return 0.0 - if self.min_booking_amount and booking_amount < float(self.min_booking_amount): return 0.0 - discount = 0.0 if self.discount_type == DiscountType.percentage: discount = float(booking_amount) * float(self.discount_value) / 100.0 elif self.discount_type == DiscountType.fixed_amount: discount = float(self.discount_value) - if self.max_discount_amount and discount > float(self.max_discount_amount): discount = float(self.max_discount_amount) - - return discount - + return discount \ No newline at end of file diff --git a/Backend/src/models/refresh_token.py b/Backend/src/models/refresh_token.py index d6d4f357..92c4fd20 100644 --- a/Backend/src/models/refresh_token.py +++ b/Backend/src/models/refresh_token.py @@ -3,16 +3,11 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class RefreshToken(Base): - __tablename__ = "refresh_tokens" - + __tablename__ = 'refresh_tokens' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) token = Column(String(500), unique=True, nullable=False, index=True) expires_at = Column(DateTime, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - - # Relationships - user = relationship("User", back_populates="refresh_tokens") - + user = relationship('User', back_populates='refresh_tokens') \ No newline at end of file diff --git a/Backend/src/models/review.py b/Backend/src/models/review.py index ac925834..e7693f43 100644 --- a/Backend/src/models/review.py +++ b/Backend/src/models/review.py @@ -4,26 +4,20 @@ from datetime import datetime import enum from ..config.database import Base - class ReviewStatus(str, enum.Enum): - pending = "pending" - approved = "approved" - rejected = "rejected" - + pending = 'pending' + approved = 'approved' + rejected = 'rejected' class Review(Base): - __tablename__ = "reviews" - + __tablename__ = 'reviews' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) + room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) rating = Column(Integer, nullable=False) comment = Column(Text, nullable=False) status = Column(Enum(ReviewStatus), nullable=False, default=ReviewStatus.pending) 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", back_populates="reviews") - room = relationship("Room", back_populates="reviews") - + user = relationship('User', back_populates='reviews') + room = relationship('Room', back_populates='reviews') \ No newline at end of file diff --git a/Backend/src/models/role.py b/Backend/src/models/role.py index a0142de7..61ba98f0 100644 --- a/Backend/src/models/role.py +++ b/Backend/src/models/role.py @@ -3,16 +3,11 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class Role(Base): - __tablename__ = "roles" - + __tablename__ = 'roles' id = Column(Integer, primary_key=True, index=True, autoincrement=True) name = Column(String(50), unique=True, nullable=False, index=True) description = Column(String(255), nullable=True) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - # Relationships - users = relationship("User", back_populates="role") - + users = relationship('User', back_populates='role') \ No newline at end of file diff --git a/Backend/src/models/room.py b/Backend/src/models/room.py index fa92c800..4bc4e0e4 100644 --- a/Backend/src/models/room.py +++ b/Backend/src/models/room.py @@ -4,36 +4,30 @@ from datetime import datetime import enum from ..config.database import Base - class RoomStatus(str, enum.Enum): - available = "available" - occupied = "occupied" - maintenance = "maintenance" - cleaning = "cleaning" - + available = 'available' + occupied = 'occupied' + maintenance = 'maintenance' + cleaning = 'cleaning' class Room(Base): - __tablename__ = "rooms" - + __tablename__ = 'rooms' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - room_type_id = Column(Integer, ForeignKey("room_types.id"), nullable=False) + room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=False) room_number = Column(String(20), unique=True, nullable=False, index=True) floor = Column(Integer, nullable=False) status = Column(Enum(RoomStatus), nullable=False, default=RoomStatus.available) price = Column(Numeric(10, 2), nullable=False) featured = Column(Boolean, nullable=False, default=False) - capacity = Column(Integer, nullable=True) # Room-specific capacity, overrides room_type capacity - room_size = Column(String(50), nullable=True) # e.g., "1 Room", "2 Rooms", "50 sqm" - view = Column(String(100), nullable=True) # e.g., "City View", "Ocean View", etc. + capacity = Column(Integer, nullable=True) + room_size = Column(String(50), nullable=True) + view = Column(String(100), nullable=True) images = Column(JSON, nullable=True) amenities = Column(JSON, nullable=True) description = 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_type = relationship("RoomType", back_populates="rooms") - bookings = relationship("Booking", back_populates="room") - reviews = relationship("Review", back_populates="room") - favorites = relationship("Favorite", back_populates="room", cascade="all, delete-orphan") - + room_type = relationship('RoomType', back_populates='rooms') + bookings = relationship('Booking', back_populates='room') + reviews = relationship('Review', back_populates='room') + favorites = relationship('Favorite', back_populates='room', cascade='all, delete-orphan') \ No newline at end of file diff --git a/Backend/src/models/room_type.py b/Backend/src/models/room_type.py index 72125144..a9efa2b7 100644 --- a/Backend/src/models/room_type.py +++ b/Backend/src/models/room_type.py @@ -3,10 +3,8 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class RoomType(Base): - __tablename__ = "room_types" - + __tablename__ = 'room_types' id = Column(Integer, primary_key=True, index=True, autoincrement=True) name = Column(String(100), unique=True, nullable=False) description = Column(Text, nullable=True) @@ -15,7 +13,4 @@ class RoomType(Base): amenities = 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 - rooms = relationship("Room", back_populates="room_type") - + rooms = relationship('Room', back_populates='room_type') \ No newline at end of file diff --git a/Backend/src/models/service.py b/Backend/src/models/service.py index 44ee852f..36ecf3a5 100644 --- a/Backend/src/models/service.py +++ b/Backend/src/models/service.py @@ -3,10 +3,8 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class Service(Base): - __tablename__ = "services" - + __tablename__ = 'services' id = Column(Integer, primary_key=True, index=True, autoincrement=True) name = Column(String(100), nullable=False) description = Column(Text, nullable=True) @@ -15,7 +13,4 @@ class Service(Base): 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 - service_usages = relationship("ServiceUsage", back_populates="service") - + service_usages = relationship('ServiceUsage', back_populates='service') \ No newline at end of file diff --git a/Backend/src/models/service_booking.py b/Backend/src/models/service_booking.py index 31d09155..d8bc1ed5 100644 --- a/Backend/src/models/service_booking.py +++ b/Backend/src/models/service_booking.py @@ -4,66 +4,53 @@ from datetime import datetime import enum from ..config.database import Base - class ServiceBookingStatus(str, enum.Enum): - pending = "pending" - confirmed = "confirmed" - completed = "completed" - cancelled = "cancelled" - + pending = 'pending' + confirmed = 'confirmed' + completed = 'completed' + cancelled = 'cancelled' class ServiceBooking(Base): - __tablename__ = "service_bookings" - + __tablename__ = 'service_bookings' id = Column(Integer, primary_key=True, index=True, autoincrement=True) booking_number = Column(String(50), unique=True, nullable=False, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + user_id = Column(Integer, ForeignKey('users.id'), nullable=False) total_amount = Column(Numeric(10, 2), nullable=False) status = Column(Enum(ServiceBookingStatus), nullable=False, default=ServiceBookingStatus.pending) 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 - user = relationship("User", back_populates="service_bookings") - service_items = relationship("ServiceBookingItem", back_populates="service_booking", cascade="all, delete-orphan") - payments = relationship("ServicePayment", back_populates="service_booking", cascade="all, delete-orphan") - + user = relationship('User', back_populates='service_bookings') + service_items = relationship('ServiceBookingItem', back_populates='service_booking', cascade='all, delete-orphan') + payments = relationship('ServicePayment', back_populates='service_booking', cascade='all, delete-orphan') class ServiceBookingItem(Base): - __tablename__ = "service_booking_items" - + __tablename__ = 'service_booking_items' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False) - service_id = Column(Integer, ForeignKey("services.id"), nullable=False) + service_booking_id = Column(Integer, ForeignKey('service_bookings.id'), nullable=False) + service_id = Column(Integer, ForeignKey('services.id'), nullable=False) quantity = Column(Integer, nullable=False, default=1) unit_price = Column(Numeric(10, 2), nullable=False) total_price = Column(Numeric(10, 2), nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False) - - # Relationships - service_booking = relationship("ServiceBooking", back_populates="service_items") - service = relationship("Service") - + service_booking = relationship('ServiceBooking', back_populates='service_items') + service = relationship('Service') class ServicePaymentStatus(str, enum.Enum): - pending = "pending" - completed = "completed" - failed = "failed" - refunded = "refunded" - + pending = 'pending' + completed = 'completed' + failed = 'failed' + refunded = 'refunded' class ServicePaymentMethod(str, enum.Enum): - cash = "cash" - stripe = "stripe" - bank_transfer = "bank_transfer" - + cash = 'cash' + stripe = 'stripe' + bank_transfer = 'bank_transfer' class ServicePayment(Base): - __tablename__ = "service_payments" - + __tablename__ = 'service_payments' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False) + service_booking_id = Column(Integer, ForeignKey('service_bookings.id'), nullable=False) amount = Column(Numeric(10, 2), nullable=False) payment_method = Column(Enum(ServicePaymentMethod), nullable=False) payment_status = Column(Enum(ServicePaymentStatus), nullable=False, default=ServicePaymentStatus.pending) @@ -72,7 +59,4 @@ class ServicePayment(Base): 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 - service_booking = relationship("ServiceBooking", back_populates="payments") - + service_booking = relationship('ServiceBooking', back_populates='payments') \ No newline at end of file diff --git a/Backend/src/models/service_usage.py b/Backend/src/models/service_usage.py index d171636f..7ea1cd85 100644 --- a/Backend/src/models/service_usage.py +++ b/Backend/src/models/service_usage.py @@ -3,13 +3,11 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class ServiceUsage(Base): - __tablename__ = "service_usages" - + __tablename__ = 'service_usages' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False) - service_id = Column(Integer, ForeignKey("services.id"), nullable=False) + booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False) + service_id = Column(Integer, ForeignKey('services.id'), nullable=False) quantity = Column(Integer, nullable=False, default=1) unit_price = Column(Numeric(10, 2), nullable=False) total_price = Column(Numeric(10, 2), nullable=False) @@ -17,8 +15,5 @@ class ServiceUsage(Base): 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 - booking = relationship("Booking", back_populates="service_usages") - service = relationship("Service", back_populates="service_usages") - + booking = relationship('Booking', back_populates='service_usages') + service = relationship('Service', back_populates='service_usages') \ No newline at end of file diff --git a/Backend/src/models/system_settings.py b/Backend/src/models/system_settings.py index ce4e619b..c0643b88 100644 --- a/Backend/src/models/system_settings.py +++ b/Backend/src/models/system_settings.py @@ -3,19 +3,12 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class SystemSettings(Base): - """ - System-wide settings controlled by administrators. - Stores key-value pairs for platform configuration like currency, etc. - """ - __tablename__ = "system_settings" - + __tablename__ = 'system_settings' id = Column(Integer, primary_key=True, index=True, autoincrement=True) key = Column(String(100), unique=True, nullable=False, index=True) value = Column(Text, nullable=False) description = Column(Text, nullable=True) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) - updated_by = relationship("User", lazy="joined") - + updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True) + updated_by = relationship('User', lazy='joined') \ No newline at end of file diff --git a/Backend/src/models/user.py b/Backend/src/models/user.py index a89c85f8..30ea4c00 100644 --- a/Backend/src/models/user.py +++ b/Backend/src/models/user.py @@ -3,33 +3,30 @@ from sqlalchemy.orm import relationship from datetime import datetime from ..config.database import Base - class User(Base): - __tablename__ = "users" - + __tablename__ = 'users' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - role_id = Column(Integer, ForeignKey("roles.id"), nullable=False) + role_id = Column(Integer, ForeignKey('roles.id'), nullable=False) email = Column(String(100), unique=True, nullable=False, index=True) password = Column(String(255), nullable=False) full_name = Column(String(100), nullable=False) phone = Column(String(20), nullable=True) address = Column(Text, nullable=True) avatar = Column(String(255), nullable=True) - currency = Column(String(3), nullable=False, default='VND') # ISO 4217 currency code + currency = Column(String(3), nullable=False, default='VND') is_active = Column(Boolean, nullable=False, default=True) mfa_enabled = Column(Boolean, nullable=False, default=False) - mfa_secret = Column(String(255), nullable=True) # TOTP secret key (encrypted in production) - mfa_backup_codes = Column(Text, nullable=True) # JSON array of backup codes (hashed) + mfa_secret = Column(String(255), nullable=True) + mfa_backup_codes = 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 - role = relationship("Role", back_populates="users") - bookings = relationship("Booking", back_populates="user") - refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan") - checkins_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkin_by", back_populates="checked_in_by") - checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by") - reviews = relationship("Review", back_populates="user") - favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") - service_bookings = relationship("ServiceBooking", back_populates="user") - + role = relationship('Role', back_populates='users') + bookings = relationship('Booking', back_populates='user') + refresh_tokens = relationship('RefreshToken', back_populates='user', cascade='all, delete-orphan') + checkins_processed = relationship('CheckInCheckOut', foreign_keys='CheckInCheckOut.checkin_by', back_populates='checked_in_by') + checkouts_processed = relationship('CheckInCheckOut', foreign_keys='CheckInCheckOut.checkout_by', back_populates='checked_out_by') + reviews = relationship('Review', back_populates='user') + favorites = relationship('Favorite', back_populates='user', cascade='all, delete-orphan') + service_bookings = relationship('ServiceBooking', back_populates='user') + visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor') + staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff') \ No newline at end of file diff --git a/Backend/src/routes/__init__.py b/Backend/src/routes/__init__.py index fb42c318..e69de29b 100644 --- a/Backend/src/routes/__init__.py +++ b/Backend/src/routes/__init__.py @@ -1,2 +0,0 @@ -# Routes package - diff --git a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc index d3f86caa..9090bfa9 100644 Binary files a/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/booking_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc new file mode 100644 index 00000000..973c65d2 Binary files /dev/null and b/Backend/src/routes/__pycache__/chat_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/favorite_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/favorite_routes.cpython-312.pyc index 738a6fd4..7e3d5432 100644 Binary files a/Backend/src/routes/__pycache__/favorite_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/favorite_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/home_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/home_routes.cpython-312.pyc index c2edcee5..c1721f5b 100644 Binary files a/Backend/src/routes/__pycache__/home_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/home_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc index 918a552c..54074aa4 100644 Binary files a/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/payment_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/about_routes.py b/Backend/src/routes/about_routes.py index e753b119..7328f925 100644 --- a/Backend/src/routes/about_routes.py +++ b/Backend/src/routes/about_routes.py @@ -1,75 +1,23 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session import json - from ..config.database import get_db from ..config.logging_config import get_logger from ..models.page_content import PageContent, PageType - logger = get_logger(__name__) - -router = APIRouter(prefix="/about", tags=["about"]) - +router = APIRouter(prefix='/about', tags=['about']) def serialize_page_content(content: PageContent) -> dict: - """Serialize PageContent model to dictionary""" - return { - "id": content.id, - "page_type": content.page_type.value, - "title": content.title, - "subtitle": content.subtitle, - "description": content.description, - "content": content.content, - "meta_title": content.meta_title, - "meta_description": content.meta_description, - "meta_keywords": content.meta_keywords, - "og_title": content.og_title, - "og_description": content.og_description, - "og_image": content.og_image, - "canonical_url": content.canonical_url, - "story_content": content.story_content, - "values": json.loads(content.values) if content.values else None, - "features": json.loads(content.features) if content.features else None, - "about_hero_image": content.about_hero_image, - "mission": content.mission, - "vision": content.vision, - "team": json.loads(content.team) if content.team else None, - "timeline": json.loads(content.timeline) if content.timeline else None, - "achievements": json.loads(content.achievements) if content.achievements else None, - "is_active": content.is_active, - "created_at": content.created_at.isoformat() if content.created_at else None, - "updated_at": content.updated_at.isoformat() if content.updated_at else None, - } + return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} - -@router.get("/") -async def get_about_content( - db: Session = Depends(get_db) -): - """Get about page content""" +@router.get('/') +async def get_about_content(db: Session=Depends(get_db)): try: content = db.query(PageContent).filter(PageContent.page_type == PageType.ABOUT).first() - if not content: - return { - "status": "success", - "data": { - "page_content": None - } - } - + return {'status': 'success', 'data': {'page_content': None}} content_dict = serialize_page_content(content) - - return { - "status": "success", - "data": { - "page_content": content_dict - } - } + return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: - logger.error(f"Error fetching about content: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching about content: {str(e)}" - ) - + logger.error(f'Error fetching about content: {str(e)}', exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching about content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/admin_privacy_routes.py b/Backend/src/routes/admin_privacy_routes.py index 05da625d..507f74f7 100644 --- a/Backend/src/routes/admin_privacy_routes.py +++ b/Backend/src/routes/admin_privacy_routes.py @@ -1,120 +1,36 @@ from fastapi import APIRouter, Depends, status from sqlalchemy.orm import Session - from ..config.database import get_db from ..middleware.auth import authorize_roles from ..models.user import User -from ..schemas.admin_privacy import ( - CookieIntegrationSettings, - CookieIntegrationSettingsResponse, - CookiePolicySettings, - CookiePolicySettingsResponse, -) +from ..schemas.admin_privacy import CookieIntegrationSettings, CookieIntegrationSettingsResponse, CookiePolicySettings, CookiePolicySettingsResponse from ..services.privacy_admin_service import privacy_admin_service +router = APIRouter(prefix='/admin/privacy', tags=['admin-privacy']) - -router = APIRouter(prefix="/admin/privacy", tags=["admin-privacy"]) - - -@router.get( - "/cookie-policy", - response_model=CookiePolicySettingsResponse, - status_code=status.HTTP_200_OK, -) -def get_cookie_policy( - db: Session = Depends(get_db), - _: User = Depends(authorize_roles("admin")), -) -> CookiePolicySettingsResponse: - """ - Get global cookie policy configuration (admin only). - """ +@router.get('/cookie-policy', response_model=CookiePolicySettingsResponse, status_code=status.HTTP_200_OK) +def get_cookie_policy(db: Session=Depends(get_db), _: User=Depends(authorize_roles('admin'))) -> CookiePolicySettingsResponse: settings = privacy_admin_service.get_policy_settings(db) policy = privacy_admin_service.get_or_create_policy(db) - updated_by_name = ( - policy.updated_by.full_name if getattr(policy, "updated_by", None) else None - ) + updated_by_name = policy.updated_by.full_name if getattr(policy, 'updated_by', None) else None + return CookiePolicySettingsResponse(data=settings, updated_at=policy.updated_at, updated_by=updated_by_name) - return CookiePolicySettingsResponse( - data=settings, - updated_at=policy.updated_at, - updated_by=updated_by_name, - ) - - -@router.put( - "/cookie-policy", - response_model=CookiePolicySettingsResponse, - status_code=status.HTTP_200_OK, -) -def update_cookie_policy( - payload: CookiePolicySettings, - db: Session = Depends(get_db), - current_user: User = Depends(authorize_roles("admin")), -) -> CookiePolicySettingsResponse: - """ - Update global cookie policy configuration (admin only). - """ +@router.put('/cookie-policy', response_model=CookiePolicySettingsResponse, status_code=status.HTTP_200_OK) +def update_cookie_policy(payload: CookiePolicySettings, db: Session=Depends(get_db), current_user: User=Depends(authorize_roles('admin'))) -> CookiePolicySettingsResponse: policy = privacy_admin_service.update_policy(db, payload, current_user) settings = privacy_admin_service.get_policy_settings(db) - updated_by_name = ( - policy.updated_by.full_name if getattr(policy, "updated_by", None) else None - ) + updated_by_name = policy.updated_by.full_name if getattr(policy, 'updated_by', None) else None + return CookiePolicySettingsResponse(data=settings, updated_at=policy.updated_at, updated_by=updated_by_name) - return CookiePolicySettingsResponse( - data=settings, - updated_at=policy.updated_at, - updated_by=updated_by_name, - ) - - -@router.get( - "/integrations", - response_model=CookieIntegrationSettingsResponse, - status_code=status.HTTP_200_OK, -) -def get_cookie_integrations( - db: Session = Depends(get_db), - _: User = Depends(authorize_roles("admin")), -) -> CookieIntegrationSettingsResponse: - """ - Get IDs for third-party integrations (admin only). - """ +@router.get('/integrations', response_model=CookieIntegrationSettingsResponse, status_code=status.HTTP_200_OK) +def get_cookie_integrations(db: Session=Depends(get_db), _: User=Depends(authorize_roles('admin'))) -> CookieIntegrationSettingsResponse: settings = privacy_admin_service.get_integration_settings(db) cfg = privacy_admin_service.get_or_create_integrations(db) - updated_by_name = ( - cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None - ) + updated_by_name = cfg.updated_by.full_name if getattr(cfg, 'updated_by', None) else None + return CookieIntegrationSettingsResponse(data=settings, updated_at=cfg.updated_at, updated_by=updated_by_name) - return CookieIntegrationSettingsResponse( - data=settings, - updated_at=cfg.updated_at, - updated_by=updated_by_name, - ) - - -@router.put( - "/integrations", - response_model=CookieIntegrationSettingsResponse, - status_code=status.HTTP_200_OK, -) -def update_cookie_integrations( - payload: CookieIntegrationSettings, - db: Session = Depends(get_db), - current_user: User = Depends(authorize_roles("admin")), -) -> CookieIntegrationSettingsResponse: - """ - Update IDs for third-party integrations (admin only). - """ +@router.put('/integrations', response_model=CookieIntegrationSettingsResponse, status_code=status.HTTP_200_OK) +def update_cookie_integrations(payload: CookieIntegrationSettings, db: Session=Depends(get_db), current_user: User=Depends(authorize_roles('admin'))) -> CookieIntegrationSettingsResponse: cfg = privacy_admin_service.update_integrations(db, payload, current_user) settings = privacy_admin_service.get_integration_settings(db) - updated_by_name = ( - cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None - ) - - return CookieIntegrationSettingsResponse( - data=settings, - updated_at=cfg.updated_at, - updated_by=updated_by_name, - ) - - + updated_by_name = cfg.updated_by.full_name if getattr(cfg, 'updated_by', None) else None + return CookieIntegrationSettingsResponse(data=settings, updated_at=cfg.updated_at, updated_by=updated_by_name) \ No newline at end of file diff --git a/Backend/src/routes/audit_routes.py b/Backend/src/routes/audit_routes.py index dde2d513..2dba67b2 100644 --- a/Backend/src/routes/audit_routes.py +++ b/Backend/src/routes/audit_routes.py @@ -3,237 +3,91 @@ from sqlalchemy.orm import Session from sqlalchemy import desc, or_, func from typing import Optional from datetime import datetime - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.audit_log import AuditLog +router = APIRouter(prefix='/audit-logs', tags=['audit-logs']) -router = APIRouter(prefix="/audit-logs", tags=["audit-logs"]) - - -@router.get("/") -async def get_audit_logs( - action: Optional[str] = Query(None, description="Filter by action"), - resource_type: Optional[str] = Query(None, description="Filter by resource type"), - user_id: Optional[int] = Query(None, description="Filter by user ID"), - status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"), - search: Optional[str] = Query(None, description="Search in action, resource_type, or details"), - start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), - end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"), - page: int = Query(1, ge=1, description="Page number"), - limit: int = Query(20, ge=1, le=100, description="Items per page"), - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Get audit logs (Admin only)""" +@router.get('/') +async def get_audit_logs(action: Optional[str]=Query(None, description='Filter by action'), resource_type: Optional[str]=Query(None, description='Filter by resource type'), user_id: Optional[int]=Query(None, description='Filter by user ID'), status_filter: Optional[str]=Query(None, alias='status', description='Filter by status'), search: Optional[str]=Query(None, description='Search in action, resource_type, or details'), start_date: Optional[str]=Query(None, description='Start date (YYYY-MM-DD)'), end_date: Optional[str]=Query(None, description='End date (YYYY-MM-DD)'), page: int=Query(1, ge=1, description='Page number'), limit: int=Query(20, ge=1, le=100, description='Items per page'), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: query = db.query(AuditLog) - - # Apply filters if action: - query = query.filter(AuditLog.action.like(f"%{action}%")) - + query = query.filter(AuditLog.action.like(f'%{action}%')) if resource_type: query = query.filter(AuditLog.resource_type == resource_type) - if user_id: query = query.filter(AuditLog.user_id == user_id) - if status_filter: query = query.filter(AuditLog.status == status_filter) - if search: - search_filter = or_( - AuditLog.action.like(f"%{search}%"), - AuditLog.resource_type.like(f"%{search}%"), - AuditLog.ip_address.like(f"%{search}%") - ) + search_filter = or_(AuditLog.action.like(f'%{search}%'), AuditLog.resource_type.like(f'%{search}%'), AuditLog.ip_address.like(f'%{search}%')) query = query.filter(search_filter) - - # Date range filter if start_date: try: - start = datetime.strptime(start_date, "%Y-%m-%d") + start = datetime.strptime(start_date, '%Y-%m-%d') query = query.filter(AuditLog.created_at >= start) except ValueError: pass - if end_date: try: - end = datetime.strptime(end_date, "%Y-%m-%d") - # Set to end of day + end = datetime.strptime(end_date, '%Y-%m-%d') end = end.replace(hour=23, minute=59, second=59) query = query.filter(AuditLog.created_at <= end) except ValueError: pass - - # Get total count total = query.count() - - # Apply pagination and ordering offset = (page - 1) * limit logs = query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all() - - # Format response result = [] for log in logs: - log_dict = { - "id": log.id, - "user_id": log.user_id, - "action": log.action, - "resource_type": log.resource_type, - "resource_id": log.resource_id, - "ip_address": log.ip_address, - "user_agent": log.user_agent, - "request_id": log.request_id, - "details": log.details, - "status": log.status, - "error_message": log.error_message, - "created_at": log.created_at.isoformat() if log.created_at else None, - } - - # Add user info if available + log_dict = {'id': log.id, 'user_id': log.user_id, 'action': log.action, 'resource_type': log.resource_type, 'resource_id': log.resource_id, 'ip_address': log.ip_address, 'user_agent': log.user_agent, 'request_id': log.request_id, 'details': log.details, 'status': log.status, 'error_message': log.error_message, 'created_at': log.created_at.isoformat() if log.created_at else None} if log.user: - log_dict["user"] = { - "id": log.user.id, - "full_name": log.user.full_name, - "email": log.user.email, - } - + log_dict['user'] = {'id': log.user.id, 'full_name': log.user.full_name, 'email': log.user.email} result.append(log_dict) - - return { - "status": "success", - "data": { - "logs": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'logs': 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("/stats") -async def get_audit_stats( - start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"), - end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"), - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Get audit log statistics (Admin only)""" +@router.get('/stats') +async def get_audit_stats(start_date: Optional[str]=Query(None, description='Start date (YYYY-MM-DD)'), end_date: Optional[str]=Query(None, description='End date (YYYY-MM-DD)'), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: query = db.query(AuditLog) - - # Date range filter if start_date: try: - start = datetime.strptime(start_date, "%Y-%m-%d") + start = datetime.strptime(start_date, '%Y-%m-%d') query = query.filter(AuditLog.created_at >= start) except ValueError: pass - if end_date: try: - end = datetime.strptime(end_date, "%Y-%m-%d") + end = datetime.strptime(end_date, '%Y-%m-%d') end = end.replace(hour=23, minute=59, second=59) query = query.filter(AuditLog.created_at <= end) except ValueError: pass - - # Get statistics total_logs = query.count() - success_count = query.filter(AuditLog.status == "success").count() - failed_count = query.filter(AuditLog.status == "failed").count() - error_count = query.filter(AuditLog.status == "error").count() - - # Get top actions - top_actions = ( - db.query( - AuditLog.action, - func.count(AuditLog.id).label("count") - ) - .group_by(AuditLog.action) - .order_by(desc("count")) - .limit(10) - .all() - ) - - # Get top resource types - top_resource_types = ( - db.query( - AuditLog.resource_type, - func.count(AuditLog.id).label("count") - ) - .group_by(AuditLog.resource_type) - .order_by(desc("count")) - .limit(10) - .all() - ) - - return { - "status": "success", - "data": { - "total": total_logs, - "by_status": { - "success": success_count, - "failed": failed_count, - "error": error_count, - }, - "top_actions": [{"action": action, "count": count} for action, count in top_actions], - "top_resource_types": [{"resource_type": rt, "count": count} for rt, count in top_resource_types], - }, - } + success_count = query.filter(AuditLog.status == 'success').count() + failed_count = query.filter(AuditLog.status == 'failed').count() + error_count = query.filter(AuditLog.status == 'error').count() + top_actions = db.query(AuditLog.action, func.count(AuditLog.id).label('count')).group_by(AuditLog.action).order_by(desc('count')).limit(10).all() + top_resource_types = db.query(AuditLog.resource_type, func.count(AuditLog.id).label('count')).group_by(AuditLog.resource_type).order_by(desc('count')).limit(10).all() + return {'status': 'success', 'data': {'total': total_logs, 'by_status': {'success': success_count, 'failed': failed_count, 'error': error_count}, 'top_actions': [{'action': action, 'count': count} for action, count in top_actions], 'top_resource_types': [{'resource_type': rt, 'count': count} for rt, count in top_resource_types]}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}") -async def get_audit_log_by_id( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Get audit log by ID (Admin only)""" +@router.get('/{id}') +async def get_audit_log_by_id(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: log = db.query(AuditLog).filter(AuditLog.id == id).first() - if not log: - raise HTTPException(status_code=404, detail="Audit log not found") - - log_dict = { - "id": log.id, - "user_id": log.user_id, - "action": log.action, - "resource_type": log.resource_type, - "resource_id": log.resource_id, - "ip_address": log.ip_address, - "user_agent": log.user_agent, - "request_id": log.request_id, - "details": log.details, - "status": log.status, - "error_message": log.error_message, - "created_at": log.created_at.isoformat() if log.created_at else None, - } - + raise HTTPException(status_code=404, detail='Audit log not found') + log_dict = {'id': log.id, 'user_id': log.user_id, 'action': log.action, 'resource_type': log.resource_type, 'resource_id': log.resource_id, 'ip_address': log.ip_address, 'user_agent': log.user_agent, 'request_id': log.request_id, 'details': log.details, 'status': log.status, 'error_message': log.error_message, 'created_at': log.created_at.isoformat() if log.created_at else None} if log.user: - log_dict["user"] = { - "id": log.user.id, - "full_name": log.user.full_name, - "email": log.user.email, - } - - return { - "status": "success", - "data": {"log": log_dict} - } + log_dict['user'] = {'id': log.user.id, 'full_name': log.user.full_name, 'email': log.user.email} + return {'status': 'success', 'data': {'log': log_dict}} except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/auth_routes.py b/Backend/src/routes/auth_routes.py index 4dfddad4..0e8ba0a0 100644 --- a/Backend/src/routes/auth_routes.py +++ b/Backend/src/routes/auth_routes.py @@ -5,518 +5,193 @@ from pathlib import Path import aiofiles import uuid import os - from ..config.database import get_db from ..services.auth_service import auth_service -from ..schemas.auth import ( - RegisterRequest, - LoginRequest, - RefreshTokenRequest, - ForgotPasswordRequest, - ResetPasswordRequest, - AuthResponse, - TokenResponse, - MessageResponse, - MFAInitResponse, - EnableMFARequest, - VerifyMFARequest, - MFAStatusResponse -) +from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse from ..middleware.auth import get_current_user from ..models.user import User - -router = APIRouter(prefix="/auth", tags=["auth"]) - +router = APIRouter(prefix='/auth', tags=['auth']) def get_base_url(request: Request) -> str: - """Get base URL for image normalization""" - return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:8000')}" - + return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}' def normalize_image_url(image_url: str, base_url: str) -> str: - """Normalize image URL to absolute URL""" if not image_url: return image_url if image_url.startswith('http://') or image_url.startswith('https://'): return image_url if image_url.startswith('/'): - return f"{base_url}{image_url}" - return f"{base_url}/{image_url}" + return f'{base_url}{image_url}' + return f'{base_url}/{image_url}' - -@router.post("/register", status_code=status.HTTP_201_CREATED) -async def register( - request: RegisterRequest, - response: Response, - db: Session = Depends(get_db) -): - """Register new user""" +@router.post('/register', status_code=status.HTTP_201_CREATED) +async def register(request: RegisterRequest, response: Response, db: Session=Depends(get_db)): try: - result = await auth_service.register( - db=db, - name=request.name, - email=request.email, - password=request.password, - phone=request.phone - ) - - # Set refresh token as HttpOnly cookie - response.set_cookie( - key="refreshToken", - value=result["refreshToken"], - httponly=True, - secure=False, # Set to True in production with HTTPS - samesite="strict", - max_age=7 * 24 * 60 * 60, # 7 days - path="/" - ) - - # Format response to match frontend expectations - return { - "status": "success", - "message": "Registration successful", - "data": { - "token": result["token"], - "user": result["user"] - } - } + result = await auth_service.register(db=db, name=request.name, email=request.email, password=request.password, phone=request.phone) + response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=7 * 24 * 60 * 60, path='/') + return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}} except ValueError as e: error_message = str(e) - return JSONResponse( - status_code=status.HTTP_400_BAD_REQUEST, - content={ - "status": "error", - "message": error_message - } - ) + return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': error_message}) - -@router.post("/login") -async def login( - request: LoginRequest, - response: Response, - db: Session = Depends(get_db) -): - """Login user""" +@router.post('/login') +async def login(request: LoginRequest, response: Response, db: Session=Depends(get_db)): try: - result = await auth_service.login( - db=db, - email=request.email, - password=request.password, - remember_me=request.rememberMe or False, - mfa_token=request.mfaToken - ) - - # Check if MFA is required - if result.get("requires_mfa"): - return { - "status": "success", - "requires_mfa": True, - "user_id": result["user_id"] - } - - # Set refresh token as HttpOnly cookie + result = await auth_service.login(db=db, email=request.email, password=request.password, remember_me=request.rememberMe or False, mfa_token=request.mfaToken) + if result.get('requires_mfa'): + return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']} max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60 - response.set_cookie( - key="refreshToken", - value=result["refreshToken"], - httponly=True, - secure=False, # Set to True in production with HTTPS - samesite="strict", - max_age=max_age, - path="/" - ) - - # Format response to match frontend expectations - return { - "status": "success", - "data": { - "token": result["token"], - "user": result["user"] - } - } + response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=max_age, path='/') + return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}} except ValueError as e: error_message = str(e) - status_code = status.HTTP_401_UNAUTHORIZED if "Invalid email or password" in error_message or "Invalid MFA token" in error_message else status.HTTP_400_BAD_REQUEST - return JSONResponse( - status_code=status_code, - content={ - "status": "error", - "message": error_message - } - ) + status_code = status.HTTP_401_UNAUTHORIZED if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message else status.HTTP_400_BAD_REQUEST + return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message}) - -@router.post("/refresh-token", response_model=TokenResponse) -async def refresh_token( - refreshToken: str = Cookie(None), - db: Session = Depends(get_db) -): - """Refresh access token""" +@router.post('/refresh-token', response_model=TokenResponse) +async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_db)): if not refreshToken: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Refresh token not found" - ) - + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Refresh token not found') try: result = await auth_service.refresh_access_token(db, refreshToken) return result except ValueError as e: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e)) - -@router.post("/logout", response_model=MessageResponse) -async def logout( - response: Response, - refreshToken: str = Cookie(None), - db: Session = Depends(get_db) -): - """Logout user""" +@router.post('/logout', response_model=MessageResponse) +async def logout(response: Response, refreshToken: str=Cookie(None), db: Session=Depends(get_db)): if refreshToken: await auth_service.logout(db, refreshToken) + response.delete_cookie(key='refreshToken', path='/') + return {'status': 'success', 'message': 'Logout successful'} - # Clear refresh token cookie - response.delete_cookie(key="refreshToken", path="/") - - return { - "status": "success", - "message": "Logout successful" - } - - -@router.get("/profile") -async def get_profile( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get current user profile""" +@router.get('/profile') +async def get_profile(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: user = await auth_service.get_profile(db, current_user.id) - return { - "status": "success", - "data": { - "user": user - } - } + return {'status': 'success', 'data': {'user': user}} except ValueError as e: - if "User not found" in str(e): - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) + if 'User not found' in str(e): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - -@router.put("/profile") -async def update_profile( - profile_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Update current user profile""" +@router.put('/profile') +async def update_profile(profile_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - user = await auth_service.update_profile( - db=db, - user_id=current_user.id, - full_name=profile_data.get("full_name"), - email=profile_data.get("email"), - phone_number=profile_data.get("phone_number"), - password=profile_data.get("password"), - current_password=profile_data.get("currentPassword"), - currency=profile_data.get("currency") - ) - return { - "status": "success", - "message": "Profile updated successfully", - "data": { - "user": user - } - } + user = await auth_service.update_profile(db=db, user_id=current_user.id, full_name=profile_data.get('full_name'), email=profile_data.get('email'), phone_number=profile_data.get('phone_number'), password=profile_data.get('password'), current_password=profile_data.get('currentPassword'), currency=profile_data.get('currency')) + return {'status': 'success', 'message': 'Profile updated successfully', 'data': {'user': user}} except ValueError as e: error_message = str(e) status_code = status.HTTP_400_BAD_REQUEST - if "not found" in error_message.lower(): + if 'not found' in error_message.lower(): status_code = status.HTTP_404_NOT_FOUND - raise HTTPException( - status_code=status_code, - detail=error_message - ) + raise HTTPException(status_code=status_code, detail=error_message) except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"An error occurred: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'An error occurred: {str(e)}') - -@router.post("/forgot-password", response_model=MessageResponse) -async def forgot_password( - request: ForgotPasswordRequest, - db: Session = Depends(get_db) -): - """Send password reset link""" +@router.post('/forgot-password', response_model=MessageResponse) +async def forgot_password(request: ForgotPasswordRequest, db: Session=Depends(get_db)): result = await auth_service.forgot_password(db, request.email) - return { - "status": "success", - "message": result["message"] - } + return {'status': 'success', 'message': result['message']} - -@router.post("/reset-password", response_model=MessageResponse) -async def reset_password( - request: ResetPasswordRequest, - db: Session = Depends(get_db) -): - """Reset password with token""" +@router.post('/reset-password', response_model=MessageResponse) +async def reset_password(request: ResetPasswordRequest, db: Session=Depends(get_db)): try: - result = await auth_service.reset_password( - db=db, - token=request.token, - password=request.password - ) - return { - "status": "success", - "message": result["message"] - } + result = await auth_service.reset_password(db=db, token=request.token, password=request.password) + return {'status': 'success', 'message': result['message']} except ValueError as e: status_code = status.HTTP_400_BAD_REQUEST - if "User not found" in str(e): + if 'User not found' in str(e): status_code = status.HTTP_404_NOT_FOUND - raise HTTPException( - status_code=status_code, - detail=str(e) - ) - - -# MFA Routes + raise HTTPException(status_code=status_code, detail=str(e)) from ..services.mfa_service import mfa_service from ..config.settings import settings - -@router.get("/mfa/init") -async def init_mfa( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Initialize MFA setup - generate secret and QR code""" +@router.get('/mfa/init') +async def init_mfa(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: if current_user.mfa_enabled: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="MFA is already enabled" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='MFA is already enabled') secret = mfa_service.generate_secret() app_name = getattr(settings, 'APP_NAME', 'Hotel Booking') qr_code = mfa_service.generate_qr_code(secret, current_user.email, app_name) - - return { - "status": "success", - "data": { - "secret": secret, - "qr_code": qr_code - } - } + return {'status': 'success', 'data': {'secret': secret, 'qr_code': qr_code}} except HTTPException: raise except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error initializing MFA: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error initializing MFA: {str(e)}') - -@router.post("/mfa/enable") -async def enable_mfa( - request: EnableMFARequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Enable MFA after verifying token""" +@router.post('/mfa/enable') +async def enable_mfa(request: EnableMFARequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - success, backup_codes = mfa_service.enable_mfa( - db=db, - user_id=current_user.id, - secret=request.secret, - verification_token=request.verification_token - ) - - return { - "status": "success", - "message": "MFA enabled successfully", - "data": { - "backup_codes": backup_codes - } - } + success, backup_codes = mfa_service.enable_mfa(db=db, user_id=current_user.id, secret=request.secret, verification_token=request.verification_token) + return {'status': 'success', 'message': 'MFA enabled successfully', 'data': {'backup_codes': backup_codes}} except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error enabling MFA: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error enabling MFA: {str(e)}') - -@router.post("/mfa/disable") -async def disable_mfa( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Disable MFA""" +@router.post('/mfa/disable') +async def disable_mfa(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: mfa_service.disable_mfa(db=db, user_id=current_user.id) - return { - "status": "success", - "message": "MFA disabled successfully" - } + return {'status': 'success', 'message': 'MFA disabled successfully'} except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error disabling MFA: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error disabling MFA: {str(e)}') - -@router.get("/mfa/status", response_model=MFAStatusResponse) -async def get_mfa_status( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get MFA status for current user""" +@router.get('/mfa/status', response_model=MFAStatusResponse) +async def get_mfa_status(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: status_data = mfa_service.get_mfa_status(db=db, user_id=current_user.id) return status_data except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error getting MFA status: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error getting MFA status: {str(e)}') - -@router.post("/mfa/regenerate-backup-codes") -async def regenerate_backup_codes( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Regenerate backup codes for MFA""" +@router.post('/mfa/regenerate-backup-codes') +async def regenerate_backup_codes(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: backup_codes = mfa_service.regenerate_backup_codes(db=db, user_id=current_user.id) - return { - "status": "success", - "message": "Backup codes regenerated successfully", - "data": { - "backup_codes": backup_codes - } - } + return {'status': 'success', 'message': 'Backup codes regenerated successfully', 'data': {'backup_codes': backup_codes}} except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error regenerating backup codes: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error regenerating backup codes: {str(e)}') - -@router.post("/avatar/upload") -async def upload_avatar( - request: Request, - image: UploadFile = File(...), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Upload user avatar""" +@router.post('/avatar/upload') +async def upload_avatar(request: Request, image: UploadFile=File(...), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Validate file type if not image.content_type or not image.content_type.startswith('image/'): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="File must be an image" - ) - - # Validate file size (max 2MB) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File must be an image') content = await image.read() - if len(content) > 2 * 1024 * 1024: # 2MB - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Avatar file size must be less than 2MB" - ) - - # Create uploads directory - upload_dir = Path(__file__).parent.parent.parent / "uploads" / "avatars" + if len(content) > 2 * 1024 * 1024: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Avatar file size must be less than 2MB') + upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars' upload_dir.mkdir(parents=True, exist_ok=True) - - # Delete old avatar if exists if current_user.avatar: old_avatar_path = Path(__file__).parent.parent.parent / current_user.avatar.lstrip('/') if old_avatar_path.exists() and old_avatar_path.is_file(): try: old_avatar_path.unlink() except Exception: - pass # Ignore deletion errors - - # Generate filename + pass ext = Path(image.filename).suffix or '.png' - filename = f"avatar-{current_user.id}-{uuid.uuid4()}{ext}" + filename = f'avatar-{current_user.id}-{uuid.uuid4()}{ext}' file_path = upload_dir / filename - - # Save file async with aiofiles.open(file_path, 'wb') as f: await f.write(content) - - # Update user avatar - image_url = f"/uploads/avatars/{filename}" + image_url = f'/uploads/avatars/{filename}' current_user.avatar = image_url db.commit() db.refresh(current_user) - - # Return the image URL base_url = get_base_url(request) full_url = normalize_image_url(image_url, base_url) - - return { - "success": True, - "status": "success", - "message": "Avatar uploaded successfully", - "data": { - "avatar_url": image_url, - "full_url": full_url, - "user": { - "id": current_user.id, - "name": current_user.full_name, - "email": current_user.email, - "phone": current_user.phone, - "avatar": image_url, - "role": current_user.role.name if current_user.role else "customer" - } - } - } + return {'success': True, 'status': 'success', 'message': 'Avatar uploaded successfully', 'data': {'avatar_url': image_url, 'full_url': full_url, 'user': {'id': current_user.id, 'name': current_user.full_name, 'email': current_user.email, 'phone': current_user.phone, 'avatar': image_url, 'role': current_user.role.name if current_user.role else 'customer'}}} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error uploading avatar: {str(e)}" - ) - + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error uploading avatar: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/banner_routes.py b/Backend/src/routes/banner_routes.py index 45c4e906..400940e2 100644 --- a/Backend/src/routes/banner_routes.py +++ b/Backend/src/routes/banner_routes.py @@ -7,300 +7,144 @@ from pathlib import Path import os import aiofiles import uuid - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.banner import Banner - -router = APIRouter(prefix="/banners", tags=["banners"]) - +router = APIRouter(prefix='/banners', tags=['banners']) def normalize_image_url(image_url: str, base_url: str) -> str: - """Normalize image URL to absolute URL""" if not image_url: return image_url if image_url.startswith('http://') or image_url.startswith('https://'): return image_url if image_url.startswith('/'): - return f"{base_url}{image_url}" - return f"{base_url}/{image_url}" - + return f'{base_url}{image_url}' + return f'{base_url}/{image_url}' def get_base_url(request: Request) -> str: - """Get base URL for image normalization""" - return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:3000')}" + return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:3000')}' - -@router.get("/") -async def get_banners( - request: Request, - position: Optional[str] = Query(None), - db: Session = Depends(get_db) -): - """Get all active banners""" +@router.get('/') +async def get_banners(request: Request, position: Optional[str]=Query(None), db: Session=Depends(get_db)): try: query = db.query(Banner).filter(Banner.is_active == True) - - # Filter by position if position: query = query.filter(Banner.position == position) - - # Filter by date range now = datetime.utcnow() - query = query.filter( - or_( - Banner.start_date == None, - Banner.start_date <= now - ) - ).filter( - or_( - Banner.end_date == None, - Banner.end_date >= now - ) - ) - + query = query.filter(or_(Banner.start_date == None, Banner.start_date <= now)).filter(or_(Banner.end_date == None, Banner.end_date >= now)) banners = query.order_by(Banner.display_order.asc(), Banner.created_at.desc()).all() - base_url = get_base_url(request) result = [] for banner in banners: - banner_dict = { - "id": banner.id, - "title": banner.title, - "description": banner.description, - "image_url": normalize_image_url(banner.image_url, base_url), - "link_url": banner.link_url, - "position": banner.position, - "display_order": banner.display_order, - "is_active": banner.is_active, - "start_date": banner.start_date.isoformat() if banner.start_date else None, - "end_date": banner.end_date.isoformat() if banner.end_date else None, - "created_at": banner.created_at.isoformat() if banner.created_at else None, - } + banner_dict = {'id': banner.id, 'title': banner.title, 'description': banner.description, 'image_url': normalize_image_url(banner.image_url, base_url), 'link_url': banner.link_url, 'position': banner.position, 'display_order': banner.display_order, 'is_active': banner.is_active, 'start_date': banner.start_date.isoformat() if banner.start_date else None, 'end_date': banner.end_date.isoformat() if banner.end_date else None, 'created_at': banner.created_at.isoformat() if banner.created_at else None} result.append(banner_dict) - - return { - "status": "success", - "data": {"banners": result} - } + return {'status': 'success', 'data': {'banners': result}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}") -async def get_banner_by_id( - id: int, - request: Request, - db: Session = Depends(get_db) -): - """Get banner by ID""" +@router.get('/{id}') +async def get_banner_by_id(id: int, request: Request, db: Session=Depends(get_db)): try: banner = db.query(Banner).filter(Banner.id == id).first() if not banner: - raise HTTPException(status_code=404, detail="Banner not found") - + raise HTTPException(status_code=404, detail='Banner not found') base_url = get_base_url(request) - banner_dict = { - "id": banner.id, - "title": banner.title, - "description": banner.description, - "image_url": normalize_image_url(banner.image_url, base_url), - "link_url": banner.link_url, - "position": banner.position, - "display_order": banner.display_order, - "is_active": banner.is_active, - "start_date": banner.start_date.isoformat() if banner.start_date else None, - "end_date": banner.end_date.isoformat() if banner.end_date else None, - "created_at": banner.created_at.isoformat() if banner.created_at else None, - } - - return { - "status": "success", - "data": {"banner": banner_dict} - } + banner_dict = {'id': banner.id, 'title': banner.title, 'description': banner.description, 'image_url': normalize_image_url(banner.image_url, base_url), 'link_url': banner.link_url, 'position': banner.position, 'display_order': banner.display_order, 'is_active': banner.is_active, 'start_date': banner.start_date.isoformat() if banner.start_date else None, 'end_date': banner.end_date.isoformat() if banner.end_date else None, 'created_at': banner.created_at.isoformat() if banner.created_at else None} + return {'status': 'success', 'data': {'banner': banner_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_banner( - banner_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Create new banner (Admin only)""" +@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) +async def create_banner(banner_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - banner = Banner( - title=banner_data.get("title"), - description=banner_data.get("description"), - image_url=banner_data.get("image_url"), - link_url=banner_data.get("link"), - position=banner_data.get("position", "home"), - display_order=banner_data.get("display_order", 0), - is_active=True, - start_date=datetime.fromisoformat(banner_data["start_date"].replace('Z', '+00:00')) if banner_data.get("start_date") else None, - end_date=datetime.fromisoformat(banner_data["end_date"].replace('Z', '+00:00')) if banner_data.get("end_date") else None, - ) - + banner = Banner(title=banner_data.get('title'), description=banner_data.get('description'), image_url=banner_data.get('image_url'), link_url=banner_data.get('link'), position=banner_data.get('position', 'home'), display_order=banner_data.get('display_order', 0), is_active=True, start_date=datetime.fromisoformat(banner_data['start_date'].replace('Z', '+00:00')) if banner_data.get('start_date') else None, end_date=datetime.fromisoformat(banner_data['end_date'].replace('Z', '+00:00')) if banner_data.get('end_date') else None) db.add(banner) db.commit() db.refresh(banner) - - return { - "status": "success", - "message": "Banner created successfully", - "data": {"banner": banner} - } + return {'status': 'success', 'message': 'Banner created successfully', 'data': {'banner': banner}} 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_banner( - id: int, - banner_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Update banner (Admin only)""" +@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def update_banner(id: int, banner_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: banner = db.query(Banner).filter(Banner.id == id).first() if not banner: - raise HTTPException(status_code=404, detail="Banner not found") - - if "title" in banner_data: - banner.title = banner_data["title"] - if "description" in banner_data: - banner.description = banner_data["description"] - if "image_url" in banner_data: - banner.image_url = banner_data["image_url"] - if "link" in banner_data: - banner.link_url = banner_data["link"] - if "position" in banner_data: - banner.position = banner_data["position"] - if "display_order" in banner_data: - banner.display_order = banner_data["display_order"] - if "is_active" in banner_data: - banner.is_active = banner_data["is_active"] - if "start_date" in banner_data: - banner.start_date = datetime.fromisoformat(banner_data["start_date"].replace('Z', '+00:00')) if banner_data["start_date"] else None - if "end_date" in banner_data: - banner.end_date = datetime.fromisoformat(banner_data["end_date"].replace('Z', '+00:00')) if banner_data["end_date"] else None - + raise HTTPException(status_code=404, detail='Banner not found') + if 'title' in banner_data: + banner.title = banner_data['title'] + if 'description' in banner_data: + banner.description = banner_data['description'] + if 'image_url' in banner_data: + banner.image_url = banner_data['image_url'] + if 'link' in banner_data: + banner.link_url = banner_data['link'] + if 'position' in banner_data: + banner.position = banner_data['position'] + if 'display_order' in banner_data: + banner.display_order = banner_data['display_order'] + if 'is_active' in banner_data: + banner.is_active = banner_data['is_active'] + if 'start_date' in banner_data: + banner.start_date = datetime.fromisoformat(banner_data['start_date'].replace('Z', '+00:00')) if banner_data['start_date'] else None + if 'end_date' in banner_data: + banner.end_date = datetime.fromisoformat(banner_data['end_date'].replace('Z', '+00:00')) if banner_data['end_date'] else None db.commit() db.refresh(banner) - - return { - "status": "success", - "message": "Banner updated successfully", - "data": {"banner": banner} - } + return {'status': 'success', 'message': 'Banner updated successfully', 'data': {'banner': banner}} except HTTPException: raise 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_banner( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete banner (Admin only)""" +@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_banner(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: banner = db.query(Banner).filter(Banner.id == id).first() if not banner: - raise HTTPException(status_code=404, detail="Banner not found") - - # Delete image file if it exists and is a local upload + raise HTTPException(status_code=404, detail='Banner not found') if banner.image_url and banner.image_url.startswith('/uploads/banners/'): - file_path = Path(__file__).parent.parent.parent / "uploads" / "banners" / Path(banner.image_url).name + file_path = Path(__file__).parent.parent.parent / 'uploads' / 'banners' / Path(banner.image_url).name if file_path.exists(): file_path.unlink() - db.delete(banner) db.commit() - - return { - "status": "success", - "message": "Banner deleted successfully" - } + return {'status': 'success', 'message': 'Banner deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))]) -async def upload_banner_image( - request: Request, - image: UploadFile = File(...), - current_user: User = Depends(authorize_roles("admin")), -): - """Upload banner image (Admin only)""" +@router.post('/upload', dependencies=[Depends(authorize_roles('admin'))]) +async def upload_banner_image(request: Request, image: UploadFile=File(...), current_user: User=Depends(authorize_roles('admin'))): try: - # Validate file exists if not image: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No file provided" - ) - - # Validate file type + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No file provided') if not image.content_type or not image.content_type.startswith('image/'): - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File must be an image. Received: {image.content_type}" - ) - - # Validate filename + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'File must be an image. Received: {image.content_type}') if not image.filename: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Filename is required" - ) - - # Create uploads directory - upload_dir = Path(__file__).parent.parent.parent / "uploads" / "banners" + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required') + upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'banners' upload_dir.mkdir(parents=True, exist_ok=True) - - # Generate filename ext = Path(image.filename).suffix or '.jpg' - filename = f"banner-{uuid.uuid4()}{ext}" + filename = f'banner-{uuid.uuid4()}{ext}' file_path = upload_dir / filename - - # Save file async with aiofiles.open(file_path, 'wb') as f: content = await image.read() if not content: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="File is empty" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty') await f.write(content) - - # Return the image URL - image_url = f"/uploads/banners/{filename}" + image_url = f'/uploads/banners/{filename}' base_url = get_base_url(request) full_url = normalize_image_url(image_url, base_url) - - return { - "success": True, - "status": "success", - "message": "Image uploaded successfully", - "data": { - "image_url": image_url, - "full_url": full_url - } - } + return {'success': True, 'status': 'success', 'message': 'Image uploaded successfully', 'data': {'image_url': image_url, 'full_url': full_url}} except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/booking_routes.py b/Backend/src/routes/booking_routes.py index 24e94d6f..ebdde817 100644 --- a/Backend/src/routes/booking_routes.py +++ b/Backend/src/routes/booking_routes.py @@ -5,7 +5,6 @@ from typing import Optional from datetime import datetime import random import os - from ..config.database import get_db from ..config.settings import settings from ..middleware.auth import get_current_user, authorize_roles @@ -18,233 +17,52 @@ from ..models.service_usage import ServiceUsage from ..services.room_service import normalize_images, get_base_url from fastapi import Request from ..utils.mailer import send_email -from ..utils.email_templates import ( - booking_confirmation_email_template, - booking_status_changed_email_template -) - -router = APIRouter(prefix="/bookings", tags=["bookings"]) - - -def _generate_invoice_email_html(invoice: dict, is_proforma: bool = False) -> str: - """Generate HTML email content for invoice""" - invoice_type = "Proforma Invoice" if is_proforma else "Invoice" - items_html = ''.join([f''' - - {item.get('description', 'N/A')} - {item.get('quantity', 0)} - {item.get('unit_price', 0):.2f} - {item.get('line_total', 0):.2f} - - ''' for item in invoice.get('items', [])]) - - return f""" - - - - - - - -
-
- {f'' if invoice.get('company_logo_url') else ''} - {f'

{invoice.get("company_name", "")}

' if invoice.get('company_name') else ''} -

{invoice_type}

-
-
-
-

{invoice_type} #{invoice.get('invoice_number', 'N/A')}

-

Issue Date: {invoice.get('issue_date', 'N/A')}

-

Due Date: {invoice.get('due_date', 'N/A')}

-

Status: {invoice.get('status', 'N/A')}

-
- -
-

Items

- - - - - - - - - - - {items_html} - -
DescriptionQuantityUnit PriceTotal
-
- -
-

Subtotal: {invoice.get('subtotal', 0):.2f}

- {f'

Discount: -{invoice.get("discount_amount", 0):.2f}

' if invoice.get('discount_amount', 0) > 0 else ''} - {f'

Promotion Code: {invoice.get("promotion_code", "")}

' if invoice.get('promotion_code') else ''} -

Tax: {invoice.get('tax_amount', 0):.2f}

-

Total Amount: {invoice.get('total_amount', 0):.2f}

-

Amount Paid: {invoice.get('amount_paid', 0):.2f}

-

Balance Due: {invoice.get('balance_due', 0):.2f}

-
-
- -
- - - """ +from ..utils.email_templates import booking_confirmation_email_template, booking_status_changed_email_template +router = APIRouter(prefix='/bookings', tags=['bookings']) +def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str: + invoice_type = 'Proforma Invoice' if is_proforma else 'Invoice' + items_html = ''.join([f for item in invoice.get('items', [])]) + return f def generate_booking_number() -> str: - """Generate unique booking number""" - prefix = "BK" + prefix = 'BK' ts = int(datetime.utcnow().timestamp() * 1000) rand = random.randint(1000, 9999) - return f"{prefix}-{ts}-{rand}" - + return f'{prefix}-{ts}-{rand}' def calculate_booking_payment_balance(booking: Booking) -> dict: - """Calculate total paid amount and remaining balance for a booking""" total_paid = 0.0 if booking.payments: - # Sum all completed payments - total_paid = sum( - float(payment.amount) if payment.amount else 0.0 - for payment in booking.payments - if payment.payment_status == PaymentStatus.completed - ) - + total_paid = sum((float(payment.amount) if payment.amount else 0.0 for payment in booking.payments if payment.payment_status == PaymentStatus.completed)) total_price = float(booking.total_price) if booking.total_price else 0.0 remaining_balance = total_price - total_paid - - return { - "total_paid": total_paid, - "total_price": total_price, - "remaining_balance": remaining_balance, - "is_fully_paid": remaining_balance <= 0.01, # Allow small floating point differences - "payment_percentage": (total_paid / total_price * 100) if total_price > 0 else 0 - } + return {'total_paid': total_paid, 'total_price': total_price, 'remaining_balance': remaining_balance, 'is_fully_paid': remaining_balance <= 0.01, 'payment_percentage': total_paid / total_price * 100 if total_price > 0 else 0} - -@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) -): - """Get all bookings (Admin/Staff only)""" +@router.get('/') +async def get_all_bookings(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), startDate: Optional[str]=Query(None), endDate: Optional[str]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: - query = db.query(Booking).options( - selectinload(Booking.payments), - joinedload(Booking.user), - joinedload(Booking.room).joinedload(Room.room_type) - ) - - # Filter by search (booking_number) + query = db.query(Booking).options(selectinload(Booking.payments), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type)) if search: - query = query.filter(Booking.booking_number.like(f"%{search}%")) - - # Filter by status + query = query.filter(Booking.booking_number.like(f'%{search}%')) if status_filter: try: query = query.filter(Booking.status == BookingStatus(status_filter)) except ValueError: pass - - # Filter by date range if startDate: start = datetime.fromisoformat(startDate.replace('Z', '+00:00')) query = query.filter(Booking.check_in_date >= start) - if endDate: end = datetime.fromisoformat(endDate.replace('Z', '+00:00')) query = query.filter(Booking.check_in_date <= end) - - # Get total count total = query.count() - - # Apply pagination offset = (page - 1) * limit bookings = query.order_by(Booking.created_at.desc()).offset(offset).limit(limit).all() - - # Include related data result = [] for booking in bookings: - # Determine payment_method and payment_status from payments payment_method_from_payments = None - payment_status_from_payments = "unpaid" + payment_status_from_payments = 'unpaid' if booking.payments: latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) if isinstance(latest_payment.payment_method, PaymentMethod): @@ -253,132 +71,45 @@ async def get_all_bookings( payment_method_from_payments = latest_payment.payment_method.value else: payment_method_from_payments = str(latest_payment.payment_method) - if latest_payment.payment_status == PaymentStatus.completed: - payment_status_from_payments = "paid" + payment_status_from_payments = 'paid' elif latest_payment.payment_status == PaymentStatus.refunded: - payment_status_from_payments = "refunded" - - booking_dict = { - "id": booking.id, - "booking_number": booking.booking_number, - "user_id": booking.user_id, - "room_id": booking.room_id, - "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, - "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, - "num_guests": booking.num_guests, - "guest_count": booking.num_guests, # Frontend expects guest_count - "total_price": float(booking.total_price) if booking.total_price else 0.0, - "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, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "payment_method": payment_method_from_payments if payment_method_from_payments else "cash", - "payment_status": payment_status_from_payments, - "deposit_paid": booking.deposit_paid, - "requires_deposit": booking.requires_deposit, - "special_requests": booking.special_requests, - "notes": booking.special_requests, # Frontend expects notes - "created_at": booking.created_at.isoformat() if booking.created_at else None, - "createdAt": booking.created_at.isoformat() if booking.created_at else None, - "updated_at": booking.updated_at.isoformat() if booking.updated_at else None, - "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, - } - - # Add user info + payment_status_from_payments = 'refunded' + booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'num_guests': booking.num_guests, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, '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, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': payment_method_from_payments if payment_method_from_payments else 'cash', 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'special_requests': booking.special_requests, 'notes': booking.special_requests, 'created_at': booking.created_at.isoformat() if booking.created_at else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updated_at': booking.updated_at.isoformat() if booking.updated_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None} if booking.user: - booking_dict["user"] = { - "id": booking.user.id, - "name": booking.user.full_name, - "full_name": booking.user.full_name, - "email": booking.user.email, - "phone": booking.user.phone, - "phone_number": booking.user.phone, - } - - # Add room info + booking_dict['user'] = {'id': booking.user.id, 'name': booking.user.full_name, 'full_name': booking.user.full_name, 'email': booking.user.email, 'phone': booking.user.phone, 'phone_number': booking.user.phone} if booking.room: - booking_dict["room"] = { - "id": booking.room.id, - "room_number": booking.room.room_number, - "floor": booking.room.floor, - } - # Safely access room_type - it should be loaded via joinedload + booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor} try: if hasattr(booking.room, 'room_type') and 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} except Exception as room_type_error: import logging logger = logging.getLogger(__name__) - logger.warning(f"Could not load room_type for booking {booking.id}: {room_type_error}") - - # Add payments + logger.warning(f'Could not load room_type for booking {booking.id}: {room_type_error}') if booking.payments: - booking_dict["payments"] = [ - { - "id": p.id, - "amount": float(p.amount) if p.amount else 0.0, - "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), - "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), - "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, - "transaction_id": p.transaction_id, - "payment_date": p.payment_date.isoformat() if p.payment_date else None, - "created_at": p.created_at.isoformat() if p.created_at else None, - } - for p in booking.payments - ] + booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments] else: - booking_dict["payments"] = [] - + booking_dict['payments'] = [] result.append(booking_dict) - - return { - "status": "success", - "data": { - "bookings": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'bookings': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: import logging import traceback logger = logging.getLogger(__name__) - logger.error(f"Error in get_all_bookings: {str(e)}") + logger.error(f'Error in get_all_bookings: {str(e)}') logger.error(traceback.format_exc()) raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/me") -async def get_my_bookings( - request: Request, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get current user's bookings""" +@router.get('/me') +async def get_my_bookings(request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - bookings = db.query(Booking).options( - selectinload(Booking.payments), - joinedload(Booking.room).joinedload(Room.room_type) - ).filter( - Booking.user_id == current_user.id - ).order_by(Booking.created_at.desc()).all() - + bookings = db.query(Booking).options(selectinload(Booking.payments), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.user_id == current_user.id).order_by(Booking.created_at.desc()).all() base_url = get_base_url(request) result = [] for booking in bookings: - # Determine payment_method and payment_status from payments payment_method_from_payments = None - payment_status_from_payments = "unpaid" + payment_status_from_payments = 'unpaid' if booking.payments: latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) if isinstance(latest_payment.payment_method, PaymentMethod): @@ -387,948 +118,428 @@ async def get_my_bookings( payment_method_from_payments = latest_payment.payment_method.value else: payment_method_from_payments = str(latest_payment.payment_method) - if latest_payment.payment_status == PaymentStatus.completed: - payment_status_from_payments = "paid" + payment_status_from_payments = 'paid' elif latest_payment.payment_status == PaymentStatus.refunded: - payment_status_from_payments = "refunded" - - booking_dict = { - "id": booking.id, - "booking_number": booking.booking_number, - "room_id": booking.room_id, - "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, - "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, - "num_guests": booking.num_guests, - "guest_count": booking.num_guests, - "total_price": float(booking.total_price) if booking.total_price else 0.0, - "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, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "payment_method": payment_method_from_payments if payment_method_from_payments else "cash", - "payment_status": payment_status_from_payments, - "deposit_paid": booking.deposit_paid, - "requires_deposit": booking.requires_deposit, - "special_requests": booking.special_requests, - "notes": booking.special_requests, - "created_at": booking.created_at.isoformat() if booking.created_at else None, - "createdAt": booking.created_at.isoformat() if booking.created_at else None, - "updated_at": booking.updated_at.isoformat() if booking.updated_at else None, - "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, - } - - # Add room info + payment_status_from_payments = 'refunded' + booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'num_guests': booking.num_guests, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, '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, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': payment_method_from_payments if payment_method_from_payments else 'cash', 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'special_requests': booking.special_requests, 'notes': booking.special_requests, 'created_at': booking.created_at.isoformat() if booking.created_at else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updated_at': booking.updated_at.isoformat() if booking.updated_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None} if booking.room and booking.room.room_type: - # Normalize room images if they exist room_images = [] if booking.room.images: try: room_images = normalize_images(booking.room.images, base_url) except: room_images = [] - - booking_dict["room"] = { - "id": booking.room.id, - "room_number": booking.room.room_number, - "floor": booking.room.floor, - "images": room_images, # Include room images - "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, - "images": room_images, # Also include in room_type for backwards compatibility - } - } - - # Add payments + booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor, 'images': room_images, '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, 'images': room_images}} if booking.payments: - booking_dict["payments"] = [ - { - "id": p.id, - "amount": float(p.amount) if p.amount else 0.0, - "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), - "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), - "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, - "transaction_id": p.transaction_id, - "payment_date": p.payment_date.isoformat() if p.payment_date else None, - "created_at": p.created_at.isoformat() if p.created_at else None, - } - for p in booking.payments - ] + booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments] else: - booking_dict["payments"] = [] - + booking_dict['payments'] = [] result.append(booking_dict) - - return { - "success": True, - "data": {"bookings": result} - } + return {'success': True, 'data': {'bookings': result}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/") -async def create_booking( - booking_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create new booking""" +@router.post('/') +async def create_booking(booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role in ['admin', 'staff']: + raise HTTPException(status_code=403, detail='Admin and staff users cannot create bookings') try: import logging logger = logging.getLogger(__name__) - - # Validate that booking_data is a dict if not isinstance(booking_data, dict): - logger.error(f"Invalid booking_data type: {type(booking_data)}, value: {booking_data}") - raise HTTPException(status_code=400, detail="Invalid request body. Expected JSON object.") - - logger.info(f"Received booking request from user {current_user.id}: {booking_data}") - - room_id = booking_data.get("room_id") - check_in_date = booking_data.get("check_in_date") - check_out_date = booking_data.get("check_out_date") - total_price = booking_data.get("total_price") - guest_count = booking_data.get("guest_count", 1) - notes = booking_data.get("notes") - payment_method = booking_data.get("payment_method", "cash") - promotion_code = booking_data.get("promotion_code") - - # Invoice information (optional) - invoice_info = booking_data.get("invoice_info", {}) - - # Detailed validation with specific error messages + logger.error(f'Invalid booking_data type: {type(booking_data)}, value: {booking_data}') + raise HTTPException(status_code=400, detail='Invalid request body. Expected JSON object.') + logger.info(f'Received booking request from user {current_user.id}: {booking_data}') + room_id = booking_data.get('room_id') + check_in_date = booking_data.get('check_in_date') + check_out_date = booking_data.get('check_out_date') + total_price = booking_data.get('total_price') + guest_count = booking_data.get('guest_count', 1) + notes = booking_data.get('notes') + payment_method = booking_data.get('payment_method', 'cash') + promotion_code = booking_data.get('promotion_code') + invoice_info = booking_data.get('invoice_info', {}) missing_fields = [] if not room_id: - missing_fields.append("room_id") + missing_fields.append('room_id') if not check_in_date: - missing_fields.append("check_in_date") + missing_fields.append('check_in_date') if not check_out_date: - missing_fields.append("check_out_date") + missing_fields.append('check_out_date') if total_price is None: - missing_fields.append("total_price") - + missing_fields.append('total_price') if missing_fields: - error_msg = f"Missing required booking fields: {', '.join(missing_fields)}" + error_msg = f'Missing required booking fields: {', '.join(missing_fields)}' logger.error(error_msg) raise HTTPException(status_code=400, detail=error_msg) - - # Check if room exists room = db.query(Room).filter(Room.id == room_id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Parse dates as date-only strings (YYYY-MM-DD) - treat as naive datetime + raise HTTPException(status_code=404, detail='Room not found') if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date: check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) else: - # Date-only format (YYYY-MM-DD) - parse as naive datetime check_in = datetime.strptime(check_in_date, '%Y-%m-%d') - if 'T' in check_out_date or 'Z' in check_out_date or '+' in check_out_date: check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) else: - # Date-only format (YYYY-MM-DD) - parse as naive datetime check_out = datetime.strptime(check_out_date, '%Y-%m-%d') - - # Check for overlapping bookings - 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: - 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') booking_number = generate_booking_number() - - # Determine if deposit is required - # Cash requires deposit, Stripe and PayPal don't require deposit (full payment or deposit handled via payment flow) - requires_deposit = payment_method == "cash" + requires_deposit = payment_method == 'cash' deposit_percentage = 20 if requires_deposit else 0 - deposit_amount = (float(total_price) * deposit_percentage) / 100 if requires_deposit else 0 - - # For Stripe and PayPal, booking can be confirmed immediately after payment + deposit_amount = float(total_price) * deposit_percentage / 100 if requires_deposit else 0 initial_status = BookingStatus.pending - if payment_method in ["stripe", "paypal"]: - # Will be confirmed after successful payment + if payment_method in ['stripe', 'paypal']: initial_status = BookingStatus.pending - - # Calculate original price (before discount) and discount amount - # Calculate room price room_price = float(room.price) if room.price and room.price > 0 else float(room.room_type.base_price) if room.room_type else 0.0 number_of_nights = (check_out - check_in).days room_total = room_price * number_of_nights - - # Calculate services total (will be recalculated when adding services, but estimate here) - services = booking_data.get("services", []) + services = booking_data.get('services', []) services_total = 0.0 if services: from ..models.service import Service for service_item in services: - service_id = service_item.get("service_id") - quantity = service_item.get("quantity", 1) + service_id = service_item.get('service_id') + quantity = service_item.get('quantity', 1) if service_id: service = db.query(Service).filter(Service.id == service_id).first() if service and service.is_active: services_total += float(service.price) * quantity - original_price = room_total + services_total discount_amount = max(0.0, original_price - float(total_price)) if promotion_code else 0.0 - - # Add promotion code to notes if provided - final_notes = notes or "" + final_notes = notes or '' if promotion_code: - promotion_note = f"Promotion Code: {promotion_code}" - final_notes = f"{promotion_note}\n{final_notes}".strip() if final_notes else promotion_note - - # Create booking - booking = Booking( - booking_number=booking_number, - user_id=current_user.id, - room_id=room_id, - check_in_date=check_in, - check_out_date=check_out, - num_guests=guest_count, - total_price=total_price, - original_price=original_price if promotion_code else None, - discount_amount=discount_amount if promotion_code and discount_amount > 0 else None, - promotion_code=promotion_code, - special_requests=final_notes, - status=initial_status, - requires_deposit=requires_deposit, - deposit_paid=False, - ) - + promotion_note = f'Promotion Code: {promotion_code}' + final_notes = f'{promotion_note}\n{final_notes}'.strip() if final_notes else promotion_note + booking = Booking(booking_number=booking_number, user_id=current_user.id, room_id=room_id, check_in_date=check_in, check_out_date=check_out, num_guests=guest_count, total_price=total_price, original_price=original_price if promotion_code else None, discount_amount=discount_amount if promotion_code and discount_amount > 0 else None, promotion_code=promotion_code, special_requests=final_notes, status=initial_status, requires_deposit=requires_deposit, deposit_paid=False) db.add(booking) db.flush() - - # Create payment record if Stripe or PayPal payment method is selected - if payment_method in ["stripe", "paypal"]: + if payment_method in ['stripe', 'paypal']: from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType - if payment_method == "stripe": + if payment_method == 'stripe': payment_method_enum = PaymentMethod.stripe - elif payment_method == "paypal": + elif payment_method == 'paypal': payment_method_enum = PaymentMethod.paypal else: - # This shouldn't happen, but just in case - logger.warning(f"Unexpected payment_method: {payment_method}, defaulting to stripe") + logger.warning(f'Unexpected payment_method: {payment_method}, defaulting to stripe') payment_method_enum = PaymentMethod.stripe - - logger.info(f"Creating payment for booking {booking.id} with payment_method: {payment_method} -> enum: {payment_method_enum.value}") - - payment = Payment( - booking_id=booking.id, - amount=total_price, - payment_method=payment_method_enum, - payment_type=PaymentType.full, - payment_status=PaymentStatus.pending, - payment_date=None, - ) + logger.info(f'Creating payment for booking {booking.id} with payment_method: {payment_method} -> enum: {payment_method_enum.value}') + payment = Payment(booking_id=booking.id, amount=total_price, payment_method=payment_method_enum, payment_type=PaymentType.full, payment_status=PaymentStatus.pending, payment_date=None) db.add(payment) db.flush() - - logger.info(f"Payment created: ID={payment.id}, method={payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method}") - - # Create deposit payment if required (for cash method) - # For cash payments, create a pending deposit payment record that can be paid via PayPal or Stripe + logger.info(f'Payment created: ID={payment.id}, method={(payment.payment_method.value if hasattr(payment.payment_method, 'value') else payment.payment_method)}') if requires_deposit and deposit_amount > 0: from ..models.payment import Payment, PaymentMethod, PaymentStatus, PaymentType - deposit_payment = Payment( - booking_id=booking.id, - amount=deposit_amount, - payment_method=PaymentMethod.stripe, # Default, will be updated when user chooses payment method - payment_type=PaymentType.deposit, - deposit_percentage=deposit_percentage, - payment_status=PaymentStatus.pending, - payment_date=None, - ) + deposit_payment = Payment(booking_id=booking.id, amount=deposit_amount, payment_method=PaymentMethod.stripe, payment_type=PaymentType.deposit, deposit_percentage=deposit_percentage, payment_status=PaymentStatus.pending, payment_date=None) db.add(deposit_payment) db.flush() - logger.info(f"Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%") - - # Add services to booking if provided - services = booking_data.get("services", []) + logger.info(f'Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%') + services = booking_data.get('services', []) if services: from ..models.service import Service - # ServiceUsage is already imported at the top of the file - for service_item in services: - service_id = service_item.get("service_id") - quantity = service_item.get("quantity", 1) - + service_id = service_item.get('service_id') + quantity = service_item.get('quantity', 1) if not service_id: continue - - # Check if service exists and is active service = db.query(Service).filter(Service.id == service_id).first() if not service or not service.is_active: continue - - # Calculate total price for this service unit_price = float(service.price) total_price = unit_price * quantity - - # Create service usage - service_usage = ServiceUsage( - booking_id=booking.id, - service_id=service_id, - quantity=quantity, - unit_price=unit_price, - total_price=total_price, - ) + service_usage = ServiceUsage(booking_id=booking.id, service_id=service_id, quantity=quantity, unit_price=unit_price, total_price=total_price) db.add(service_usage) - db.commit() db.refresh(booking) - - # Automatically create invoice(s) for the booking try: from ..services.invoice_service import InvoiceService from ..utils.mailer import send_email from sqlalchemy.orm import joinedload, selectinload - - # Reload booking with service_usages for invoice creation - booking = db.query(Booking).options( - selectinload(Booking.service_usages).selectinload(ServiceUsage.service) - ).filter(Booking.id == booking.id).first() - - # Get company settings for invoice + booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload(ServiceUsage.service)).filter(Booking.id == booking.id).first() from ..models.system_settings import SystemSettings company_settings = {} - for key in ["company_name", "company_address", "company_phone", "company_email", "company_tax_id", "company_logo_url"]: + for key in ['company_name', 'company_address', 'company_phone', 'company_email', 'company_tax_id', 'company_logo_url']: setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() if setting and setting.value: company_settings[key] = setting.value - - # Get tax rate from settings (default to 0 if not set) - tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == "tax_rate").first() + tax_rate_setting = db.query(SystemSettings).filter(SystemSettings.key == 'tax_rate').first() tax_rate = float(tax_rate_setting.value) if tax_rate_setting and tax_rate_setting.value else 0.0 - - # Merge invoice info from form with company settings (form takes precedence) - # Only include non-empty values from invoice_info invoice_kwargs = {**company_settings} if invoice_info: - if invoice_info.get("company_name"): - invoice_kwargs["company_name"] = invoice_info.get("company_name") - if invoice_info.get("company_address"): - invoice_kwargs["company_address"] = invoice_info.get("company_address") - if invoice_info.get("company_tax_id"): - invoice_kwargs["company_tax_id"] = invoice_info.get("company_tax_id") - if invoice_info.get("customer_tax_id"): - invoice_kwargs["customer_tax_id"] = invoice_info.get("customer_tax_id") - if invoice_info.get("notes"): - invoice_kwargs["notes"] = invoice_info.get("notes") - if invoice_info.get("terms_and_conditions"): - invoice_kwargs["terms_and_conditions"] = invoice_info.get("terms_and_conditions") - if invoice_info.get("payment_instructions"): - invoice_kwargs["payment_instructions"] = invoice_info.get("payment_instructions") - - # Get discount from booking + if invoice_info.get('company_name'): + invoice_kwargs['company_name'] = invoice_info.get('company_name') + if invoice_info.get('company_address'): + invoice_kwargs['company_address'] = invoice_info.get('company_address') + if invoice_info.get('company_tax_id'): + invoice_kwargs['company_tax_id'] = invoice_info.get('company_tax_id') + if invoice_info.get('customer_tax_id'): + invoice_kwargs['customer_tax_id'] = invoice_info.get('customer_tax_id') + if invoice_info.get('notes'): + invoice_kwargs['notes'] = invoice_info.get('notes') + if invoice_info.get('terms_and_conditions'): + invoice_kwargs['terms_and_conditions'] = invoice_info.get('terms_and_conditions') + if invoice_info.get('payment_instructions'): + invoice_kwargs['payment_instructions'] = invoice_info.get('payment_instructions') booking_discount = float(booking.discount_amount) if booking.discount_amount else 0.0 - - # Add promotion code to invoice notes if present - invoice_notes = invoice_kwargs.get("notes", "") + invoice_notes = invoice_kwargs.get('notes', '') if booking.promotion_code: - promotion_note = f"Promotion Code: {booking.promotion_code}" - invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note - invoice_kwargs["notes"] = invoice_notes - - # Create invoices based on payment method - if payment_method == "cash": - # For cash bookings: create invoice for 20% deposit + proforma for 80% remaining + promotion_note = f'Promotion Code: {booking.promotion_code}' + invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note + invoice_kwargs['notes'] = invoice_notes + if payment_method == 'cash': deposit_amount = float(total_price) * 0.2 remaining_amount = float(total_price) * 0.8 - - # Calculate proportional discount for partial invoices - # Deposit invoice gets 20% of the discount, proforma gets 80% deposit_discount = booking_discount * 0.2 if booking_discount > 0 else 0.0 proforma_discount = booking_discount * 0.8 if booking_discount > 0 else 0.0 - - # Create invoice for deposit (20%) - deposit_invoice = InvoiceService.create_invoice_from_booking( - booking_id=booking.id, - db=db, - created_by_id=current_user.id, - tax_rate=tax_rate, - discount_amount=deposit_discount, - due_days=30, - is_proforma=False, - invoice_amount=deposit_amount, - **invoice_kwargs - ) - - # Create proforma invoice for remaining amount (80%) - proforma_invoice = InvoiceService.create_invoice_from_booking( - booking_id=booking.id, - db=db, - created_by_id=current_user.id, - tax_rate=tax_rate, - discount_amount=proforma_discount, - due_days=30, - is_proforma=True, - invoice_amount=remaining_amount, - **invoice_kwargs - ) - - # Send deposit invoice via email + deposit_invoice = InvoiceService.create_invoice_from_booking(booking_id=booking.id, db=db, created_by_id=current_user.id, tax_rate=tax_rate, discount_amount=deposit_discount, due_days=30, is_proforma=False, invoice_amount=deposit_amount, **invoice_kwargs) + proforma_invoice = InvoiceService.create_invoice_from_booking(booking_id=booking.id, db=db, created_by_id=current_user.id, tax_rate=tax_rate, discount_amount=proforma_discount, due_days=30, is_proforma=True, invoice_amount=remaining_amount, **invoice_kwargs) try: invoice_html = _generate_invoice_email_html(deposit_invoice, is_proforma=False) - await send_email( - to=current_user.email, - subject=f"Invoice {deposit_invoice['invoice_number']} - Deposit Payment", - html=invoice_html - ) - logger.info(f"Deposit invoice sent to {current_user.email}") + await send_email(to=current_user.email, subject=f'Invoice {deposit_invoice['invoice_number']} - Deposit Payment', html=invoice_html) + logger.info(f'Deposit invoice sent to {current_user.email}') except Exception as email_error: - logger.error(f"Failed to send deposit invoice email: {str(email_error)}") - - # Send proforma invoice via email + logger.error(f'Failed to send deposit invoice email: {str(email_error)}') try: proforma_html = _generate_invoice_email_html(proforma_invoice, is_proforma=True) - await send_email( - to=current_user.email, - subject=f"Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance", - html=proforma_html - ) - logger.info(f"Proforma invoice sent to {current_user.email}") + await send_email(to=current_user.email, subject=f'Proforma Invoice {proforma_invoice['invoice_number']} - Remaining Balance', html=proforma_html) + logger.info(f'Proforma invoice sent to {current_user.email}') except Exception as email_error: - logger.error(f"Failed to send proforma invoice email: {str(email_error)}") + logger.error(f'Failed to send proforma invoice email: {str(email_error)}') else: - # For full payment (Stripe/PayPal): create full invoice - # Invoice will be created and sent after payment is confirmed - # We create it now as draft, and it will be updated when payment is confirmed - full_invoice = InvoiceService.create_invoice_from_booking( - booking_id=booking.id, - db=db, - created_by_id=current_user.id, - tax_rate=tax_rate, - discount_amount=booking_discount, - due_days=30, - is_proforma=False, - **invoice_kwargs - ) - - # Don't send invoice email yet - will be sent after payment is confirmed - # The invoice will be updated and sent when payment is completed - logger.info(f"Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)") + full_invoice = InvoiceService.create_invoice_from_booking(booking_id=booking.id, db=db, created_by_id=current_user.id, tax_rate=tax_rate, discount_amount=booking_discount, due_days=30, is_proforma=False, **invoice_kwargs) + logger.info(f'Invoice {full_invoice['invoice_number']} created for booking {booking.id} (will be sent after payment confirmation)') except Exception as e: - # Log error but don't fail booking creation if invoice creation fails import logging import traceback logger = logging.getLogger(__name__) - logger.error(f"Failed to create invoice for booking {booking.id}: {str(e)}") - logger.error(f"Traceback: {traceback.format_exc()}") - - # Fetch with relations for proper serialization (eager load payments and service_usages) + logger.error(f'Failed to create invoice for booking {booking.id}: {str(e)}') + logger.error(f'Traceback: {traceback.format_exc()}') from sqlalchemy.orm import joinedload, selectinload - booking = db.query(Booking).options( - joinedload(Booking.payments), - selectinload(Booking.service_usages).selectinload(ServiceUsage.service) - ).filter(Booking.id == booking.id).first() - - # Determine payment_method and payment_status from payments + booking = db.query(Booking).options(joinedload(Booking.payments), selectinload(Booking.service_usages).selectinload(ServiceUsage.service)).filter(Booking.id == booking.id).first() payment_method_from_payments = None - payment_status_from_payments = "unpaid" + payment_status_from_payments = 'unpaid' if booking.payments: latest_payment = sorted(booking.payments, key=lambda p: p.created_at, reverse=True)[0] - # Safely extract payment method value if isinstance(latest_payment.payment_method, PaymentMethod): payment_method_from_payments = latest_payment.payment_method.value elif hasattr(latest_payment.payment_method, 'value'): payment_method_from_payments = latest_payment.payment_method.value else: payment_method_from_payments = str(latest_payment.payment_method) - - logger.info(f"Booking {booking.id} - Latest payment method: {payment_method_from_payments}, raw: {latest_payment.payment_method}") - + logger.info(f'Booking {booking.id} - Latest payment method: {payment_method_from_payments}, raw: {latest_payment.payment_method}') if latest_payment.payment_status == PaymentStatus.completed: - payment_status_from_payments = "paid" + payment_status_from_payments = 'paid' elif latest_payment.payment_status == PaymentStatus.refunded: - payment_status_from_payments = "refunded" - - # Use payment_method from payments if available, otherwise fall back to request payment_method + payment_status_from_payments = 'refunded' final_payment_method = payment_method_from_payments if payment_method_from_payments else payment_method - logger.info(f"Booking {booking.id} - Final payment_method: {final_payment_method} (from_payments: {payment_method_from_payments}, request: {payment_method})") - - # Serialize booking properly - booking_dict = { - "id": booking.id, - "booking_number": booking.booking_number, - "user_id": booking.user_id, - "room_id": booking.room_id, - "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, - "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, - "guest_count": booking.num_guests, - "total_price": float(booking.total_price) if booking.total_price else 0.0, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "payment_method": final_payment_method, - "payment_status": payment_status_from_payments, - "deposit_paid": booking.deposit_paid, - "requires_deposit": booking.requires_deposit, - "notes": booking.special_requests, - "guest_info": { - "full_name": current_user.full_name, - "email": current_user.email, - "phone": current_user.phone_number if hasattr(current_user, 'phone_number') else (current_user.phone if hasattr(current_user, 'phone') else ""), - }, - "createdAt": booking.created_at.isoformat() if booking.created_at else None, - "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, - "created_at": booking.created_at.isoformat() if booking.created_at else None, - } - - # Add payments if they exist + logger.info(f'Booking {booking.id} - Final payment_method: {final_payment_method} (from_payments: {payment_method_from_payments}, request: {payment_method})') + booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': final_payment_method, 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'notes': booking.special_requests, 'guest_info': {'full_name': current_user.full_name, 'email': current_user.email, 'phone': current_user.phone_number if hasattr(current_user, 'phone_number') else current_user.phone if hasattr(current_user, 'phone') else ''}, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None, 'created_at': booking.created_at.isoformat() if booking.created_at else None} if booking.payments: - booking_dict["payments"] = [ - { - "id": p.id, - "booking_id": p.booking_id, - "amount": float(p.amount) if p.amount else 0.0, - "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), - "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type, - "deposit_percentage": p.deposit_percentage, - "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, - "transaction_id": p.transaction_id, - "payment_date": p.payment_date.isoformat() if p.payment_date else None, - "notes": p.notes, - "created_at": p.created_at.isoformat() if p.created_at else None, - } - for p in booking.payments - ] - - # Add service usages if they exist + booking_dict['payments'] = [{'id': p.id, 'booking_id': p.booking_id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type, 'deposit_percentage': p.deposit_percentage, 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'notes': p.notes, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments] service_usages = getattr(booking, 'service_usages', None) import logging logger = logging.getLogger(__name__) - logger.info(f"Booking {booking.id} - service_usages: {service_usages}, type: {type(service_usages)}") - + logger.info(f'Booking {booking.id} - service_usages: {service_usages}, type: {type(service_usages)}') if service_usages and len(service_usages) > 0: - logger.info(f"Booking {booking.id} - Found {len(service_usages)} service usages") - booking_dict["service_usages"] = [ - { - "id": su.id, - "service_id": su.service_id, - "service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service", - "quantity": su.quantity, - "unit_price": float(su.unit_price) if su.unit_price else 0.0, - "total_price": float(su.total_price) if su.total_price else 0.0, - } - for su in service_usages - ] - logger.info(f"Booking {booking.id} - Serialized service_usages: {booking_dict['service_usages']}") + logger.info(f'Booking {booking.id} - Found {len(service_usages)} service usages') + booking_dict['service_usages'] = [{'id': su.id, 'service_id': su.service_id, 'service_name': su.service.name if hasattr(su, 'service') and su.service else 'Unknown Service', 'quantity': su.quantity, 'unit_price': float(su.unit_price) if su.unit_price else 0.0, 'total_price': float(su.total_price) if su.total_price else 0.0} for su in service_usages] + logger.info(f'Booking {booking.id} - Serialized service_usages: {booking_dict['service_usages']}') else: - # Initialize empty array if no service_usages - logger.info(f"Booking {booking.id} - No service_usages found, initializing empty array") - booking_dict["service_usages"] = [] - - # Add room info if available + logger.info(f'Booking {booking.id} - No service_usages found, initializing empty array') + booking_dict['service_usages'] = [] if booking.room: - 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: - 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, - } - - # Don't send email here - emails will be sent when booking is confirmed or cancelled - - 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" - } + 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'} except HTTPException: raise except Exception as e: import logging import traceback logger = logging.getLogger(__name__) - logger.error(f"Error creating booking (payment_method: {payment_method}): {str(e)}") - logger.error(f"Traceback: {traceback.format_exc()}") + logger.error(f'Error creating booking (payment_method: {payment_method}): {str(e)}') + logger.error(f'Traceback: {traceback.format_exc()}') db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}") -async def get_booking_by_id( - id: int, - request: Request, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get booking by ID""" +@router.get('/{id}') +async def get_booking_by_id(id: int, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Eager load all relationships to avoid N+1 queries - # Using selectinload for better performance with multiple relationships from sqlalchemy.orm import selectinload - 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: - raise HTTPException(status_code=404, detail="Booking not found") - - # Check access - if current_user.role_id != 1 and booking.user_id != current_user.id: # Not admin - raise HTTPException(status_code=403, detail="Forbidden") - - # Determine payment_method and payment_status from payments - # Get latest payment efficiently (already loaded via joinedload) + raise HTTPException(status_code=404, detail='Booking not found') + if current_user.role_id != 1 and booking.user_id != current_user.id: + raise HTTPException(status_code=403, detail='Forbidden') import logging logger = logging.getLogger(__name__) - payment_method_from_payments = None - payment_status = "unpaid" + payment_status = 'unpaid' if booking.payments: - # Find latest payment (payments are already loaded, so this is fast) latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) - # Safely extract payment method value if isinstance(latest_payment.payment_method, PaymentMethod): payment_method_from_payments = latest_payment.payment_method.value elif hasattr(latest_payment.payment_method, 'value'): payment_method_from_payments = latest_payment.payment_method.value else: payment_method_from_payments = str(latest_payment.payment_method) - - logger.info(f"Get booking {id} - Latest payment method: {payment_method_from_payments}, raw: {latest_payment.payment_method}") - + logger.info(f'Get booking {id} - Latest payment method: {payment_method_from_payments}, raw: {latest_payment.payment_method}') if latest_payment.payment_status == PaymentStatus.completed: - payment_status = "paid" + payment_status = 'paid' elif latest_payment.payment_status == PaymentStatus.refunded: - payment_status = "refunded" - - # Use payment_method from payments, fallback to "cash" if no payments - final_payment_method = payment_method_from_payments if payment_method_from_payments else "cash" - logger.info(f"Get booking {id} - Final payment_method: {final_payment_method}") - - booking_dict = { - "id": booking.id, - "booking_number": booking.booking_number, - "user_id": booking.user_id, - "room_id": booking.room_id, - "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, - "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, - "guest_count": booking.num_guests, # Frontend expects guest_count - "total_price": float(booking.total_price) if booking.total_price else 0.0, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "payment_method": final_payment_method, - "payment_status": payment_status, - "deposit_paid": booking.deposit_paid, - "requires_deposit": booking.requires_deposit, - "notes": booking.special_requests, # Frontend expects notes - "guest_info": { - "full_name": booking.user.full_name if booking.user else "", - "email": booking.user.email if booking.user else "", - "phone": booking.user.phone_number if booking.user and hasattr(booking.user, 'phone_number') else (booking.user.phone if booking.user and hasattr(booking.user, 'phone') else ""), - } if booking.user else None, - "createdAt": booking.created_at.isoformat() if booking.created_at else None, - "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, - "created_at": booking.created_at.isoformat() if booking.created_at else None, - } - - # Add relations - # Only get base_url if we need it (room has images) + payment_status = 'refunded' + final_payment_method = payment_method_from_payments if payment_method_from_payments else 'cash' + logger.info(f'Get booking {id} - Final payment_method: {final_payment_method}') + booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': final_payment_method, 'payment_status': payment_status, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'notes': booking.special_requests, 'guest_info': {'full_name': booking.user.full_name if booking.user else '', 'email': booking.user.email if booking.user else '', 'phone': booking.user.phone_number if booking.user and hasattr(booking.user, 'phone_number') else booking.user.phone if booking.user and hasattr(booking.user, 'phone') else ''} if booking.user else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None, 'created_at': booking.created_at.isoformat() if booking.created_at else None} if booking.room and booking.room.images: base_url = get_base_url(request) - # Normalize room images if they exist try: room_images = normalize_images(booking.room.images, base_url) except: room_images = [] else: room_images = [] - if booking.room: - - booking_dict["room"] = { - "id": booking.room.id, - "room_number": booking.room.room_number, - "floor": booking.room.floor, - "status": booking.room.status.value if isinstance(booking.room.status, RoomStatus) else booking.room.status, - "images": room_images, # Include room images directly on room object - } + booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'floor': booking.room.floor, 'status': booking.room.status.value if isinstance(booking.room.status, RoomStatus) else booking.room.status, 'images': room_images} if booking.room.room_type: - # Use room images if room_type doesn't have images (which is typical) - # RoomType doesn't have images column, images are stored on Room room_type_images = room_images if room_images else [] - - 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, - "images": room_type_images, - } - + 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, 'images': room_type_images} if booking.payments: - booking_dict["payments"] = [ - { - "id": p.id, - "amount": float(p.amount) if p.amount else 0.0, - "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), - "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), - "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, - "transaction_id": p.transaction_id, - "payment_date": p.payment_date.isoformat() if p.payment_date else None, - "created_at": p.created_at.isoformat() if p.created_at else None, - } - for p in booking.payments - ] - - # Add service usages if they exist - # Use getattr to safely access service_usages in case relationship isn't loaded + booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments] service_usages = getattr(booking, 'service_usages', None) import logging logger = logging.getLogger(__name__) - logger.info(f"Get booking {id} - service_usages: {service_usages}, type: {type(service_usages)}") - + logger.info(f'Get booking {id} - service_usages: {service_usages}, type: {type(service_usages)}') if service_usages and len(service_usages) > 0: - logger.info(f"Get booking {id} - Found {len(service_usages)} service usages") - booking_dict["service_usages"] = [ - { - "id": su.id, - "service_id": su.service_id, - "service_name": su.service.name if hasattr(su, 'service') and su.service else "Unknown Service", - "quantity": su.quantity, - "unit_price": float(su.unit_price) if su.unit_price else 0.0, - "total_price": float(su.total_price) if su.total_price else 0.0, - } - for su in service_usages - ] - logger.info(f"Get booking {id} - Serialized service_usages: {booking_dict['service_usages']}") + logger.info(f'Get booking {id} - Found {len(service_usages)} service usages') + booking_dict['service_usages'] = [{'id': su.id, 'service_id': su.service_id, 'service_name': su.service.name if hasattr(su, 'service') and su.service else 'Unknown Service', 'quantity': su.quantity, 'unit_price': float(su.unit_price) if su.unit_price else 0.0, 'total_price': float(su.total_price) if su.total_price else 0.0} for su in service_usages] + logger.info(f'Get booking {id} - Serialized service_usages: {booking_dict['service_usages']}') else: - # Initialize empty array if no service_usages - logger.info(f"Get booking {id} - No service_usages found, initializing empty array") - booking_dict["service_usages"] = [] - - return { - "success": True, - "data": {"booking": booking_dict} - } + logger.info(f'Get booking {id} - No service_usages found, initializing empty array') + booking_dict['service_usages'] = [] + return {'success': True, 'data': {'booking': booking_dict}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.patch("/{id}/cancel") -async def cancel_booking( - id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Cancel a booking""" +@router.patch('/{id}/cancel') +async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: booking = db.query(Booking).filter(Booking.id == id).first() - if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - + raise HTTPException(status_code=404, detail='Booking not found') if booking.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") - + raise HTTPException(status_code=403, detail='Forbidden') if booking.status == BookingStatus.cancelled: - raise HTTPException(status_code=400, detail="Booking already cancelled") - - # Prevent cancellation of confirmed bookings + raise HTTPException(status_code=400, detail='Booking already cancelled') if booking.status == BookingStatus.confirmed: - raise HTTPException( - status_code=400, - detail="Cannot cancel a confirmed booking. Please contact support for assistance." - ) - - # Only allow cancellation of pending bookings + raise HTTPException(status_code=400, detail='Cannot cancel a confirmed booking. Please contact support for assistance.') 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.') + booking = db.query(Booking).options(selectinload(Booking.payments)).filter(Booking.id == id).first() + payments_updated = False + if booking.payments: + for payment in booking.payments: + if payment.payment_status == PaymentStatus.pending: + payment.payment_status = PaymentStatus.failed + existing_notes = payment.notes or '' + 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() + payments_updated = True + from sqlalchemy import update, func + pending_payments = db.query(Payment).filter(Payment.booking_id == id, Payment.payment_status == PaymentStatus.pending).all() + for payment in pending_payments: + payment.payment_status = PaymentStatus.failed + existing_notes = payment.notes or '' + 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() + payments_updated = True booking.status = BookingStatus.cancelled + if payments_updated > 0: + db.flush() db.commit() - - # Send cancellation email (non-blocking) try: from ..models.system_settings import SystemSettings - - # Get client URL from settings - 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")) - - email_html = booking_status_changed_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - status="cancelled", - client_url=client_url - ) - await send_email( - to=booking.user.email if booking.user else None, - subject=f"Booking Cancelled - {booking.booking_number}", - html=email_html - ) + 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') + email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url) + await send_email(to=booking.user.email if booking.user else None, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Failed to send cancellation email: {e}") - - return { - "success": True, - "data": {"booking": booking} - } + logger.error(f'Failed to send cancellation email: {e}') + return {'success': True, 'data': {'booking': booking}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))]) -async def update_booking( - id: int, - booking_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Update booking status (Admin only)""" +@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def update_booking(id: int, booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Load booking with payments to check balance - 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() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - + raise HTTPException(status_code=404, detail='Booking not found') old_status = booking.status - status_value = booking_data.get("status") + status_value = booking_data.get('status') if status_value: try: - booking.status = BookingStatus(status_value) + new_status = BookingStatus(status_value) + booking.status = new_status + if new_status == BookingStatus.cancelled: + if booking.payments: + for payment in booking.payments: + if payment.payment_status == PaymentStatus.pending: + payment.payment_status = PaymentStatus.failed + existing_notes = payment.notes or '' + cancellation_note = f'\nPayment cancelled due to booking cancellation on {datetime.utcnow().isoformat()}' + payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip() + db.flush() except ValueError: - raise HTTPException(status_code=400, detail="Invalid status") - + raise HTTPException(status_code=400, detail='Invalid status') db.commit() db.refresh(booking) - - # Check payment balance if status changed to checked_in payment_warning = None - if status_value and old_status != booking.status and booking.status == BookingStatus.checked_in: + if status_value and old_status != booking.status and (booking.status == BookingStatus.checked_in): payment_balance = calculate_booking_payment_balance(booking) - if payment_balance["remaining_balance"] > 0.01: # More than 1 cent remaining - payment_warning = { - "message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}", - "total_paid": payment_balance["total_paid"], - "total_price": payment_balance["total_price"], - "remaining_balance": payment_balance["remaining_balance"], - "payment_percentage": payment_balance["payment_percentage"] - } - - # Send status change email only if status changed to confirmed or cancelled (non-blocking) + if payment_balance['remaining_balance'] > 0.01: + payment_warning = {'message': f'Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}', 'total_paid': payment_balance['total_paid'], 'total_price': payment_balance['total_price'], 'remaining_balance': payment_balance['remaining_balance'], 'payment_percentage': payment_balance['payment_percentage']} if status_value and old_status != booking.status: if booking.status in [BookingStatus.confirmed, BookingStatus.cancelled]: try: from ..models.system_settings import SystemSettings from ..services.room_service import get_base_url from fastapi import Request - - # Get client URL from settings - 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_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') if booking.status == BookingStatus.confirmed: - # Send booking confirmation email with full details from sqlalchemy.orm import selectinload - booking_with_room = db.query(Booking).options( - selectinload(Booking.room).selectinload(Room.room_type) - ).filter(Booking.id == booking.id).first() - + booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking.id).first() room = booking_with_room.room if booking_with_room else None - room_type_name = room.room_type.name if room and room.room_type else "Room" - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + room_type_name = room.room_type.name if room and room.room_type else 'Room' + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - email_html = booking_confirmation_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - 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 - ) - await send_email( - to=booking.user.email if booking.user else None, - subject=f"Booking Confirmed - {booking.booking_number}", - html=email_html - ) + email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', 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) + await send_email(to=booking.user.email if booking.user else None, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html) elif booking.status == BookingStatus.cancelled: - # Send cancellation email - email_html = booking_status_changed_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - status="cancelled", - client_url=client_url - ) - await send_email( - to=booking.user.email if booking.user else None, - subject=f"Booking Cancelled - {booking.booking_number}", - html=email_html - ) + email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url) + await send_email(to=booking.user.email if booking.user else None, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Failed to send status change email: {e}") - - response_data = { - "status": "success", - "message": "Booking updated successfully", - "data": {"booking": booking} - } - - # Add payment warning if there's remaining balance during check-in + logger.error(f'Failed to send status change email: {e}') + response_data = {'status': 'success', 'message': 'Booking updated successfully', 'data': {'booking': booking}} if payment_warning: - response_data["warning"] = payment_warning - response_data["message"] = "Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance." - + response_data['warning'] = payment_warning + response_data['message'] = 'Booking updated successfully. ⚠️ Payment reminder: Guest has remaining balance.' return response_data except HTTPException: raise @@ -1336,26 +547,14 @@ async def update_booking( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/check/{booking_number}") -async def check_booking_by_number( - booking_number: str, - db: Session = Depends(get_db) -): - """Check booking by booking number""" +@router.get('/check/{booking_number}') +async def check_booking_by_number(booking_number: str, db: Session=Depends(get_db)): try: - booking = db.query(Booking).options( - selectinload(Booking.payments), - joinedload(Booking.user), - joinedload(Booking.room).joinedload(Room.room_type) - ).filter(Booking.booking_number == booking_number).first() - + booking = db.query(Booking).options(selectinload(Booking.payments), joinedload(Booking.user), joinedload(Booking.room).joinedload(Room.room_type)).filter(Booking.booking_number == booking_number).first() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - - # Determine payment_method and payment_status from payments + raise HTTPException(status_code=404, detail='Booking not found') payment_method_from_payments = None - payment_status_from_payments = "unpaid" + payment_status_from_payments = 'unpaid' if booking.payments: latest_payment = max(booking.payments, key=lambda p: p.created_at if p.created_at else datetime.min) if isinstance(latest_payment.payment_method, PaymentMethod): @@ -1364,107 +563,28 @@ async def check_booking_by_number( payment_method_from_payments = latest_payment.payment_method.value else: payment_method_from_payments = str(latest_payment.payment_method) - if latest_payment.payment_status == PaymentStatus.completed: - payment_status_from_payments = "paid" + payment_status_from_payments = 'paid' elif latest_payment.payment_status == PaymentStatus.refunded: - payment_status_from_payments = "refunded" - - booking_dict = { - "id": booking.id, - "booking_number": booking.booking_number, - "user_id": booking.user_id, - "room_id": booking.room_id, - "check_in_date": booking.check_in_date.strftime("%Y-%m-%d") if booking.check_in_date else None, - "check_out_date": booking.check_out_date.strftime("%Y-%m-%d") if booking.check_out_date else None, - "num_guests": booking.num_guests, - "guest_count": booking.num_guests, - "total_price": float(booking.total_price) if booking.total_price else 0.0, - "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, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "payment_method": payment_method_from_payments if payment_method_from_payments else "cash", - "payment_status": payment_status_from_payments, - "deposit_paid": booking.deposit_paid, - "requires_deposit": booking.requires_deposit, - "special_requests": booking.special_requests, - "notes": booking.special_requests, - "created_at": booking.created_at.isoformat() if booking.created_at else None, - "createdAt": booking.created_at.isoformat() if booking.created_at else None, - "updated_at": booking.updated_at.isoformat() if booking.updated_at else None, - "updatedAt": booking.updated_at.isoformat() if booking.updated_at else None, - } - - # Add user info + payment_status_from_payments = 'refunded' + booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'user_id': booking.user_id, 'room_id': booking.room_id, 'check_in_date': booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else None, 'check_out_date': booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else None, 'num_guests': booking.num_guests, 'guest_count': booking.num_guests, 'total_price': float(booking.total_price) if booking.total_price else 0.0, '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, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'payment_method': payment_method_from_payments if payment_method_from_payments else 'cash', 'payment_status': payment_status_from_payments, 'deposit_paid': booking.deposit_paid, 'requires_deposit': booking.requires_deposit, 'special_requests': booking.special_requests, 'notes': booking.special_requests, 'created_at': booking.created_at.isoformat() if booking.created_at else None, 'createdAt': booking.created_at.isoformat() if booking.created_at else None, 'updated_at': booking.updated_at.isoformat() if booking.updated_at else None, 'updatedAt': booking.updated_at.isoformat() if booking.updated_at else None} if booking.user: - booking_dict["user"] = { - "id": booking.user.id, - "name": booking.user.full_name, - "full_name": booking.user.full_name, - "email": booking.user.email, - "phone": booking.user.phone, - "phone_number": booking.user.phone, - } - - # Add room info + booking_dict['user'] = {'id': booking.user.id, 'name': booking.user.full_name, 'full_name': booking.user.full_name, 'email': booking.user.email, 'phone': booking.user.phone, 'phone_number': booking.user.phone} if booking.room: - 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: - 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, - } - - # Add payments + 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} if booking.payments: - booking_dict["payments"] = [ - { - "id": p.id, - "amount": float(p.amount) if p.amount else 0.0, - "payment_method": p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else (p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method)), - "payment_type": p.payment_type.value if isinstance(p.payment_type, PaymentType) else (p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type)), - "payment_status": p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, - "transaction_id": p.transaction_id, - "payment_date": p.payment_date.isoformat() if p.payment_date else None, - "created_at": p.created_at.isoformat() if p.created_at else None, - } - for p in booking.payments - ] + booking_dict['payments'] = [{'id': p.id, 'amount': float(p.amount) if p.amount else 0.0, 'payment_method': p.payment_method.value if isinstance(p.payment_method, PaymentMethod) else p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method), 'payment_type': p.payment_type.value if isinstance(p.payment_type, PaymentType) else p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type), 'payment_status': p.payment_status.value if isinstance(p.payment_status, PaymentStatus) else p.payment_status, 'transaction_id': p.transaction_id, 'payment_date': p.payment_date.isoformat() if p.payment_date else None, 'created_at': p.created_at.isoformat() if p.created_at else None} for p in booking.payments] else: - booking_dict["payments"] = [] - - # Calculate and add payment balance information + booking_dict['payments'] = [] payment_balance = calculate_booking_payment_balance(booking) - booking_dict["payment_balance"] = { - "total_paid": payment_balance["total_paid"], - "total_price": payment_balance["total_price"], - "remaining_balance": payment_balance["remaining_balance"], - "is_fully_paid": payment_balance["is_fully_paid"], - "payment_percentage": payment_balance["payment_percentage"] - } - - # Add warning if there's remaining balance (useful for check-in) - response_data = { - "status": "success", - "data": {"booking": booking_dict} - } - - if payment_balance["remaining_balance"] > 0.01: - response_data["warning"] = { - "message": f"Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}", - "remaining_balance": payment_balance["remaining_balance"], - "payment_percentage": payment_balance["payment_percentage"] - } - + 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}} + if payment_balance['remaining_balance'] > 0.01: + response_data['warning'] = {'message': f'Guest has not fully paid. Remaining balance: {payment_balance['remaining_balance']:.2f}', 'remaining_balance': payment_balance['remaining_balance'], 'payment_percentage': payment_balance['payment_percentage']} return response_data except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/chat_routes.py b/Backend/src/routes/chat_routes.py new file mode 100644 index 00000000..57dd4433 --- /dev/null +++ b/Backend/src/routes/chat_routes.py @@ -0,0 +1,335 @@ +from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect +from sqlalchemy.orm import Session +from sqlalchemy import and_, or_ +from typing import List, Optional +from datetime import datetime +import json +from ..config.database import get_db +from ..middleware.auth import get_current_user, get_current_user_optional +from ..models.user import User +from ..models.chat import Chat, ChatMessage, ChatStatus +from ..models.role import Role +router = APIRouter(prefix='/chat', tags=['chat']) + +class ConnectionManager: + + def __init__(self): + self.active_connections: dict[int, List[WebSocket]] = {} + self.staff_connections: dict[int, WebSocket] = {} + self.visitor_connections: dict[int, WebSocket] = {} + + async def connect_chat(self, websocket: WebSocket, chat_id: int, user_type: str): + await websocket.accept() + if chat_id not in self.active_connections: + self.active_connections[chat_id] = [] + self.active_connections[chat_id].append(websocket) + if user_type == 'staff': + pass + elif user_type == 'visitor': + self.visitor_connections[chat_id] = websocket + + def disconnect_chat(self, websocket: WebSocket, chat_id: int): + if chat_id in self.active_connections: + if websocket in self.active_connections[chat_id]: + self.active_connections[chat_id].remove(websocket) + if not self.active_connections[chat_id]: + del self.active_connections[chat_id] + if chat_id in self.visitor_connections and self.visitor_connections[chat_id] == websocket: + del self.visitor_connections[chat_id] + + async def send_personal_message(self, message: dict, websocket: WebSocket): + try: + await websocket.send_json(message) + except Exception as e: + print(f'Error sending message: {e}') + + async def broadcast_to_chat(self, message: dict, chat_id: int): + if chat_id in self.active_connections: + disconnected = [] + for connection in self.active_connections[chat_id]: + try: + await connection.send_json(message) + except Exception as e: + print(f'Error broadcasting to connection: {e}') + disconnected.append(connection) + for conn in disconnected: + self.active_connections[chat_id].remove(conn) + + async def notify_staff_new_chat(self, chat_data: dict): + disconnected = [] + for user_id, websocket in self.staff_connections.items(): + try: + await websocket.send_json({'type': 'new_chat', 'data': chat_data}) + except Exception as e: + print(f'Error notifying staff {user_id}: {e}') + disconnected.append(user_id) + for user_id in disconnected: + del self.staff_connections[user_id] + + async def notify_staff_new_message(self, chat_id: int, message_data: dict, chat: Chat): + if message_data.get('sender_type') == 'visitor': + notification_data = {'type': 'new_message_notification', 'data': {'chat_id': chat_id, 'chat': {'id': chat.id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'status': chat.status.value, 'created_at': chat.created_at.isoformat()}, 'message': {'id': message_data.get('id'), 'message': message_data.get('message'), 'sender_type': message_data.get('sender_type'), 'created_at': message_data.get('created_at')}}} + disconnected = [] + for user_id, websocket in self.staff_connections.items(): + try: + await websocket.send_json(notification_data) + except Exception as e: + print(f'Error notifying staff {user_id}: {e}') + disconnected.append(user_id) + for user_id in disconnected: + del self.staff_connections[user_id] + + def connect_staff(self, user_id: int, websocket: WebSocket): + self.staff_connections[user_id] = websocket + + def disconnect_staff(self, user_id: int): + if user_id in self.staff_connections: + del self.staff_connections[user_id] +manager = ConnectionManager() + +@router.post('/create', status_code=status.HTTP_201_CREATED) +async def create_chat(visitor_name: Optional[str]=None, visitor_email: Optional[str]=None, visitor_phone: Optional[str]=None, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)): + if current_user: + chat = Chat(visitor_id=current_user.id, visitor_name=current_user.full_name, visitor_email=current_user.email, status=ChatStatus.pending) + else: + if not visitor_name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Visitor name is required') + if not visitor_email: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Visitor email is required') + chat = Chat(visitor_name=visitor_name, visitor_email=visitor_email, status=ChatStatus.pending) + db.add(chat) + db.commit() + db.refresh(chat) + chat_data = {'id': chat.id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'status': chat.status.value, 'created_at': chat.created_at.isoformat()} + await manager.notify_staff_new_chat(chat_data) + return {'success': True, 'data': {'id': chat.id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'status': chat.status.value, 'created_at': chat.created_at.isoformat()}} + +@router.post('/{chat_id}/accept') +async def accept_chat(chat_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role.name not in ['staff', 'admin']: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Only staff members can accept chats') + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found') + if chat.status != ChatStatus.pending: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Chat is not pending') + chat.staff_id = current_user.id + chat.status = ChatStatus.active + db.commit() + db.refresh(chat) + await manager.broadcast_to_chat({'type': 'chat_accepted', 'data': {'staff_name': current_user.full_name, 'staff_id': current_user.id}}, chat_id) + return {'success': True, 'data': {'id': chat.id, 'staff_id': chat.staff_id, 'staff_name': current_user.full_name, 'status': chat.status.value}} + +@router.get('/list') +async def list_chats(status_filter: Optional[str]=None, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role.name in ['staff', 'admin']: + query = db.query(Chat) + if status_filter: + try: + status_enum = ChatStatus(status_filter) + query = query.filter(Chat.status == status_enum) + except ValueError: + pass + chats = query.order_by(Chat.created_at.desc()).all() + else: + chats = db.query(Chat).filter(Chat.visitor_id == current_user.id).order_by(Chat.created_at.desc()).all() + return {'success': True, 'data': [{'id': chat.id, 'visitor_id': chat.visitor_id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'staff_id': chat.staff_id, 'staff_name': chat.staff.full_name if chat.staff else None, 'status': chat.status.value, 'created_at': chat.created_at.isoformat(), 'updated_at': chat.updated_at.isoformat(), 'message_count': len(chat.messages)} for chat in chats]} + +@router.get('/{chat_id}') +async def get_chat(chat_id: int, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)): + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found') + if current_user: + if current_user.role.name not in ['staff', 'admin']: + if chat.visitor_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to view this chat") + return {'success': True, 'data': {'id': chat.id, 'visitor_id': chat.visitor_id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'staff_id': chat.staff_id, 'staff_name': chat.staff.full_name if chat.staff else None, 'status': chat.status.value, 'created_at': chat.created_at.isoformat(), 'updated_at': chat.updated_at.isoformat()}} + +@router.get('/{chat_id}/messages') +async def get_messages(chat_id: int, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)): + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found') + if current_user: + if current_user.role.name not in ['staff', 'admin']: + if chat.visitor_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to view this chat") + else: + pass + messages = db.query(ChatMessage).filter(ChatMessage.chat_id == chat_id).order_by(ChatMessage.created_at.asc()).all() + return {'success': True, 'data': [{'id': msg.id, 'chat_id': msg.chat_id, 'sender_id': msg.sender_id, 'sender_type': msg.sender_type, 'sender_name': msg.sender.full_name if msg.sender else None, 'message': msg.message, 'is_read': msg.is_read, 'created_at': msg.created_at.isoformat()} for msg in messages]} + +@router.post('/{chat_id}/message') +async def send_message(chat_id: int, message: str, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)): + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found') + if chat.status == ChatStatus.closed: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Chat is closed') + sender_type = 'visitor' + sender_id = None + if current_user: + if current_user.role.name in ['staff', 'admin']: + sender_type = 'staff' + sender_id = current_user.id + else: + sender_type = 'visitor' + sender_id = current_user.id + if chat.visitor_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to send messages in this chat") + else: + sender_type = 'visitor' + sender_id = None + chat_message = ChatMessage(chat_id=chat_id, sender_id=sender_id, sender_type=sender_type, message=message) + db.add(chat_message) + db.commit() + db.refresh(chat_message) + message_data = {'type': 'new_message', 'data': {'id': chat_message.id, 'chat_id': chat_message.chat_id, 'sender_id': chat_message.sender_id, 'sender_type': chat_message.sender_type, 'sender_name': chat_message.sender.full_name if chat_message.sender else None, 'message': chat_message.message, 'is_read': chat_message.is_read, 'created_at': chat_message.created_at.isoformat()}} + await manager.broadcast_to_chat(message_data, chat_id) + if chat_message.sender_type == 'visitor': + await manager.notify_staff_new_message(chat_id, message_data['data'], chat) + return {'success': True, 'data': {'id': chat_message.id, 'chat_id': chat_message.chat_id, 'sender_type': chat_message.sender_type, 'message': chat_message.message, 'created_at': chat_message.created_at.isoformat()}} + +@router.post('/{chat_id}/close') +async def close_chat(chat_id: int, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)): + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found') + if current_user: + if current_user.role.name not in ['staff', 'admin']: + if chat.visitor_id != current_user.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to close this chat") + else: + pass + chat.status = ChatStatus.closed + chat.closed_at = datetime.utcnow() + db.commit() + await manager.broadcast_to_chat({'type': 'chat_closed', 'data': {'chat_id': chat_id}}, chat_id) + return {'success': True, 'data': {'id': chat.id, 'status': chat.status.value}} + +@router.websocket('/ws/{chat_id}') +async def websocket_chat(websocket: WebSocket, chat_id: int, user_type: str=None, token: Optional[str]=None): + query_params = dict(websocket.query_params) + user_type = query_params.get('user_type', 'visitor') + token = query_params.get('token') + current_user = None + if user_type == 'staff' and token: + try: + from ..middleware.auth import verify_token + from ..config.database import get_db + payload = verify_token(token) + user_id = payload.get('userId') + db_gen = get_db() + db = next(db_gen) + try: + current_user = db.query(User).filter(User.id == user_id).first() + if not current_user or current_user.role.name not in ['staff', 'admin']: + await websocket.close(code=1008, reason='Unauthorized') + return + finally: + db.close() + except Exception as e: + await websocket.close(code=1008, reason='Invalid token') + return + await manager.connect_chat(websocket, chat_id, user_type) + if user_type == 'staff' and current_user: + manager.connect_staff(current_user.id, websocket) + try: + while True: + data = await websocket.receive_text() + message_data = json.loads(data) + if message_data.get('type') == 'message': + from ..config.database import get_db + db_gen = get_db() + db = next(db_gen) + try: + chat = db.query(Chat).filter(Chat.id == chat_id).first() + if not chat: + continue + sender_id = current_user.id if current_user else None + sender_type = 'staff' if user_type == 'staff' else 'visitor' + chat_message = ChatMessage(chat_id=chat_id, sender_id=sender_id, sender_type=sender_type, message=message_data.get('message', '')) + db.add(chat_message) + db.commit() + db.refresh(chat_message) + finally: + db.close() + message_data = {'type': 'new_message', 'data': {'id': chat_message.id, 'chat_id': chat_message.chat_id, 'sender_id': chat_message.sender_id, 'sender_type': chat_message.sender_type, 'sender_name': chat_message.sender.full_name if chat_message.sender else None, 'message': chat_message.message, 'is_read': chat_message.is_read, 'created_at': chat_message.created_at.isoformat()}} + await manager.broadcast_to_chat(message_data, chat_id) + if chat_message.sender_type == 'visitor': + await manager.notify_staff_new_message(chat_id, message_data['data'], chat) + except WebSocketDisconnect: + manager.disconnect_chat(websocket, chat_id) + if user_type == 'staff' and current_user: + manager.disconnect_staff(current_user.id) + +@router.websocket('/ws/staff/notifications') +async def websocket_staff_notifications(websocket: WebSocket): + current_user = None + try: + await websocket.accept() + query_params = dict(websocket.query_params) + token = query_params.get('token') + if not token: + await websocket.close(code=1008, reason='Token required') + return + try: + from ..middleware.auth import verify_token + from ..config.database import get_db + payload = verify_token(token) + user_id = payload.get('userId') + if not user_id: + await websocket.close(code=1008, reason='Invalid token payload') + return + db_gen = get_db() + db = next(db_gen) + try: + current_user = db.query(User).filter(User.id == user_id).first() + if not current_user: + await websocket.close(code=1008, reason='User not found') + return + role = db.query(Role).filter(Role.id == current_user.role_id).first() + if not role or role.name not in ['staff', 'admin']: + await websocket.close(code=1008, reason='Unauthorized role') + return + finally: + db.close() + except Exception as e: + print(f'WebSocket token verification error: {e}') + import traceback + traceback.print_exc() + await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}') + return + manager.connect_staff(current_user.id, websocket) + try: + await websocket.send_json({'type': 'connected', 'data': {'message': 'WebSocket connected'}}) + except Exception as e: + print(f'Error sending initial message: {e}') + while True: + try: + data = await websocket.receive_text() + try: + message_data = json.loads(data) + if message_data.get('type') == 'ping': + await websocket.send_json({'type': 'pong', 'data': 'pong'}) + except json.JSONDecodeError: + await websocket.send_json({'type': 'pong', 'data': 'pong'}) + except WebSocketDisconnect: + print('WebSocket disconnected normally') + break + except Exception as e: + print(f'WebSocket receive error: {e}') + break + except WebSocketDisconnect: + print('WebSocket disconnected') + except Exception as e: + print(f'WebSocket error: {e}') + import traceback + traceback.print_exc() + finally: + if current_user: + try: + manager.disconnect_staff(current_user.id) + except: + pass \ No newline at end of file diff --git a/Backend/src/routes/contact_content_routes.py b/Backend/src/routes/contact_content_routes.py index b239df30..65d9324b 100644 --- a/Backend/src/routes/contact_content_routes.py +++ b/Backend/src/routes/contact_content_routes.py @@ -1,68 +1,23 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session import json - from ..config.database import get_db from ..config.logging_config import get_logger from ..models.page_content import PageContent, PageType - logger = get_logger(__name__) - -router = APIRouter(prefix="/contact-content", tags=["contact-content"]) - +router = APIRouter(prefix='/contact-content', tags=['contact-content']) def serialize_page_content(content: PageContent) -> dict: - """Serialize PageContent model to dictionary""" - return { - "id": content.id, - "page_type": content.page_type.value, - "title": content.title, - "subtitle": content.subtitle, - "description": content.description, - "content": content.content, - "meta_title": content.meta_title, - "meta_description": content.meta_description, - "meta_keywords": content.meta_keywords, - "og_title": content.og_title, - "og_description": content.og_description, - "og_image": content.og_image, - "canonical_url": content.canonical_url, - "contact_info": json.loads(content.contact_info) if content.contact_info else None, - "map_url": content.map_url, - "is_active": content.is_active, - "created_at": content.created_at.isoformat() if content.created_at else None, - "updated_at": content.updated_at.isoformat() if content.updated_at else None, - } + return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} - -@router.get("/") -async def get_contact_content( - db: Session = Depends(get_db) -): - """Get contact page content""" +@router.get('/') +async def get_contact_content(db: Session=Depends(get_db)): try: content = db.query(PageContent).filter(PageContent.page_type == PageType.CONTACT).first() - if not content: - return { - "status": "success", - "data": { - "page_content": None - } - } - + return {'status': 'success', 'data': {'page_content': None}} content_dict = serialize_page_content(content) - - return { - "status": "success", - "data": { - "page_content": content_dict - } - } + return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: - logger.error(f"Error fetching contact content: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching contact content: {str(e)}" - ) - + logger.error(f'Error fetching contact content: {str(e)}', exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching contact content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/contact_routes.py b/Backend/src/routes/contact_routes.py index a6e87bb6..e03448d4 100644 --- a/Backend/src/routes/contact_routes.py +++ b/Backend/src/routes/contact_routes.py @@ -3,17 +3,13 @@ from sqlalchemy.orm import Session from pydantic import BaseModel, EmailStr from typing import Optional import logging - from ..config.database import get_db from ..models.user import User from ..models.role import Role from ..models.system_settings import SystemSettings from ..utils.mailer import send_email - logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/contact", tags=["contact"]) - +router = APIRouter(prefix='/contact', tags=['contact']) class ContactForm(BaseModel): name: str @@ -22,182 +18,35 @@ class ContactForm(BaseModel): message: str phone: Optional[str] = None - def get_admin_email(db: Session) -> str: - """Get admin email from system settings or find admin user""" - # First, try to get from company_email (company settings) - company_email_setting = db.query(SystemSettings).filter( - SystemSettings.key == "company_email" - ).first() - + company_email_setting = db.query(SystemSettings).filter(SystemSettings.key == 'company_email').first() if company_email_setting and company_email_setting.value: return company_email_setting.value - - # Second, try to get from admin_email (legacy setting) - admin_email_setting = db.query(SystemSettings).filter( - SystemSettings.key == "admin_email" - ).first() - + admin_email_setting = db.query(SystemSettings).filter(SystemSettings.key == 'admin_email').first() if admin_email_setting and admin_email_setting.value: return admin_email_setting.value - - # If not found in settings, find the first admin user - admin_role = db.query(Role).filter(Role.name == "admin").first() + admin_role = db.query(Role).filter(Role.name == 'admin').first() if admin_role: - admin_user = db.query(User).filter( - User.role_id == admin_role.id, - User.is_active == True - ).first() - + admin_user = db.query(User).filter(User.role_id == admin_role.id, User.is_active == True).first() if admin_user: return admin_user.email - - # Fallback to SMTP_FROM_EMAIL if configured from ..config.settings import settings if settings.SMTP_FROM_EMAIL: return settings.SMTP_FROM_EMAIL - - # Last resort: raise error - raise HTTPException( - status_code=500, - detail="Admin email not configured. Please set company_email in system settings or ensure an admin user exists." - ) + raise HTTPException(status_code=500, detail='Admin email not configured. Please set company_email in system settings or ensure an admin user exists.') - -@router.post("/submit") -async def submit_contact_form( - contact_data: ContactForm, - db: Session = Depends(get_db) -): - """Submit contact form and send email to admin""" +@router.post('/submit') +async def submit_contact_form(contact_data: ContactForm, db: Session=Depends(get_db)): try: - # Get admin email admin_email = get_admin_email(db) - - # Create email subject - subject = f"Contact Form: {contact_data.subject}" - - # Create email body (HTML) - html_body = f""" - - - - - - - -
-
-

New Contact Form Submission

-
-
-
- Name: -
{contact_data.name}
-
-
- Email: -
{contact_data.email}
-
- {f'
Phone:
{contact_data.phone}
' if contact_data.phone else ''} -
- Subject: -
{contact_data.subject}
-
-
- Message: -
{contact_data.message}
-
-
- -
- - - """ - - # Create plain text version - text_body = f""" -New Contact Form Submission - -Name: {contact_data.name} -Email: {contact_data.email} -{f'Phone: {contact_data.phone}' if contact_data.phone else ''} -Subject: {contact_data.subject} - -Message: -{contact_data.message} - """ - - # Send email to admin - await send_email( - to=admin_email, - subject=subject, - html=html_body, - text=text_body - ) - - logger.info(f"Contact form submitted successfully. Email sent to {admin_email}") - - return { - "status": "success", - "message": "Thank you for contacting us! We will get back to you soon." - } - + subject = f'Contact Form: {contact_data.subject}' + html_body = f + text_body = f + await send_email(to=admin_email, subject=subject, html=html_body, text=text_body) + logger.info(f'Contact form submitted successfully. Email sent to {admin_email}') + return {'status': 'success', 'message': 'Thank you for contacting us! We will get back to you soon.'} except HTTPException: raise except Exception as e: - logger.error(f"Failed to submit contact form: {type(e).__name__}: {str(e)}", exc_info=True) - raise HTTPException( - status_code=500, - detail="Failed to submit contact form. Please try again later." - ) - + logger.error(f'Failed to submit contact form: {type(e).__name__}: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail='Failed to submit contact form. Please try again later.') \ No newline at end of file diff --git a/Backend/src/routes/favorite_routes.py b/Backend/src/routes/favorite_routes.py index 55734926..fc9bba62 100644 --- a/Backend/src/routes/favorite_routes.py +++ b/Backend/src/routes/favorite_routes.py @@ -1,7 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from sqlalchemy import func - from ..config.database import get_db from ..middleware.auth import get_current_user from ..models.user import User @@ -9,179 +8,74 @@ from ..models.favorite import Favorite from ..models.room import Room from ..models.room_type import RoomType from ..models.review import Review, ReviewStatus +router = APIRouter(prefix='/favorites', tags=['favorites']) -router = APIRouter(prefix="/favorites", tags=["favorites"]) - - -@router.get("/") -async def get_favorites( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get user's favorite rooms""" +@router.get('/') +async def get_favorites(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role in ['admin', 'staff']: + raise HTTPException(status_code=403, detail='Admin and staff users cannot have favorites') try: - favorites = db.query(Favorite).filter( - Favorite.user_id == current_user.id - ).order_by(Favorite.created_at.desc()).all() - + favorites = db.query(Favorite).filter(Favorite.user_id == current_user.id).order_by(Favorite.created_at.desc()).all() result = [] for favorite in favorites: if not favorite.room: continue - room = favorite.room - - # Get review stats - review_stats = db.query( - func.avg(Review.rating).label('average_rating'), - func.count(Review.id).label('total_reviews') - ).filter( - Review.room_id == room.id, - Review.status == ReviewStatus.approved - ).first() - - room_dict = { - "id": room.id, - "room_type_id": room.room_type_id, - "room_number": room.room_number, - "floor": room.floor, - "status": room.status.value if hasattr(room.status, 'value') else room.status, - "price": float(room.price) if room.price else 0.0, - "featured": room.featured, - "description": room.description, - "amenities": room.amenities, - "images": room.images or [], - "average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, - "total_reviews": review_stats.total_reviews or 0 if review_stats else 0, - } - + review_stats = db.query(func.avg(Review.rating).label('average_rating'), func.count(Review.id).label('total_reviews')).filter(Review.room_id == room.id, Review.status == ReviewStatus.approved).first() + room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if hasattr(room.status, 'value') else room.status, 'price': float(room.price) if room.price else 0.0, 'featured': room.featured, 'description': room.description, 'amenities': room.amenities, 'images': room.images or [], 'average_rating': round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, 'total_reviews': review_stats.total_reviews or 0 if review_stats else 0} if room.room_type: - room_dict["room_type"] = { - "id": room.room_type.id, - "name": room.room_type.name, - "description": room.room_type.description, - "base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0, - "capacity": room.room_type.capacity, - "amenities": room.room_type.amenities, - } - - favorite_dict = { - "id": favorite.id, - "user_id": favorite.user_id, - "room_id": favorite.room_id, - "room": room_dict, - "created_at": favorite.created_at.isoformat() if favorite.created_at else None, - } - + room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities} + favorite_dict = {'id': favorite.id, 'user_id': favorite.user_id, 'room_id': favorite.room_id, 'room': room_dict, 'created_at': favorite.created_at.isoformat() if favorite.created_at else None} result.append(favorite_dict) - - return { - "status": "success", - "data": { - "favorites": result, - "total": len(result), - } - } + return {'status': 'success', 'data': {'favorites': result, 'total': len(result)}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/{room_id}") -async def add_favorite( - room_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Add room to favorites""" +@router.post('/{room_id}') +async def add_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role in ['admin', 'staff']: + raise HTTPException(status_code=403, detail='Admin and staff users cannot add favorites') try: - # Check if room exists room = db.query(Room).filter(Room.id == room_id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Check if already favorited - existing = db.query(Favorite).filter( - Favorite.user_id == current_user.id, - Favorite.room_id == room_id - ).first() - + raise HTTPException(status_code=404, detail='Room not found') + existing = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first() if existing: - raise HTTPException( - status_code=400, - detail="Room already in favorites list" - ) - - # Create favorite - favorite = Favorite( - user_id=current_user.id, - room_id=room_id - ) - + raise HTTPException(status_code=400, detail='Room already in favorites list') + favorite = Favorite(user_id=current_user.id, room_id=room_id) db.add(favorite) db.commit() db.refresh(favorite) - - return { - "status": "success", - "message": "Added to favorites list", - "data": {"favorite": favorite} - } + return {'status': 'success', 'message': 'Added to favorites list', 'data': {'favorite': favorite}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.delete("/{room_id}") -async def remove_favorite( - room_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Remove room from favorites""" +@router.delete('/{room_id}') +async def remove_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role in ['admin', 'staff']: + raise HTTPException(status_code=403, detail='Admin and staff users cannot remove favorites') try: - favorite = db.query(Favorite).filter( - Favorite.user_id == current_user.id, - Favorite.room_id == room_id - ).first() - + favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first() if not favorite: - raise HTTPException( - status_code=404, - detail="Room not found in favorites list" - ) - + raise HTTPException(status_code=404, detail='Room not found in favorites list') db.delete(favorite) db.commit() - - return { - "status": "success", - "message": "Removed from favorites list" - } + return {'status': 'success', 'message': 'Removed from favorites list'} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/check/{room_id}") -async def check_favorite( - room_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Check if room is favorited by user""" +@router.get('/check/{room_id}') +async def check_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): + if current_user.role in ['admin', 'staff']: + return {'status': 'success', 'data': {'isFavorited': False}} try: - favorite = db.query(Favorite).filter( - Favorite.user_id == current_user.id, - Favorite.room_id == room_id - ).first() - - return { - "status": "success", - "data": {"isFavorited": favorite is not None} - } + favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first() + return {'status': 'success', 'data': {'isFavorited': favorite is not None}} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/footer_routes.py b/Backend/src/routes/footer_routes.py index ba577829..250d1711 100644 --- a/Backend/src/routes/footer_routes.py +++ b/Backend/src/routes/footer_routes.py @@ -1,63 +1,23 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session import json - from ..config.database import get_db from ..config.logging_config import get_logger from ..models.page_content import PageContent, PageType - logger = get_logger(__name__) - -router = APIRouter(prefix="/footer", tags=["footer"]) - +router = APIRouter(prefix='/footer', tags=['footer']) def serialize_page_content(content: PageContent) -> dict: - """Serialize PageContent model to dictionary""" - return { - "id": content.id, - "page_type": content.page_type.value, - "title": content.title, - "subtitle": content.subtitle, - "description": content.description, - "content": content.content, - "social_links": json.loads(content.social_links) if content.social_links else None, - "footer_links": json.loads(content.footer_links) if content.footer_links else None, - "badges": json.loads(content.badges) if content.badges else None, - "copyright_text": content.copyright_text, - "is_active": content.is_active, - "created_at": content.created_at.isoformat() if content.created_at else None, - "updated_at": content.updated_at.isoformat() if content.updated_at else None, - } + return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} - -@router.get("/") -async def get_footer_content( - db: Session = Depends(get_db) -): - """Get footer content""" +@router.get('/') +async def get_footer_content(db: Session=Depends(get_db)): try: content = db.query(PageContent).filter(PageContent.page_type == PageType.FOOTER).first() - if not content: - return { - "status": "success", - "data": { - "page_content": None - } - } - + return {'status': 'success', 'data': {'page_content': None}} content_dict = serialize_page_content(content) - - return { - "status": "success", - "data": { - "page_content": content_dict - } - } + return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: - logger.error(f"Error fetching footer content: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching footer content: {str(e)}" - ) - + logger.error(f'Error fetching footer content: {str(e)}', exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching footer content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/home_routes.py b/Backend/src/routes/home_routes.py index 5163c81e..630986c8 100644 --- a/Backend/src/routes/home_routes.py +++ b/Backend/src/routes/home_routes.py @@ -1,110 +1,23 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session import json - from ..config.database import get_db from ..config.logging_config import get_logger from ..models.page_content import PageContent, PageType - logger = get_logger(__name__) - -router = APIRouter(prefix="/home", tags=["home"]) - +router = APIRouter(prefix='/home', tags=['home']) def serialize_page_content(content: PageContent) -> dict: - """Serialize PageContent model to dictionary""" - return { - "id": content.id, - "page_type": content.page_type.value, - "title": content.title, - "subtitle": content.subtitle, - "description": content.description, - "content": content.content, - "meta_title": content.meta_title, - "meta_description": content.meta_description, - "meta_keywords": content.meta_keywords, - "og_title": content.og_title, - "og_description": content.og_description, - "og_image": content.og_image, - "canonical_url": content.canonical_url, - "hero_title": content.hero_title, - "hero_subtitle": content.hero_subtitle, - "hero_image": content.hero_image, - "amenities_section_title": content.amenities_section_title, - "amenities_section_subtitle": content.amenities_section_subtitle, - "amenities": json.loads(content.amenities) if content.amenities else None, - "testimonials_section_title": content.testimonials_section_title, - "testimonials_section_subtitle": content.testimonials_section_subtitle, - "testimonials": json.loads(content.testimonials) if content.testimonials else None, - "gallery_section_title": content.gallery_section_title, - "gallery_section_subtitle": content.gallery_section_subtitle, - "gallery_images": json.loads(content.gallery_images) if content.gallery_images else None, - "luxury_section_title": content.luxury_section_title, - "luxury_section_subtitle": content.luxury_section_subtitle, - "luxury_section_image": content.luxury_section_image, - "luxury_features": json.loads(content.luxury_features) if content.luxury_features else None, - "luxury_gallery_section_title": content.luxury_gallery_section_title, - "luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle, - "luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None, - "luxury_testimonials_section_title": content.luxury_testimonials_section_title, - "luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle, - "luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, - "about_preview_title": content.about_preview_title, - "about_preview_subtitle": content.about_preview_subtitle, - "about_preview_content": content.about_preview_content, - "about_preview_image": content.about_preview_image, - "stats": json.loads(content.stats) if content.stats else None, - "luxury_services_section_title": content.luxury_services_section_title, - "luxury_services_section_subtitle": content.luxury_services_section_subtitle, - "luxury_services": json.loads(content.luxury_services) if content.luxury_services else None, - "luxury_experiences_section_title": content.luxury_experiences_section_title, - "luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle, - "luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None, - "awards_section_title": content.awards_section_title, - "awards_section_subtitle": content.awards_section_subtitle, - "awards": json.loads(content.awards) if content.awards else None, - "cta_title": content.cta_title, - "cta_subtitle": content.cta_subtitle, - "cta_button_text": content.cta_button_text, - "cta_button_link": content.cta_button_link, - "cta_image": content.cta_image, - "partners_section_title": content.partners_section_title, - "partners_section_subtitle": content.partners_section_subtitle, - "partners": json.loads(content.partners) if content.partners else None, - "is_active": content.is_active, - "created_at": content.created_at.isoformat() if content.created_at else None, - "updated_at": content.updated_at.isoformat() if content.updated_at else None, - } + return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} - -@router.get("/") -async def get_home_content( - db: Session = Depends(get_db) -): - """Get homepage content""" +@router.get('/') +async def get_home_content(db: Session=Depends(get_db)): try: content = db.query(PageContent).filter(PageContent.page_type == PageType.HOME).first() - if not content: - return { - "status": "success", - "data": { - "page_content": None - } - } - + return {'status': 'success', 'data': {'page_content': None}} content_dict = serialize_page_content(content) - - return { - "status": "success", - "data": { - "page_content": content_dict - } - } + return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: - logger.error(f"Error fetching home content: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching home content: {str(e)}" - ) - + logger.error(f'Error fetching home content: {str(e)}', exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching home content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/invoice_routes.py b/Backend/src/routes/invoice_routes.py index c121b123..8fea1dae 100644 --- a/Backend/src/routes/invoice_routes.py +++ b/Backend/src/routes/invoice_routes.py @@ -2,139 +2,60 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional from datetime import datetime - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.invoice import Invoice, InvoiceStatus from ..models.booking import Booking from ..services.invoice_service import InvoiceService +router = APIRouter(prefix='/invoices', tags=['invoices']) -router = APIRouter(prefix="/invoices", tags=["invoices"]) - - -@router.get("/") -async def get_invoices( - booking_id: Optional[int] = Query(None), - status_filter: Optional[str] = Query(None, alias="status"), - page: int = Query(1, ge=1), - limit: int = Query(10, ge=1, le=100), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get invoices for current user (or all invoices for admin)""" +@router.get('/') +async def get_invoices(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Admin can see all invoices, users can only see their own user_id = None if current_user.role_id == 1 else current_user.id - - result = InvoiceService.get_invoices( - db=db, - user_id=user_id, - booking_id=booking_id, - status=status_filter, - page=page, - limit=limit - ) - - return { - "status": "success", - "data": result - } + 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} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}") -async def get_invoice_by_id( - id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get invoice by ID""" +@router.get('/{id}') +async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: invoice = InvoiceService.get_invoice(id, db) - if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Check access: admin can see all, users can only see their own - if current_user.role_id != 1 and invoice["user_id"] != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") - - return { - "status": "success", - "data": {"invoice": invoice} - } + raise HTTPException(status_code=404, detail='Invoice not found') + if current_user.role_id != 1 and invoice['user_id'] != current_user.id: + raise HTTPException(status_code=403, detail='Forbidden') + return {'status': 'success', 'data': {'invoice': invoice}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/") -async def create_invoice( - invoice_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create a new invoice from a booking (Admin/Staff only)""" +@router.post('/') +async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Only admin/staff can create invoices if current_user.role_id not in [1, 2]: - raise HTTPException(status_code=403, detail="Forbidden") - - booking_id = invoice_data.get("booking_id") + raise HTTPException(status_code=403, detail='Forbidden') + booking_id = invoice_data.get('booking_id') if not booking_id: - raise HTTPException(status_code=400, detail="booking_id is required") - - # Ensure booking_id is an integer + raise HTTPException(status_code=400, detail='booking_id is required') try: booking_id = int(booking_id) except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="booking_id must be a valid integer") - - # Check if booking exists + raise HTTPException(status_code=400, detail='booking_id must be a valid integer') booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - - # Prepare invoice kwargs - invoice_kwargs = { - "company_name": invoice_data.get("company_name"), - "company_address": invoice_data.get("company_address"), - "company_phone": invoice_data.get("company_phone"), - "company_email": invoice_data.get("company_email"), - "company_tax_id": invoice_data.get("company_tax_id"), - "company_logo_url": invoice_data.get("company_logo_url"), - "customer_tax_id": invoice_data.get("customer_tax_id"), - "notes": invoice_data.get("notes"), - "terms_and_conditions": invoice_data.get("terms_and_conditions"), - "payment_instructions": invoice_data.get("payment_instructions"), - } - - # Add promotion code to invoice notes if present in booking - invoice_notes = invoice_kwargs.get("notes", "") + raise HTTPException(status_code=404, detail='Booking not found') + invoice_kwargs = {'company_name': invoice_data.get('company_name'), 'company_address': invoice_data.get('company_address'), 'company_phone': invoice_data.get('company_phone'), 'company_email': invoice_data.get('company_email'), 'company_tax_id': invoice_data.get('company_tax_id'), 'company_logo_url': invoice_data.get('company_logo_url'), 'customer_tax_id': invoice_data.get('customer_tax_id'), 'notes': invoice_data.get('notes'), 'terms_and_conditions': invoice_data.get('terms_and_conditions'), 'payment_instructions': invoice_data.get('payment_instructions')} + invoice_notes = invoice_kwargs.get('notes', '') if booking.promotion_code: - promotion_note = f"Promotion Code: {booking.promotion_code}" - invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note - invoice_kwargs["notes"] = invoice_notes - - # Create invoice - 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} - } + promotion_note = f'Promotion Code: {booking.promotion_code}' + invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note + invoice_kwargs['notes'] = invoice_notes + invoice = InvoiceService.create_invoice_from_booking(booking_id=booking_id, db=db, created_by_id=current_user.id, tax_rate=invoice_data.get('tax_rate', 0.0), discount_amount=invoice_data.get('discount_amount', 0.0), due_days=invoice_data.get('due_days', 30), **invoice_kwargs) + return {'status': 'success', 'message': 'Invoice created successfully', 'data': {'invoice': invoice}} except HTTPException: raise except ValueError as e: @@ -142,33 +63,14 @@ async def create_invoice( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}") -async def update_invoice( - id: int, - invoice_data: dict, - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Update an invoice (Admin/Staff only)""" +@router.put('/{id}') +async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: invoice = db.query(Invoice).filter(Invoice.id == id).first() if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - - # Update invoice - 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} - } + raise HTTPException(status_code=404, detail='Invoice not found') + updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, **invoice_data) + return {'status': 'success', 'message': 'Invoice updated successfully', 'data': {'invoice': updated_invoice}} except HTTPException: raise except ValueError as e: @@ -176,30 +78,12 @@ async def update_invoice( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/{id}/mark-paid") -async def mark_invoice_as_paid( - id: int, - payment_data: dict, - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Mark an invoice as paid (Admin/Staff only)""" +@router.post('/{id}/mark-paid') +async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: - amount = payment_data.get("amount") - - updated_invoice = InvoiceService.mark_invoice_as_paid( - invoice_id=id, - db=db, - amount=amount, - updated_by_id=current_user.id - ) - - return { - "status": "success", - "message": "Invoice marked as paid successfully", - "data": {"invoice": updated_invoice} - } + amount = payment_data.get('amount') + updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id) + return {'status': 'success', 'message': 'Invoice marked as paid successfully', 'data': {'invoice': updated_invoice}} except HTTPException: raise except ValueError as e: @@ -207,61 +91,32 @@ async def mark_invoice_as_paid( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.delete("/{id}") -async def delete_invoice( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete an invoice (Admin only)""" +@router.delete('/{id}') +async def delete_invoice(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: invoice = db.query(Invoice).filter(Invoice.id == id).first() if not invoice: - raise HTTPException(status_code=404, detail="Invoice not found") - + raise HTTPException(status_code=404, detail='Invoice not found') db.delete(invoice) db.commit() - - return { - "status": "success", - "message": "Invoice deleted successfully" - } + return {'status': 'success', 'message': 'Invoice deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/booking/{booking_id}") -async def get_invoices_by_booking( - booking_id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get all invoices for a specific booking""" +@router.get('/booking/{booking_id}') +async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Check if booking exists and user has access booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - - # Check access: admin can see all, users can only see their own bookings + raise HTTPException(status_code=404, detail='Booking not found') if current_user.role_id != 1 and booking.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") - - result = InvoiceService.get_invoices( - db=db, - booking_id=booking_id - ) - - return { - "status": "success", - "data": result - } + raise HTTPException(status_code=403, detail='Forbidden') + result = InvoiceService.get_invoices(db=db, booking_id=booking_id) + return {'status': 'success', 'data': result} except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/page_content_routes.py b/Backend/src/routes/page_content_routes.py index bebef6eb..90136046 100644 --- a/Backend/src/routes/page_content_routes.py +++ b/Backend/src/routes/page_content_routes.py @@ -7,425 +7,123 @@ import json import os import aiofiles import uuid - from ..config.database import get_db from ..config.logging_config import get_logger from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.page_content import PageContent, PageType - logger = get_logger(__name__) +router = APIRouter(prefix='/page-content', tags=['page-content']) -router = APIRouter(prefix="/page-content", tags=["page-content"]) - - -@router.get("/") -async def get_all_page_contents( - db: Session = Depends(get_db) -): - """Get all page contents""" +@router.get('/') +async def get_all_page_contents(db: Session=Depends(get_db)): try: contents = db.query(PageContent).all() result = [] for content in contents: - content_dict = { - "id": content.id, - "page_type": content.page_type.value, - "title": content.title, - "subtitle": content.subtitle, - "description": content.description, - "content": content.content, - "meta_title": content.meta_title, - "meta_description": content.meta_description, - "meta_keywords": content.meta_keywords, - "og_title": content.og_title, - "og_description": content.og_description, - "og_image": content.og_image, - "canonical_url": content.canonical_url, - "contact_info": json.loads(content.contact_info) if content.contact_info else None, - "map_url": content.map_url, - "social_links": json.loads(content.social_links) if content.social_links else None, - "footer_links": json.loads(content.footer_links) if content.footer_links else None, - "badges": json.loads(content.badges) if content.badges else None, - "copyright_text": content.copyright_text, - "hero_title": content.hero_title, - "hero_subtitle": content.hero_subtitle, - "hero_image": content.hero_image, - "story_content": content.story_content, - "values": json.loads(content.values) if content.values else None, - "features": json.loads(content.features) if content.features else None, - "about_hero_image": content.about_hero_image, - "mission": content.mission, - "vision": content.vision, - "team": json.loads(content.team) if content.team else None, - "timeline": json.loads(content.timeline) if content.timeline else None, - "achievements": json.loads(content.achievements) if content.achievements else None, - "amenities_section_title": content.amenities_section_title, - "amenities_section_subtitle": content.amenities_section_subtitle, - "amenities": json.loads(content.amenities) if content.amenities else None, - "testimonials_section_title": content.testimonials_section_title, - "testimonials_section_subtitle": content.testimonials_section_subtitle, - "testimonials": json.loads(content.testimonials) if content.testimonials else None, - "gallery_section_title": content.gallery_section_title, - "gallery_section_subtitle": content.gallery_section_subtitle, - "gallery_images": json.loads(content.gallery_images) if content.gallery_images else None, - "luxury_section_title": content.luxury_section_title, - "luxury_section_subtitle": content.luxury_section_subtitle, - "luxury_section_image": content.luxury_section_image, - "luxury_features": json.loads(content.luxury_features) if content.luxury_features else None, - "luxury_gallery_section_title": content.luxury_gallery_section_title, - "luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle, - "luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None, - "luxury_testimonials_section_title": content.luxury_testimonials_section_title, - "luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle, - "luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, - "about_preview_title": content.about_preview_title, - "about_preview_subtitle": content.about_preview_subtitle, - "about_preview_content": content.about_preview_content, - "about_preview_image": content.about_preview_image, - "stats": json.loads(content.stats) if content.stats else None, - "luxury_services_section_title": content.luxury_services_section_title, - "luxury_services_section_subtitle": content.luxury_services_section_subtitle, - "luxury_services": json.loads(content.luxury_services) if content.luxury_services else None, - "luxury_experiences_section_title": content.luxury_experiences_section_title, - "luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle, - "luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None, - "awards_section_title": content.awards_section_title, - "awards_section_subtitle": content.awards_section_subtitle, - "awards": json.loads(content.awards) if content.awards else None, - "cta_title": content.cta_title, - "cta_subtitle": content.cta_subtitle, - "cta_button_text": content.cta_button_text, - "cta_button_link": content.cta_button_link, - "cta_image": content.cta_image, - "partners_section_title": content.partners_section_title, - "partners_section_subtitle": content.partners_section_subtitle, - "partners": json.loads(content.partners) if content.partners else None, - "is_active": content.is_active, - "created_at": content.created_at.isoformat() if content.created_at else None, - "updated_at": content.updated_at.isoformat() if content.updated_at else None, - } + content_dict = {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} result.append(content_dict) - - return { - "status": "success", - "data": { - "page_contents": result - } - } + return {'status': 'success', 'data': {'page_contents': result}} except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching page contents: {str(e)}" - ) - + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page contents: {str(e)}') def get_base_url(request: Request) -> str: - """Get base URL for image normalization""" - return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:8000')}" - + return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}' def normalize_image_url(image_url: str, base_url: str) -> str: - """Normalize image URL to absolute URL""" if not image_url: return image_url if image_url.startswith('http://') or image_url.startswith('https://'): return image_url if image_url.startswith('/'): - return f"{base_url}{image_url}" - return f"{base_url}/{image_url}" + return f'{base_url}{image_url}' + return f'{base_url}/{image_url}' - -@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))]) -async def upload_page_content_image( - request: Request, - image: UploadFile = File(...), - current_user: User = Depends(authorize_roles("admin")), -): - """Upload page content image (Admin only)""" +@router.post('/upload', dependencies=[Depends(authorize_roles('admin'))]) +async def upload_page_content_image(request: Request, image: UploadFile=File(...), current_user: User=Depends(authorize_roles('admin'))): try: - logger.info(f"Upload request received: filename={image.filename}, content_type={image.content_type}") - - # Validate file exists + logger.info(f'Upload request received: filename={image.filename}, content_type={image.content_type}') if not image: - logger.error("No file provided in upload request") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="No file provided" - ) - - # Validate file type + logger.error('No file provided in upload request') + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No file provided') if not image.content_type or not image.content_type.startswith('image/'): - logger.error(f"Invalid file type: {image.content_type}") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"File must be an image. Received: {image.content_type}" - ) - - # Validate filename + logger.error(f'Invalid file type: {image.content_type}') + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'File must be an image. Received: {image.content_type}') if not image.filename: - logger.error("No filename provided in upload request") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Filename is required" - ) - - # Create uploads directory - upload_dir = Path(__file__).parent.parent.parent / "uploads" / "page-content" + logger.error('No filename provided in upload request') + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required') + upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'page-content' upload_dir.mkdir(parents=True, exist_ok=True) - logger.info(f"Upload directory: {upload_dir}") - - # Generate filename + logger.info(f'Upload directory: {upload_dir}') ext = Path(image.filename).suffix or '.jpg' - filename = f"page-content-{uuid.uuid4()}{ext}" + filename = f'page-content-{uuid.uuid4()}{ext}' file_path = upload_dir / filename - - # Save file async with aiofiles.open(file_path, 'wb') as f: content = await image.read() if not content: - logger.error("Empty file uploaded") - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="File is empty" - ) + logger.error('Empty file uploaded') + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty') await f.write(content) - logger.info(f"File saved successfully: {file_path}, size: {len(content)} bytes") - - # Return the image URL - image_url = f"/uploads/page-content/{filename}" + logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes') + image_url = f'/uploads/page-content/{filename}' base_url = get_base_url(request) full_url = normalize_image_url(image_url, base_url) - - logger.info(f"Upload successful: {image_url}") - return { - "success": True, - "status": "success", - "message": "Image uploaded successfully", - "data": { - "image_url": image_url, - "full_url": full_url - } - } + logger.info(f'Upload successful: {image_url}') + return {'success': True, 'status': 'success', 'message': 'Image uploaded successfully', 'data': {'image_url': image_url, 'full_url': full_url}} except HTTPException as e: - logger.error(f"HTTPException in upload: {e.detail}") + logger.error(f'HTTPException in upload: {e.detail}') raise except Exception as e: - logger.error(f"Unexpected error uploading image: {str(e)}", exc_info=True) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error uploading image: {str(e)}" - ) + logger.error(f'Unexpected error uploading image: {str(e)}', exc_info=True) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error uploading image: {str(e)}') - -@router.get("/{page_type}") -async def get_page_content( - page_type: PageType, - db: Session = Depends(get_db) -): - """Get content for a specific page""" +@router.get('/{page_type}') +async def get_page_content(page_type: PageType, db: Session=Depends(get_db)): try: content = db.query(PageContent).filter(PageContent.page_type == page_type).first() - if not content: - # Return default structure if not found - return { - "status": "success", - "data": { - "page_content": None - } - } - - content_dict = { - "id": content.id, - "page_type": content.page_type.value, - "title": content.title, - "subtitle": content.subtitle, - "description": content.description, - "content": content.content, - "meta_title": content.meta_title, - "meta_description": content.meta_description, - "meta_keywords": content.meta_keywords, - "og_title": content.og_title, - "og_description": content.og_description, - "og_image": content.og_image, - "canonical_url": content.canonical_url, - "contact_info": json.loads(content.contact_info) if content.contact_info else None, - "map_url": content.map_url, - "social_links": json.loads(content.social_links) if content.social_links else None, - "footer_links": json.loads(content.footer_links) if content.footer_links else None, - "badges": json.loads(content.badges) if content.badges else None, - "copyright_text": content.copyright_text, - "hero_title": content.hero_title, - "hero_subtitle": content.hero_subtitle, - "hero_image": content.hero_image, - "story_content": content.story_content, - "values": json.loads(content.values) if content.values else None, - "features": json.loads(content.features) if content.features else None, - "about_hero_image": content.about_hero_image, - "mission": content.mission, - "vision": content.vision, - "team": json.loads(content.team) if content.team else None, - "timeline": json.loads(content.timeline) if content.timeline else None, - "achievements": json.loads(content.achievements) if content.achievements else None, - "amenities_section_title": content.amenities_section_title, - "amenities_section_subtitle": content.amenities_section_subtitle, - "amenities": json.loads(content.amenities) if content.amenities else None, - "testimonials_section_title": content.testimonials_section_title, - "testimonials_section_subtitle": content.testimonials_section_subtitle, - "testimonials": json.loads(content.testimonials) if content.testimonials else None, - "gallery_section_title": content.gallery_section_title, - "gallery_section_subtitle": content.gallery_section_subtitle, - "gallery_images": json.loads(content.gallery_images) if content.gallery_images else None, - "luxury_section_title": content.luxury_section_title, - "luxury_section_subtitle": content.luxury_section_subtitle, - "luxury_section_image": content.luxury_section_image, - "luxury_features": json.loads(content.luxury_features) if content.luxury_features else None, - "luxury_gallery_section_title": content.luxury_gallery_section_title, - "luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle, - "luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None, - "luxury_testimonials_section_title": content.luxury_testimonials_section_title, - "luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle, - "luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, - "about_preview_title": content.about_preview_title, - "about_preview_subtitle": content.about_preview_subtitle, - "about_preview_content": content.about_preview_content, - "about_preview_image": content.about_preview_image, - "stats": json.loads(content.stats) if content.stats else None, - "luxury_services_section_title": content.luxury_services_section_title, - "luxury_services_section_subtitle": content.luxury_services_section_subtitle, - "luxury_services": json.loads(content.luxury_services) if content.luxury_services else None, - "luxury_experiences_section_title": content.luxury_experiences_section_title, - "luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle, - "luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None, - "awards_section_title": content.awards_section_title, - "awards_section_subtitle": content.awards_section_subtitle, - "awards": json.loads(content.awards) if content.awards else None, - "cta_title": content.cta_title, - "cta_subtitle": content.cta_subtitle, - "cta_button_text": content.cta_button_text, - "cta_button_link": content.cta_button_link, - "cta_image": content.cta_image, - "partners_section_title": content.partners_section_title, - "partners_section_subtitle": content.partners_section_subtitle, - "partners": json.loads(content.partners) if content.partners else None, - "is_active": content.is_active, - "created_at": content.created_at.isoformat() if content.created_at else None, - "updated_at": content.updated_at.isoformat() if content.updated_at else None, - } - - return { - "status": "success", - "data": { - "page_content": content_dict - } - } + return {'status': 'success', 'data': {'page_content': None}} + content_dict = {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} + return {'status': 'success', 'data': {'page_content': content_dict}} except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error fetching page content: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page content: {str(e)}') - -@router.post("/{page_type}") -async def create_or_update_page_content( - page_type: PageType, - title: Optional[str] = None, - subtitle: Optional[str] = None, - description: Optional[str] = None, - content: Optional[str] = None, - meta_title: Optional[str] = None, - meta_description: Optional[str] = None, - meta_keywords: Optional[str] = None, - og_title: Optional[str] = None, - og_description: Optional[str] = None, - og_image: Optional[str] = None, - canonical_url: Optional[str] = None, - contact_info: Optional[str] = None, - map_url: Optional[str] = None, - social_links: Optional[str] = None, - footer_links: Optional[str] = None, - badges: Optional[str] = None, - hero_title: Optional[str] = None, - hero_subtitle: Optional[str] = None, - hero_image: Optional[str] = None, - story_content: Optional[str] = None, - values: Optional[str] = None, - features: Optional[str] = None, - about_hero_image: Optional[str] = None, - mission: Optional[str] = None, - vision: Optional[str] = None, - team: Optional[str] = None, - timeline: Optional[str] = None, - achievements: Optional[str] = None, - is_active: bool = True, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create or update page content (admin only)""" +@router.post('/{page_type}') +async def create_or_update_page_content(page_type: PageType, title: Optional[str]=None, subtitle: Optional[str]=None, description: Optional[str]=None, content: Optional[str]=None, meta_title: Optional[str]=None, meta_description: Optional[str]=None, meta_keywords: Optional[str]=None, og_title: Optional[str]=None, og_description: Optional[str]=None, og_image: Optional[str]=None, canonical_url: Optional[str]=None, contact_info: Optional[str]=None, map_url: Optional[str]=None, social_links: Optional[str]=None, footer_links: Optional[str]=None, badges: Optional[str]=None, hero_title: Optional[str]=None, hero_subtitle: Optional[str]=None, hero_image: Optional[str]=None, story_content: Optional[str]=None, values: Optional[str]=None, features: Optional[str]=None, about_hero_image: Optional[str]=None, mission: Optional[str]=None, vision: Optional[str]=None, team: Optional[str]=None, timeline: Optional[str]=None, achievements: Optional[str]=None, is_active: bool=True, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - authorize_roles(current_user, ["admin"]) - - # Validate JSON fields if provided + authorize_roles(current_user, ['admin']) if contact_info: try: json.loads(contact_info) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON in contact_info" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid JSON in contact_info') if social_links: try: json.loads(social_links) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON in social_links" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid JSON in social_links') if footer_links: try: json.loads(footer_links) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON in footer_links" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid JSON in footer_links') if badges: try: json.loads(badges) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON in badges" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid JSON in badges') if values: try: json.loads(values) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON in values" - ) - + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid JSON in values') if features: try: json.loads(features) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid JSON in features" - ) - - # Check if content exists + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Invalid JSON in features') existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() - if existing_content: - # Update existing if title is not None: existing_content.title = title if subtitle is not None: @@ -484,224 +182,49 @@ async def create_or_update_page_content( existing_content.achievements = achievements if is_active is not None: existing_content.is_active = is_active - existing_content.updated_at = datetime.utcnow() db.commit() db.refresh(existing_content) - - return { - "status": "success", - "message": "Page content updated successfully", - "data": { - "page_content": { - "id": existing_content.id, - "page_type": existing_content.page_type.value, - "title": existing_content.title, - "updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None, - } - } - } + return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None}}} else: - # Create new - new_content = PageContent( - page_type=page_type, - title=title, - subtitle=subtitle, - description=description, - content=content, - meta_title=meta_title, - meta_description=meta_description, - meta_keywords=meta_keywords, - og_title=og_title, - og_description=og_description, - og_image=og_image, - canonical_url=canonical_url, - contact_info=contact_info, - map_url=map_url, - social_links=social_links, - footer_links=footer_links, - badges=badges, - hero_title=hero_title, - hero_subtitle=hero_subtitle, - hero_image=hero_image, - story_content=story_content, - values=values, - features=features, - about_hero_image=about_hero_image, - mission=mission, - vision=vision, - team=team, - timeline=timeline, - achievements=achievements, - is_active=is_active, - ) - + new_content = PageContent(page_type=page_type, title=title, subtitle=subtitle, description=description, content=content, meta_title=meta_title, meta_description=meta_description, meta_keywords=meta_keywords, og_title=og_title, og_description=og_description, og_image=og_image, canonical_url=canonical_url, contact_info=contact_info, map_url=map_url, social_links=social_links, footer_links=footer_links, badges=badges, hero_title=hero_title, hero_subtitle=hero_subtitle, hero_image=hero_image, story_content=story_content, values=values, features=features, about_hero_image=about_hero_image, mission=mission, vision=vision, team=team, timeline=timeline, achievements=achievements, is_active=is_active) db.add(new_content) db.commit() db.refresh(new_content) - - return { - "status": "success", - "message": "Page content created successfully", - "data": { - "page_content": { - "id": new_content.id, - "page_type": new_content.page_type.value, - "title": new_content.title, - "created_at": new_content.created_at.isoformat() if new_content.created_at else None, - } - } - } + return {'status': 'success', 'message': 'Page content created successfully', 'data': {'page_content': {'id': new_content.id, 'page_type': new_content.page_type.value, 'title': new_content.title, 'created_at': new_content.created_at.isoformat() if new_content.created_at else None}}} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error saving page content: {str(e)}" - ) + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error saving page content: {str(e)}') - -@router.put("/{page_type}") -async def update_page_content( - page_type: PageType, - page_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create or update page content using JSON body (admin only)""" +@router.put('/{page_type}') +async def update_page_content(page_type: PageType, page_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - authorize_roles(current_user, ["admin"]) - + authorize_roles(current_user, ['admin']) existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() - if not existing_content: - # Create new content if it doesn't exist - existing_content = PageContent( - page_type=page_type, - is_active=True, - ) + existing_content = PageContent(page_type=page_type, is_active=True) db.add(existing_content) - - # Update fields from request body for key, value in page_data.items(): if hasattr(existing_content, key): - # Handle JSON fields - convert dict/list to JSON string - if key in ["contact_info", "social_links", "footer_links", "badges", "values", "features", - "amenities", "testimonials", "gallery_images", "stats", "luxury_features", - "luxury_gallery", "luxury_testimonials", "luxury_services", "luxury_experiences", - "awards", "partners", "team", "timeline", "achievements"] and value is not None: + if key in ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] and value is not None: if isinstance(value, str): - # Already a string, validate it's valid JSON try: json.loads(value) except json.JSONDecodeError: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=f"Invalid JSON in {key}" - ) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid JSON in {key}') elif isinstance(value, (dict, list)): - # Convert dict/list to JSON string for storage value = json.dumps(value) - - # Skip None values to allow partial updates if value is not None: setattr(existing_content, key, value) - existing_content.updated_at = datetime.utcnow() db.commit() db.refresh(existing_content) - - content_dict = { - "id": existing_content.id, - "page_type": existing_content.page_type.value, - "title": existing_content.title, - "subtitle": existing_content.subtitle, - "description": existing_content.description, - "content": existing_content.content, - "meta_title": existing_content.meta_title, - "meta_description": existing_content.meta_description, - "meta_keywords": existing_content.meta_keywords, - "og_title": existing_content.og_title, - "og_description": existing_content.og_description, - "og_image": existing_content.og_image, - "canonical_url": existing_content.canonical_url, - "contact_info": json.loads(existing_content.contact_info) if existing_content.contact_info else None, - "map_url": existing_content.map_url, - "social_links": json.loads(existing_content.social_links) if existing_content.social_links else None, - "footer_links": json.loads(existing_content.footer_links) if existing_content.footer_links else None, - "badges": json.loads(existing_content.badges) if existing_content.badges else None, - "copyright_text": existing_content.copyright_text, - "hero_title": existing_content.hero_title, - "hero_subtitle": existing_content.hero_subtitle, - "hero_image": existing_content.hero_image, - "story_content": existing_content.story_content, - "values": json.loads(existing_content.values) if existing_content.values else None, - "features": json.loads(existing_content.features) if existing_content.features else None, - "about_hero_image": existing_content.about_hero_image, - "mission": existing_content.mission, - "vision": existing_content.vision, - "team": json.loads(existing_content.team) if existing_content.team else None, - "timeline": json.loads(existing_content.timeline) if existing_content.timeline else None, - "achievements": json.loads(existing_content.achievements) if existing_content.achievements else None, - "amenities_section_title": existing_content.amenities_section_title, - "amenities_section_subtitle": existing_content.amenities_section_subtitle, - "amenities": json.loads(existing_content.amenities) if existing_content.amenities else None, - "testimonials_section_title": existing_content.testimonials_section_title, - "testimonials_section_subtitle": existing_content.testimonials_section_subtitle, - "testimonials": json.loads(existing_content.testimonials) if existing_content.testimonials else None, - "gallery_section_title": existing_content.gallery_section_title, - "gallery_section_subtitle": existing_content.gallery_section_subtitle, - "gallery_images": json.loads(existing_content.gallery_images) if existing_content.gallery_images else None, - "luxury_section_title": existing_content.luxury_section_title, - "luxury_section_subtitle": existing_content.luxury_section_subtitle, - "luxury_section_image": existing_content.luxury_section_image, - "luxury_features": json.loads(existing_content.luxury_features) if existing_content.luxury_features else None, - "luxury_gallery_section_title": existing_content.luxury_gallery_section_title, - "luxury_gallery_section_subtitle": existing_content.luxury_gallery_section_subtitle, - "luxury_gallery": json.loads(existing_content.luxury_gallery) if existing_content.luxury_gallery else None, - "luxury_testimonials_section_title": existing_content.luxury_testimonials_section_title, - "luxury_testimonials_section_subtitle": existing_content.luxury_testimonials_section_subtitle, - "luxury_testimonials": json.loads(existing_content.luxury_testimonials) if existing_content.luxury_testimonials else None, - "about_preview_title": existing_content.about_preview_title, - "about_preview_subtitle": existing_content.about_preview_subtitle, - "about_preview_content": existing_content.about_preview_content, - "about_preview_image": existing_content.about_preview_image, - "stats": json.loads(existing_content.stats) if existing_content.stats else None, - "luxury_services_section_title": existing_content.luxury_services_section_title, - "luxury_services_section_subtitle": existing_content.luxury_services_section_subtitle, - "luxury_services": json.loads(existing_content.luxury_services) if existing_content.luxury_services else None, - "luxury_experiences_section_title": existing_content.luxury_experiences_section_title, - "luxury_experiences_section_subtitle": existing_content.luxury_experiences_section_subtitle, - "luxury_experiences": json.loads(existing_content.luxury_experiences) if existing_content.luxury_experiences else None, - "awards_section_title": existing_content.awards_section_title, - "awards_section_subtitle": existing_content.awards_section_subtitle, - "awards": json.loads(existing_content.awards) if existing_content.awards else None, - "cta_title": existing_content.cta_title, - "cta_subtitle": existing_content.cta_subtitle, - "cta_button_text": existing_content.cta_button_text, - "cta_button_link": existing_content.cta_button_link, - "cta_image": existing_content.cta_image, - "partners_section_title": existing_content.partners_section_title, - "partners_section_subtitle": existing_content.partners_section_subtitle, - "partners": json.loads(existing_content.partners) if existing_content.partners else None, - "is_active": existing_content.is_active, - "updated_at": existing_content.updated_at.isoformat() if existing_content.updated_at else None, - } - - return { - "status": "success", - "message": "Page content updated successfully", - "data": { - "page_content": content_dict - } - } + content_dict = {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'subtitle': existing_content.subtitle, 'description': existing_content.description, 'content': existing_content.content, 'meta_title': existing_content.meta_title, 'meta_description': existing_content.meta_description, 'meta_keywords': existing_content.meta_keywords, 'og_title': existing_content.og_title, 'og_description': existing_content.og_description, 'og_image': existing_content.og_image, 'canonical_url': existing_content.canonical_url, 'contact_info': json.loads(existing_content.contact_info) if existing_content.contact_info else None, 'map_url': existing_content.map_url, 'social_links': json.loads(existing_content.social_links) if existing_content.social_links else None, 'footer_links': json.loads(existing_content.footer_links) if existing_content.footer_links else None, 'badges': json.loads(existing_content.badges) if existing_content.badges else None, 'copyright_text': existing_content.copyright_text, 'hero_title': existing_content.hero_title, 'hero_subtitle': existing_content.hero_subtitle, 'hero_image': existing_content.hero_image, 'story_content': existing_content.story_content, 'values': json.loads(existing_content.values) if existing_content.values else None, 'features': json.loads(existing_content.features) if existing_content.features else None, 'about_hero_image': existing_content.about_hero_image, 'mission': existing_content.mission, 'vision': existing_content.vision, 'team': json.loads(existing_content.team) if existing_content.team else None, 'timeline': json.loads(existing_content.timeline) if existing_content.timeline else None, 'achievements': json.loads(existing_content.achievements) if existing_content.achievements else None, 'amenities_section_title': existing_content.amenities_section_title, 'amenities_section_subtitle': existing_content.amenities_section_subtitle, 'amenities': json.loads(existing_content.amenities) if existing_content.amenities else None, 'testimonials_section_title': existing_content.testimonials_section_title, 'testimonials_section_subtitle': existing_content.testimonials_section_subtitle, 'testimonials': json.loads(existing_content.testimonials) if existing_content.testimonials else None, 'gallery_section_title': existing_content.gallery_section_title, 'gallery_section_subtitle': existing_content.gallery_section_subtitle, 'gallery_images': json.loads(existing_content.gallery_images) if existing_content.gallery_images else None, 'luxury_section_title': existing_content.luxury_section_title, 'luxury_section_subtitle': existing_content.luxury_section_subtitle, 'luxury_section_image': existing_content.luxury_section_image, 'luxury_features': json.loads(existing_content.luxury_features) if existing_content.luxury_features else None, 'luxury_gallery_section_title': existing_content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': existing_content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(existing_content.luxury_gallery) if existing_content.luxury_gallery else None, 'luxury_testimonials_section_title': existing_content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': existing_content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(existing_content.luxury_testimonials) if existing_content.luxury_testimonials else None, 'about_preview_title': existing_content.about_preview_title, 'about_preview_subtitle': existing_content.about_preview_subtitle, 'about_preview_content': existing_content.about_preview_content, 'about_preview_image': existing_content.about_preview_image, 'stats': json.loads(existing_content.stats) if existing_content.stats else None, 'luxury_services_section_title': existing_content.luxury_services_section_title, 'luxury_services_section_subtitle': existing_content.luxury_services_section_subtitle, 'luxury_services': json.loads(existing_content.luxury_services) if existing_content.luxury_services else None, 'luxury_experiences_section_title': existing_content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': existing_content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(existing_content.luxury_experiences) if existing_content.luxury_experiences else None, 'awards_section_title': existing_content.awards_section_title, 'awards_section_subtitle': existing_content.awards_section_subtitle, 'awards': json.loads(existing_content.awards) if existing_content.awards else None, 'cta_title': existing_content.cta_title, 'cta_subtitle': existing_content.cta_subtitle, 'cta_button_text': existing_content.cta_button_text, 'cta_button_link': existing_content.cta_button_link, 'cta_image': existing_content.cta_image, 'partners_section_title': existing_content.partners_section_title, 'partners_section_subtitle': existing_content.partners_section_subtitle, 'partners': json.loads(existing_content.partners) if existing_content.partners else None, 'is_active': existing_content.is_active, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None} + return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': content_dict}} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"Error updating page content: {str(e)}" - ) - + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error updating page content: {str(e)}') \ No newline at end of file diff --git a/Backend/src/routes/payment_routes.py b/Backend/src/routes/payment_routes.py index 699b91d4..1fd7cd2f 100644 --- a/Backend/src/routes/payment_routes.py +++ b/Backend/src/routes/payment_routes.py @@ -3,7 +3,6 @@ from sqlalchemy.orm import Session, joinedload, selectinload from typing import Optional from datetime import datetime import os - from ..config.database import get_db from ..config.settings import settings from ..middleware.auth import get_current_user, authorize_roles @@ -14,945 +13,415 @@ from ..utils.mailer import send_email from ..utils.email_templates import payment_confirmation_email_template, booking_status_changed_email_template from ..services.stripe_service import StripeService from ..services.paypal_service import PayPalService +router = APIRouter(prefix='/payments', tags=['payments']) -router = APIRouter(prefix="/payments", tags=["payments"]) - - -async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str = "Payment failed or canceled"): - """ - Helper function to cancel a booking when payment fails or is canceled. - This bypasses the normal cancellation restrictions and sends cancellation email. - """ +async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'): if booking.status == BookingStatus.cancelled: - return # Already cancelled - + return + from sqlalchemy.orm import selectinload + booking = db.query(Booking).options(selectinload(Booking.payments)).filter(Booking.id == booking.id).first() + if booking.payments: + for payment in booking.payments: + if payment.payment_status == PaymentStatus.pending: + payment.payment_status = PaymentStatus.failed + existing_notes = payment.notes or '' + cancellation_note = f'\nPayment cancelled due to booking cancellation: {reason} on {datetime.utcnow().isoformat()}' + payment.notes = existing_notes + cancellation_note if existing_notes else cancellation_note.strip() booking.status = BookingStatus.cancelled db.commit() db.refresh(booking) - - # Send cancellation email (non-blocking) try: from ..models.system_settings import SystemSettings - - # Get client URL from settings - 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_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') if booking.user: - email_html = booking_status_changed_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - status="cancelled", - client_url=client_url - ) - await send_email( - to=booking.user.email, - subject=f"Booking Cancelled - {booking.booking_number}", - html=email_html - ) + email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url) + await send_email(to=booking.user.email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Failed to send cancellation email: {e}") + logger.error(f'Failed to send cancellation email: {e}') - -@router.get("/") -async def get_payments( - booking_id: Optional[int] = Query(None), - status_filter: Optional[str] = Query(None, alias="status"), - page: int = Query(1, ge=1), - limit: int = Query(10, ge=1, le=100), - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get all payments""" +@router.get('/') +async def get_payments(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Build base query if booking_id: query = db.query(Payment).filter(Payment.booking_id == booking_id) else: query = db.query(Payment) - - # Filter by status if status_filter: try: query = query.filter(Payment.payment_status == PaymentStatus(status_filter)) except ValueError: pass - - # Users can only see their own payments unless admin - if current_user.role_id != 1: # Not admin + if current_user.role_id != 1: query = query.join(Booking).filter(Booking.user_id == current_user.id) - - # Get total count before applying eager loading total = query.count() - - # Load payments with booking and user relationships using selectinload to avoid join conflicts - query = query.options( - selectinload(Payment.booking).selectinload(Booking.user) - ) - + query = query.options(selectinload(Payment.booking).selectinload(Booking.user)) offset = (page - 1) * limit payments = query.order_by(Payment.created_at.desc()).offset(offset).limit(limit).all() - result = [] for payment in payments: - payment_dict = { - "id": payment.id, - "booking_id": payment.booking_id, - "amount": float(payment.amount) if payment.amount else 0.0, - "payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, - "payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, - "deposit_percentage": payment.deposit_percentage, - "related_payment_id": payment.related_payment_id, - "payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, - "transaction_id": payment.transaction_id, - "payment_date": payment.payment_date.isoformat() if payment.payment_date else None, - "notes": payment.notes, - "created_at": payment.created_at.isoformat() if payment.created_at else None, - } - + payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None} if payment.booking: - payment_dict["booking"] = { - "id": payment.booking.id, - "booking_number": payment.booking.booking_number, - } - # Include user information if available + payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} 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) - - return { - "status": "success", - "data": { - "payments": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'payments': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except HTTPException: raise except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Error fetching payments: {str(e)}", exc_info=True) - raise HTTPException(status_code=500, detail=f"Error fetching payments: {str(e)}") + logger.error(f'Error fetching payments: {str(e)}', exc_info=True) + raise HTTPException(status_code=500, detail=f'Error fetching payments: {str(e)}') - -@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) -): - """Get all payments for a specific booking""" +@router.get('/booking/{booking_id}') +async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Check if booking exists and user has access booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - - # Check access - users can only see their own bookings unless admin + raise HTTPException(status_code=404, detail='Booking not found') if current_user.role_id != 1 and booking.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") - - # Get all payments for this booking with user relationship - payments = db.query(Payment).options( - joinedload(Payment.booking).joinedload(Booking.user) - ).filter(Payment.booking_id == booking_id).order_by(Payment.created_at.desc()).all() - + 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() result = [] for payment in payments: - payment_dict = { - "id": payment.id, - "booking_id": payment.booking_id, - "amount": float(payment.amount) if payment.amount else 0.0, - "payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, - "payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, - "deposit_percentage": payment.deposit_percentage, - "related_payment_id": payment.related_payment_id, - "payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, - "transaction_id": payment.transaction_id, - "payment_date": payment.payment_date.isoformat() if payment.payment_date else None, - "notes": payment.notes, - "created_at": payment.created_at.isoformat() if payment.created_at else None, - } - + payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None} if payment.booking: - payment_dict["booking"] = { - "id": payment.booking.id, - "booking_number": payment.booking.booking_number, - } - # Include user information if available + payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} 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) - - return { - "status": "success", - "data": { - "payments": result - } - } + return {'status': 'success', 'data': {'payments': result}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}") -async def get_payment_by_id( - id: int, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get payment by ID""" +@router.get('/{id}') +async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: payment = db.query(Payment).filter(Payment.id == id).first() if not payment: - raise HTTPException(status_code=404, detail="Payment not found") - - # Check access - if current_user.role_id != 1: # Not admin + raise HTTPException(status_code=404, detail='Payment not found') + if current_user.role_id != 1: if payment.booking and payment.booking.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") - - payment_dict = { - "id": payment.id, - "booking_id": payment.booking_id, - "amount": float(payment.amount) if payment.amount else 0.0, - "payment_method": payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, - "payment_type": payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, - "deposit_percentage": payment.deposit_percentage, - "related_payment_id": payment.related_payment_id, - "payment_status": payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, - "transaction_id": payment.transaction_id, - "payment_date": payment.payment_date.isoformat() if payment.payment_date else None, - "notes": payment.notes, - "created_at": payment.created_at.isoformat() if payment.created_at else None, - } - + raise HTTPException(status_code=403, detail='Forbidden') + payment_dict = {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else payment.payment_method, 'payment_type': payment.payment_type.value if isinstance(payment.payment_type, PaymentType) else payment.payment_type, 'deposit_percentage': payment.deposit_percentage, 'related_payment_id': payment.related_payment_id, 'payment_status': payment.payment_status.value if isinstance(payment.payment_status, PaymentStatus) else payment.payment_status, 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None, 'notes': payment.notes, 'created_at': payment.created_at.isoformat() if payment.created_at else None} if payment.booking: - payment_dict["booking"] = { - "id": payment.booking.id, - "booking_number": payment.booking.booking_number, - } - - return { - "status": "success", - "data": {"payment": payment_dict} - } + payment_dict['booking'] = {'id': payment.booking.id, 'booking_number': payment.booking.booking_number} + return {'status': 'success', 'data': {'payment': payment_dict}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/") -async def create_payment( - payment_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create new payment""" +@router.post('/') +async def create_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - booking_id = payment_data.get("booking_id") - amount = float(payment_data.get("amount", 0)) - payment_method = payment_data.get("payment_method", "cash") - payment_type = payment_data.get("payment_type", "full") - - # Check if booking exists + booking_id = payment_data.get('booking_id') + amount = float(payment_data.get('amount', 0)) + payment_method = payment_data.get('payment_method', 'cash') + payment_type = payment_data.get('payment_type', 'full') booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - - # Check access + raise HTTPException(status_code=404, detail='Booking not found') if current_user.role_id != 1 and booking.user_id != current_user.id: - raise HTTPException(status_code=403, detail="Forbidden") - - # Create payment - 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 marked as paid, update status - if payment_data.get("mark_as_paid"): + raise HTTPException(status_code=403, detail='Forbidden') + payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if payment_data.get('mark_as_paid') else None, notes=payment_data.get('notes')) + if payment_data.get('mark_as_paid'): payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() - db.add(payment) db.commit() db.refresh(payment) - - # Send payment confirmation email if payment was marked as paid (non-blocking) if payment.payment_status == PaymentStatus.completed and booking.user: try: from ..models.system_settings import SystemSettings - - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - 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 - ) + email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, payment_type=payment.payment_type.value if payment.payment_type else None, total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) + await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Failed to send payment confirmation email: {e}") - - return { - "status": "success", - "message": "Payment created successfully", - "data": {"payment": payment} - } + logger.error(f'Failed to send payment confirmation email: {e}') + return {'status': 'success', 'message': 'Payment created successfully', 'data': {'payment': payment}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}/status", dependencies=[Depends(authorize_roles("admin", "staff"))]) -async def update_payment_status( - id: int, - status_data: dict, - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Update payment status (Admin/Staff only)""" +@router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff'))]) +async def update_payment_status(id: int, status_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: payment = db.query(Payment).filter(Payment.id == id).first() if not payment: - raise HTTPException(status_code=404, detail="Payment not found") - - status_value = status_data.get("status") + raise HTTPException(status_code=404, detail='Payment not found') + status_value = status_data.get('status') old_status = payment.payment_status - if status_value: try: new_status = PaymentStatus(status_value) payment.payment_status = new_status - - # Auto-cancel booking if payment is marked as failed or refunded if new_status in [PaymentStatus.failed, PaymentStatus.refunded]: booking = db.query(Booking).filter(Booking.id == payment.booking_id).first() if booking and booking.status != BookingStatus.cancelled: - await cancel_booking_on_payment_failure( - booking, - db, - reason=f"Payment {new_status.value}" - ) + await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}') except ValueError: - raise HTTPException(status_code=400, detail="Invalid payment status") - - if status_data.get("transaction_id"): - payment.transaction_id = status_data["transaction_id"] - - if status_data.get("mark_as_paid"): + raise HTTPException(status_code=400, detail='Invalid payment status') + if status_data.get('transaction_id'): + payment.transaction_id = status_data['transaction_id'] + if status_data.get('mark_as_paid'): payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() - db.commit() db.refresh(payment) - - # Send payment confirmation email if payment was just completed (non-blocking) if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: try: from ..models.system_settings import SystemSettings - - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - # Refresh booking relationship payment = db.query(Payment).filter(Payment.id == id).first() if payment.booking and payment.booking.user: - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - email_html = payment_confirmation_email_template( - booking_number=payment.booking.booking_number, - guest_name=payment.booking.user.full_name, - amount=float(payment.amount), - payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), - transaction_id=payment.transaction_id, - client_url=client_url, - currency_symbol=currency_symbol - ) - await send_email( - to=payment.booking.user.email, - subject=f"Payment Confirmed - {payment.booking.booking_number}", - html=email_html - ) - - # If this is a deposit payment, update booking deposit_paid status + email_html = payment_confirmation_email_template(booking_number=payment.booking.booking_number, guest_name=payment.booking.user.full_name, amount=float(payment.amount), payment_method=payment.payment_method.value if isinstance(payment.payment_method, PaymentMethod) else str(payment.payment_method), transaction_id=payment.transaction_id, client_url=client_url, currency_symbol=currency_symbol) + await send_email(to=payment.booking.user.email, subject=f'Payment Confirmed - {payment.booking.booking_number}', html=email_html) if payment.payment_type == PaymentType.deposit and payment.booking: payment.booking.deposit_paid = True - # Restore cancelled bookings or confirm pending bookings when deposit is paid if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]: payment.booking.status = BookingStatus.confirmed db.commit() - # If this is a full payment, also restore cancelled bookings elif payment.payment_type == PaymentType.full and payment.booking: - # Calculate total paid from all completed payments - total_paid = sum( - float(p.amount) for p in payment.booking.payments - if p.payment_status == PaymentStatus.completed - ) - # Confirm booking if fully paid, and restore cancelled bookings + total_paid = sum((float(p.amount) for p in payment.booking.payments if p.payment_status == PaymentStatus.completed)) if total_paid >= float(payment.booking.total_price): if payment.booking.status in [BookingStatus.pending, BookingStatus.cancelled]: payment.booking.status = BookingStatus.confirmed db.commit() except Exception as e: - print(f"Failed to send payment confirmation email: {e}") - - return { - "status": "success", - "message": "Payment status updated successfully", - "data": {"payment": payment} - } + print(f'Failed to send payment confirmation email: {e}') + return {'status': 'success', 'message': 'Payment status updated successfully', 'data': {'payment': payment}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/stripe/create-intent") -async def create_stripe_payment_intent( - intent_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create a Stripe payment intent""" +@router.post('/stripe/create-intent') +async def create_stripe_payment_intent(intent_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Check if Stripe is configured (from database or environment) from ..services.stripe_service import get_stripe_secret_key secret_key = get_stripe_secret_key(db) if not secret_key: secret_key = settings.STRIPE_SECRET_KEY - if not secret_key: - raise HTTPException( - status_code=500, - detail="Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable." - ) - - booking_id = intent_data.get("booking_id") - amount = float(intent_data.get("amount", 0)) - currency = intent_data.get("currency", "usd") - - # Log the incoming amount for debugging + raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.') + booking_id = intent_data.get('booking_id') + amount = float(intent_data.get('amount', 0)) + currency = intent_data.get('currency', 'usd') import logging logger = logging.getLogger(__name__) - logger.info(f"Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}") - + logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}') if not booking_id or amount <= 0: - raise HTTPException( - status_code=400, - detail="booking_id and amount are required" - ) - - # Validate amount is reasonable (Stripe max is $999,999.99) + raise HTTPException(status_code=400, detail='booking_id and amount are required') if amount > 999999.99: logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99") - raise HTTPException( - status_code=400, - detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments." - ) - - # Verify booking exists and user has access + raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.") booking = db.query(Booking).filter(Booking.id == booking_id).first() 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: - raise HTTPException(status_code=403, detail="Forbidden") - - # For deposit payments, verify the amount matches the deposit payment record - # This ensures users are only charged the deposit (20%) and not the full amount - 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() - + 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) - # Allow small floating point differences (0.01) if abs(amount - expected_deposit_amount) > 0.01: - logger.warning( - f"Amount mismatch for deposit payment: " - f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, " - f"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})." - ) - - # Create payment intent - intent = StripeService.create_payment_intent( - amount=amount, - currency=currency, - metadata={ - "booking_id": str(booking_id), - "booking_number": booking.booking_number, - "user_id": str(current_user.id), - }, - db=db - ) - - # Get publishable key from database or environment + 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}).') + intent = StripeService.create_payment_intent(amount=amount, currency=currency, metadata={'booking_id': str(booking_id), 'booking_number': booking.booking_number, 'user_id': str(current_user.id)}, db=db) from ..services.stripe_service import get_stripe_publishable_key publishable_key = get_stripe_publishable_key(db) if not publishable_key: publishable_key = settings.STRIPE_PUBLISHABLE_KEY - if not publishable_key: import logging logger = logging.getLogger(__name__) - logger.warning("Stripe publishable key is not configured") - raise HTTPException( - status_code=500, - detail="Stripe publishable key is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_PUBLISHABLE_KEY environment variable." - ) - - if not intent.get("client_secret"): + logger.warning('Stripe publishable key is not configured') + raise HTTPException(status_code=500, detail='Stripe publishable key is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_PUBLISHABLE_KEY environment variable.') + if not intent.get('client_secret'): import logging logger = logging.getLogger(__name__) - logger.error("Payment intent created but client_secret is missing") - raise HTTPException( - status_code=500, - detail="Failed to create payment intent. Client secret is missing." - ) - - return { - "status": "success", - "message": "Payment intent created successfully", - "data": { - "client_secret": intent["client_secret"], - "payment_intent_id": intent["id"], - "publishable_key": publishable_key, - } - } + logger.error('Payment intent created but client_secret is missing') + raise HTTPException(status_code=500, detail='Failed to create payment intent. Client secret is missing.') + return {'status': 'success', 'message': 'Payment intent created successfully', 'data': {'client_secret': intent['client_secret'], 'payment_intent_id': intent['id'], 'publishable_key': publishable_key}} except HTTPException: raise except ValueError as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Payment intent creation error: {str(e)}") + logger.error(f'Payment intent 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 payment intent: {str(e)}", exc_info=True) + logger.error(f'Unexpected error creating payment intent: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/stripe/confirm") -async def confirm_stripe_payment( - payment_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Confirm a Stripe payment""" +@router.post('/stripe/confirm') +async def confirm_stripe_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - payment_intent_id = payment_data.get("payment_intent_id") - booking_id = payment_data.get("booking_id") - + payment_intent_id = payment_data.get('payment_intent_id') + booking_id = payment_data.get('booking_id') if not payment_intent_id: - raise HTTPException( - status_code=400, - detail="payment_intent_id is required" - ) - - # Confirm payment (this commits the transaction internally) - payment = await StripeService.confirm_payment( - payment_intent_id=payment_intent_id, - db=db, - booking_id=booking_id - ) - - # Ensure the transaction is committed before proceeding - # The service method already commits, but we ensure it here too + raise HTTPException(status_code=400, detail='payment_intent_id is required') + payment = await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=booking_id) try: db.commit() except Exception: - # If already committed, this will raise an error, which we can ignore pass - - # Get fresh booking from database to get updated status (after commit) - booking = db.query(Booking).filter(Booking.id == payment["booking_id"]).first() + booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first() if booking: db.refresh(booking) - - # Send payment confirmation email (non-blocking, after commit) - # This won't affect the transaction since it's already committed if booking and booking.user: try: from ..models.system_settings import SystemSettings - - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - 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 - ) + email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='stripe', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) + await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.warning(f"Failed to send payment confirmation email: {e}") - - return { - "status": "success", - "message": "Payment confirmed successfully", - "data": { - "payment": payment, - "booking": { - "id": booking.id if booking else None, - "booking_number": booking.booking_number if booking else None, - "status": booking.status.value if booking else None, - } - } - } + 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}}} except HTTPException: db.rollback() raise except ValueError as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Payment confirmation error: {str(e)}") + logger.error(f'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 payment: {str(e)}", exc_info=True) + logger.error(f'Unexpected error confirming payment: {str(e)}', exc_info=True) db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/stripe/webhook") -async def stripe_webhook( - request: Request, - db: Session = Depends(get_db) -): - """Handle Stripe webhook events""" +@router.post('/stripe/webhook') +async def stripe_webhook(request: Request, db: Session=Depends(get_db)): try: - # Check if webhook secret is configured (from database or environment) from ..services.stripe_service import get_stripe_webhook_secret webhook_secret = get_stripe_webhook_secret(db) if not webhook_secret: webhook_secret = settings.STRIPE_WEBHOOK_SECRET - if not webhook_secret: - raise HTTPException( - status_code=503, - detail={ - "status": "error", - "message": "Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable." - } - ) - + raise HTTPException(status_code=503, detail={'status': 'error', 'message': 'Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.'}) payload = await request.body() - signature = request.headers.get("stripe-signature") - + signature = request.headers.get('stripe-signature') if not signature: - raise HTTPException( - status_code=400, - detail="Missing stripe-signature header" - ) - - result = await StripeService.handle_webhook( - payload=payload, - signature=signature, - db=db - ) - - return { - "status": "success", - "data": result - } + raise HTTPException(status_code=400, detail='Missing stripe-signature header') + result = await StripeService.handle_webhook(payload=payload, signature=signature, db=db) + return {'status': 'success', 'data': result} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/paypal/create-order") -async def create_paypal_order( - order_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create a PayPal order""" +@router.post('/paypal/create-order') +async def create_paypal_order(order_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Check if PayPal is configured from ..services.paypal_service import get_paypal_client_id, get_paypal_client_secret client_id = get_paypal_client_id(db) if not client_id: client_id = settings.PAYPAL_CLIENT_ID - client_secret = get_paypal_client_secret(db) if not client_secret: client_secret = settings.PAYPAL_CLIENT_SECRET - if not client_id or not client_secret: - raise HTTPException( - status_code=500, - detail="PayPal is not configured. Please configure PayPal settings in Admin Panel or set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables." - ) - - booking_id = order_data.get("booking_id") - amount = float(order_data.get("amount", 0)) - currency = order_data.get("currency", "USD") - + raise HTTPException(status_code=500, detail='PayPal is not configured. Please configure PayPal settings in Admin Panel or set PAYPAL_CLIENT_ID and PAYPAL_CLIENT_SECRET environment variables.') + booking_id = order_data.get('booking_id') + amount = float(order_data.get('amount', 0)) + currency = order_data.get('currency', 'USD') if not booking_id or amount <= 0: - raise HTTPException( - status_code=400, - detail="booking_id and amount are required" - ) - - # Validate amount + raise HTTPException(status_code=400, detail='booking_id and amount are required') if amount > 100000: - raise HTTPException( - status_code=400, - detail=f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000. Please contact support for large payments." - ) - - # Verify booking exists and user has access + raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000. Please contact support for large payments.") booking = db.query(Booking).filter(Booking.id == booking_id).first() 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: - raise HTTPException(status_code=403, detail="Forbidden") - - # For deposit payments, verify the amount matches the deposit payment record - # This ensures users are only charged the deposit (20%) and not the full amount - 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() - + 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) - # Allow small floating point differences (0.01) if abs(amount - expected_deposit_amount) > 0.01: import logging logger = logging.getLogger(__name__) - logger.warning( - f"Amount mismatch for deposit payment: " - f"Requested ${amount:,.2f}, Expected deposit ${expected_deposit_amount:,.2f}, " - f"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})." - ) - - # Get return URLs from request or use defaults - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") - return_url = order_data.get("return_url", f"{client_url}/payment/paypal/return") - cancel_url = order_data.get("cancel_url", f"{client_url}/payment/paypal/cancel") - - # Create PayPal order - order = PayPalService.create_order( - amount=amount, - currency=currency, - metadata={ - "booking_id": str(booking_id), - "booking_number": booking.booking_number, - "user_id": str(current_user.id), - "description": f"Hotel Booking Payment - {booking.booking_number}", - "return_url": return_url, - "cancel_url": cancel_url, - }, - db=db - ) - - if not order.get("approval_url"): - raise HTTPException( - status_code=500, - detail="Failed to create PayPal order. Approval URL is missing." - ) - - return { - "status": "success", - "message": "PayPal order created successfully", - "data": { - "order_id": order["id"], - "approval_url": order["approval_url"], - "status": order["status"], - } - } + 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}).') + client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + return_url = order_data.get('return_url', f'{client_url}/payment/paypal/return') + cancel_url = order_data.get('cancel_url', f'{client_url}/payment/paypal/cancel') + order = PayPalService.create_order(amount=amount, currency=currency, metadata={'booking_id': str(booking_id), 'booking_number': booking.booking_number, 'user_id': str(current_user.id), 'description': f'Hotel Booking Payment - {booking.booking_number}', 'return_url': return_url, 'cancel_url': cancel_url}, db=db) + if not order.get('approval_url'): + raise HTTPException(status_code=500, detail='Failed to create PayPal order. Approval URL is missing.') + return {'status': 'success', 'message': 'PayPal order created successfully', 'data': {'order_id': order['id'], 'approval_url': order['approval_url'], 'status': order['status']}} except HTTPException: raise except ValueError as e: import logging logger = logging.getLogger(__name__) - logger.error(f"PayPal order creation error: {str(e)}") + logger.error(f'PayPal order 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 PayPal order: {str(e)}", exc_info=True) + logger.error(f'Unexpected error creating PayPal order: {str(e)}', exc_info=True) raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/paypal/cancel") -async def cancel_paypal_payment( - payment_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Mark PayPal payment as failed and cancel booking when user cancels on PayPal""" +@router.post('/paypal/cancel') +async def cancel_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - booking_id = payment_data.get("booking_id") - + booking_id = payment_data.get('booking_id') if not booking_id: - raise HTTPException( - status_code=400, - detail="booking_id is required" - ) - - # Find pending PayPal payment for this booking - payment = db.query(Payment).filter( - Payment.booking_id == booking_id, - Payment.payment_method == PaymentMethod.paypal, - Payment.payment_status == PaymentStatus.pending - ).order_by(Payment.created_at.desc()).first() - - # Also check for deposit payments + raise HTTPException(status_code=400, detail='booking_id is required') + payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_method == PaymentMethod.paypal, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first() if not payment: - 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() - + 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 payment: payment.payment_status = PaymentStatus.failed db.commit() db.refresh(payment) - - # Auto-cancel booking booking = db.query(Booking).filter(Booking.id == booking_id).first() if booking and booking.status != BookingStatus.cancelled: - await cancel_booking_on_payment_failure( - booking, - db, - reason="PayPal payment canceled by user" - ) - - return { - "status": "success", - "message": "Payment canceled and booking cancelled" - } + await cancel_booking_on_payment_failure(booking, db, reason='PayPal payment canceled by user') + return {'status': 'success', 'message': 'Payment canceled and booking cancelled'} except HTTPException: db.rollback() raise @@ -960,108 +429,49 @@ async def cancel_paypal_payment( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/paypal/capture") -async def capture_paypal_payment( - payment_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Capture a PayPal payment""" +@router.post('/paypal/capture') +async def capture_paypal_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - order_id = payment_data.get("order_id") - booking_id = payment_data.get("booking_id") - + order_id = payment_data.get('order_id') + booking_id = payment_data.get('booking_id') if not order_id: - raise HTTPException( - status_code=400, - detail="order_id is required" - ) - - # Confirm payment (this commits the transaction internally) - payment = await PayPalService.confirm_payment( - order_id=order_id, - db=db, - booking_id=booking_id - ) - - # Ensure the transaction is committed + raise HTTPException(status_code=400, detail='order_id is required') + payment = await PayPalService.confirm_payment(order_id=order_id, db=db, booking_id=booking_id) try: db.commit() except Exception: pass - - # Get fresh booking from database - booking = db.query(Booking).filter(Booking.id == payment["booking_id"]).first() + booking = db.query(Booking).filter(Booking.id == payment['booking_id']).first() if booking: db.refresh(booking) - - # Send payment confirmation email (non-blocking) if booking and booking.user: try: from ..models.system_settings import SystemSettings - - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - 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 - ) + email_html = payment_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name, amount=payment['amount'], payment_method='paypal', transaction_id=payment['transaction_id'], payment_type=payment.get('payment_type'), total_price=float(booking.total_price), client_url=client_url, currency_symbol=currency_symbol) + await send_email(to=booking.user.email, subject=f'Payment Confirmed - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.warning(f"Failed to send payment confirmation email: {e}") - - return { - "status": "success", - "message": "Payment confirmed successfully", - "data": { - "payment": payment, - "booking": { - "id": booking.id if booking else None, - "booking_number": booking.booking_number if booking else None, - "status": booking.status.value if booking else None, - } - } - } + 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}}} except HTTPException: db.rollback() raise except ValueError as e: import logging logger = logging.getLogger(__name__) - logger.error(f"PayPal payment confirmation error: {str(e)}") + logger.error(f'PayPal 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 PayPal payment: {str(e)}", exc_info=True) + logger.error(f'Unexpected error confirming PayPal payment: {str(e)}', exc_info=True) db.rollback() - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/privacy_routes.py b/Backend/src/routes/privacy_routes.py index 57f5233c..96f2db44 100644 --- a/Backend/src/routes/privacy_routes.py +++ b/Backend/src/routes/privacy_routes.py @@ -1,111 +1,39 @@ from fastapi import APIRouter, Depends, Request, Response, status from sqlalchemy.orm import Session - from ..config.database import get_db from ..config.logging_config import get_logger from ..config.settings import settings from ..middleware.cookie_consent import COOKIE_CONSENT_COOKIE_NAME, _parse_consent_cookie from ..schemas.admin_privacy import PublicPrivacyConfigResponse -from ..schemas.privacy import ( - CookieCategoryPreferences, - CookieConsent, - CookieConsentResponse, - UpdateCookieConsentRequest, -) +from ..schemas.privacy import CookieCategoryPreferences, CookieConsent, CookieConsentResponse, UpdateCookieConsentRequest from ..services.privacy_admin_service import privacy_admin_service - - logger = get_logger(__name__) +router = APIRouter(prefix='/privacy', tags=['privacy']) -router = APIRouter(prefix="/privacy", tags=["privacy"]) - - -@router.get( - "/cookie-consent", - response_model=CookieConsentResponse, - status_code=status.HTTP_200_OK, -) +@router.get('/cookie-consent', response_model=CookieConsentResponse, status_code=status.HTTP_200_OK) async def get_cookie_consent(request: Request) -> CookieConsentResponse: - """ - Return the current cookie consent preferences. - Reads from the cookie (if present) or returns default (necessary only). - """ raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME) consent = _parse_consent_cookie(raw_cookie) - - # Ensure necessary is always true consent.categories.necessary = True - return CookieConsentResponse(data=consent) - -@router.post( - "/cookie-consent", - response_model=CookieConsentResponse, - status_code=status.HTTP_200_OK, -) -async def update_cookie_consent( - request: UpdateCookieConsentRequest, response: Response -) -> CookieConsentResponse: - """ - Update cookie consent preferences. - - The 'necessary' category is controlled by the server and always true. - """ - # Build categories from existing cookie (if any) so partial updates work - existing_raw = response.headers.get("cookie") # usually empty here - # We can't reliably read cookies from the response; rely on defaults. - # For the purposes of this API, we always start from defaults and then - # override with the request payload. +@router.post('/cookie-consent', response_model=CookieConsentResponse, status_code=status.HTTP_200_OK) +async def update_cookie_consent(request: UpdateCookieConsentRequest, response: Response) -> CookieConsentResponse: + existing_raw = response.headers.get('cookie') categories = CookieCategoryPreferences() - if request.analytics is not None: categories.analytics = request.analytics if request.marketing is not None: categories.marketing = request.marketing if request.preferences is not None: categories.preferences = request.preferences - - # 'necessary' enforced server-side categories.necessary = True - consent = CookieConsent(categories=categories, has_decided=True) - - # Persist consent as a secure, HttpOnly cookie - response.set_cookie( - key=COOKIE_CONSENT_COOKIE_NAME, - value=consent.model_dump_json(), - httponly=True, - secure=settings.is_production, - samesite="lax", - max_age=365 * 24 * 60 * 60, # 1 year - path="/", - ) - - logger.info( - "Cookie consent updated: analytics=%s, marketing=%s, preferences=%s", - consent.categories.analytics, - consent.categories.marketing, - consent.categories.preferences, - ) - + response.set_cookie(key=COOKIE_CONSENT_COOKIE_NAME, value=consent.model_dump_json(), httponly=True, secure=settings.is_production, samesite='lax', max_age=365 * 24 * 60 * 60, path='/') + logger.info('Cookie consent updated: analytics=%s, marketing=%s, preferences=%s', consent.categories.analytics, consent.categories.marketing, consent.categories.preferences) return CookieConsentResponse(data=consent) - -@router.get( - "/config", - response_model=PublicPrivacyConfigResponse, - status_code=status.HTTP_200_OK, -) -async def get_public_privacy_config( - db: Session = Depends(get_db), -) -> PublicPrivacyConfigResponse: - """ - Public privacy configuration for the frontend: - - Global policy flags - - Public integration IDs (e.g. GA measurement ID) - """ +@router.get('/config', response_model=PublicPrivacyConfigResponse, status_code=status.HTTP_200_OK) +async def get_public_privacy_config(db: Session=Depends(get_db)) -> PublicPrivacyConfigResponse: config = privacy_admin_service.get_public_privacy_config(db) - return PublicPrivacyConfigResponse(data=config) - - + return PublicPrivacyConfigResponse(data=config) \ No newline at end of file diff --git a/Backend/src/routes/promotion_routes.py b/Backend/src/routes/promotion_routes.py index 6a525dbf..a921e165 100644 --- a/Backend/src/routes/promotion_routes.py +++ b/Backend/src/routes/promotion_routes.py @@ -3,346 +3,158 @@ from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import Optional from datetime import datetime - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.promotion import Promotion, DiscountType +router = APIRouter(prefix='/promotions', tags=['promotions']) -router = APIRouter(prefix="/promotions", tags=["promotions"]) - - -@router.get("/") -async def get_promotions( - search: Optional[str] = Query(None), - status_filter: Optional[str] = Query(None, alias="status"), - type: Optional[str] = Query(None), - page: int = Query(1, ge=1), - limit: int = Query(10, ge=1, le=100), - db: Session = Depends(get_db) -): - """Get all promotions with filters""" +@router.get('/') +async def get_promotions(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), type: Optional[str]=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(Promotion) - - # Filter by search (code or name) if search: - query = query.filter( - or_( - Promotion.code.like(f"%{search}%"), - Promotion.name.like(f"%{search}%") - ) - ) - - # Filter by status (is_active) + query = query.filter(or_(Promotion.code.like(f'%{search}%'), Promotion.name.like(f'%{search}%'))) if status_filter: - is_active = status_filter == "active" + is_active = status_filter == 'active' query = query.filter(Promotion.is_active == is_active) - - # Filter by discount type if type: try: query = query.filter(Promotion.discount_type == DiscountType(type)) except ValueError: pass - total = query.count() offset = (page - 1) * limit promotions = query.order_by(Promotion.created_at.desc()).offset(offset).limit(limit).all() - result = [] for promo in promotions: - promo_dict = { - "id": promo.id, - "code": promo.code, - "name": promo.name, - "description": promo.description, - "discount_type": promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type, - "discount_value": float(promo.discount_value) if promo.discount_value else 0.0, - "min_booking_amount": float(promo.min_booking_amount) if promo.min_booking_amount else None, - "max_discount_amount": float(promo.max_discount_amount) if promo.max_discount_amount else None, - "start_date": promo.start_date.isoformat() if promo.start_date else None, - "end_date": promo.end_date.isoformat() if promo.end_date else None, - "usage_limit": promo.usage_limit, - "used_count": promo.used_count, - "is_active": promo.is_active, - "created_at": promo.created_at.isoformat() if promo.created_at else None, - } + promo_dict = {'id': promo.id, 'code': promo.code, 'name': promo.name, 'description': promo.description, 'discount_type': promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type, 'discount_value': float(promo.discount_value) if promo.discount_value else 0.0, 'min_booking_amount': float(promo.min_booking_amount) if promo.min_booking_amount else None, 'max_discount_amount': float(promo.max_discount_amount) if promo.max_discount_amount else None, 'start_date': promo.start_date.isoformat() if promo.start_date else None, 'end_date': promo.end_date.isoformat() if promo.end_date else None, 'usage_limit': promo.usage_limit, 'used_count': promo.used_count, 'is_active': promo.is_active, 'created_at': promo.created_at.isoformat() if promo.created_at else None} result.append(promo_dict) - - return { - "status": "success", - "data": { - "promotions": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'promotions': 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("/{code}") -async def get_promotion_by_code(code: str, db: Session = Depends(get_db)): - """Get promotion by code""" +@router.get('/{code}') +async def get_promotion_by_code(code: str, db: Session=Depends(get_db)): try: promotion = db.query(Promotion).filter(Promotion.code == code).first() if not promotion: - raise HTTPException(status_code=404, detail="Promotion not found") - - promo_dict = { - "id": promotion.id, - "code": promotion.code, - "name": promotion.name, - "description": promotion.description, - "discount_type": promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type, - "discount_value": float(promotion.discount_value) if promotion.discount_value else 0.0, - "min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None, - "max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None, - "start_date": promotion.start_date.isoformat() if promotion.start_date else None, - "end_date": promotion.end_date.isoformat() if promotion.end_date else None, - "usage_limit": promotion.usage_limit, - "used_count": promotion.used_count, - "is_active": promotion.is_active, - } - - return { - "status": "success", - "data": {"promotion": promo_dict} - } + raise HTTPException(status_code=404, detail='Promotion not found') + promo_dict = {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type, 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0.0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'is_active': promotion.is_active} + return {'status': 'success', 'data': {'promotion': promo_dict}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/validate") -async def validate_promotion( - validation_data: dict, - db: Session = Depends(get_db) -): - """Validate and apply promotion""" +@router.post('/validate') +async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)): try: - code = validation_data.get("code") - # Accept both booking_value (from frontend) and booking_amount (for backward compatibility) - booking_amount = float(validation_data.get("booking_value") or validation_data.get("booking_amount", 0)) - + code = validation_data.get('code') + booking_amount = float(validation_data.get('booking_value') or validation_data.get('booking_amount', 0)) promotion = db.query(Promotion).filter(Promotion.code == code).first() if not promotion: - raise HTTPException(status_code=404, detail="Promotion code not found") - - # Check if promotion is active + raise HTTPException(status_code=404, detail='Promotion code not found') if not promotion.is_active: - raise HTTPException(status_code=400, detail="Promotion is not active") - - # Check date validity + raise HTTPException(status_code=400, detail='Promotion is not active') now = datetime.utcnow() if promotion.start_date and now < promotion.start_date: - raise HTTPException(status_code=400, detail="Promotion is not valid at this time") + raise HTTPException(status_code=400, detail='Promotion is not valid at this time') if promotion.end_date and now > promotion.end_date: - raise HTTPException(status_code=400, detail="Promotion is not valid at this time") - - # Check usage limit + raise HTTPException(status_code=400, detail='Promotion is not valid at this time') if promotion.usage_limit and promotion.used_count >= promotion.usage_limit: - raise HTTPException(status_code=400, detail="Promotion usage limit reached") - - # Check minimum booking amount + raise HTTPException(status_code=400, detail='Promotion usage limit reached') if promotion.min_booking_amount and booking_amount < float(promotion.min_booking_amount): - raise HTTPException( - status_code=400, - detail=f"Minimum booking amount is {promotion.min_booking_amount}" - ) - - # Calculate discount + raise HTTPException(status_code=400, detail=f'Minimum booking amount is {promotion.min_booking_amount}') discount_amount = promotion.calculate_discount(booking_amount) final_amount = booking_amount - discount_amount - - return { - "success": True, - "status": "success", - "data": { - "promotion": { - "id": promotion.id, - "code": promotion.code, - "name": promotion.name, - "description": promotion.description, - "discount_type": promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type), - "discount_value": float(promotion.discount_value) if promotion.discount_value else 0, - "min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None, - "max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None, - "start_date": promotion.start_date.isoformat() if promotion.start_date else None, - "end_date": promotion.end_date.isoformat() if promotion.end_date else None, - "usage_limit": promotion.usage_limit, - "used_count": promotion.used_count, - "status": "active" if promotion.is_active else "inactive", - }, - "discount": discount_amount, - "original_amount": booking_amount, - "discount_amount": discount_amount, - "final_amount": final_amount, - }, - "message": "Promotion validated successfully" - } + return {'success': True, 'status': 'success', 'data': {'promotion': {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type), 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'status': 'active' if promotion.is_active else 'inactive'}, 'discount': discount_amount, 'original_amount': booking_amount, 'discount_amount': discount_amount, 'final_amount': final_amount}, 'message': 'Promotion validated successfully'} 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_promotion( - promotion_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Create new promotion (Admin only)""" +@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) +async def create_promotion(promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - code = promotion_data.get("code") - - # Check if code exists + code = promotion_data.get('code') existing = db.query(Promotion).filter(Promotion.code == code).first() if existing: - raise HTTPException(status_code=400, detail="Promotion code already exists") - - discount_type = promotion_data.get("discount_type") - discount_value = float(promotion_data.get("discount_value", 0)) - - # Validate discount value - if discount_type == "percentage" and discount_value > 100: - raise HTTPException( - status_code=400, - detail="Percentage discount cannot exceed 100%" - ) - - promotion = Promotion( - code=code, - name=promotion_data.get("name"), - description=promotion_data.get("description"), - discount_type=DiscountType(discount_type), - discount_value=discount_value, - min_booking_amount=float(promotion_data["min_booking_amount"]) if promotion_data.get("min_booking_amount") else None, - max_discount_amount=float(promotion_data["max_discount_amount"]) if promotion_data.get("max_discount_amount") else None, - start_date=datetime.fromisoformat(promotion_data["start_date"].replace('Z', '+00:00')) if promotion_data.get("start_date") else None, - end_date=datetime.fromisoformat(promotion_data["end_date"].replace('Z', '+00:00')) if promotion_data.get("end_date") else None, - usage_limit=promotion_data.get("usage_limit"), - used_count=0, - is_active=promotion_data.get("status") == "active" if promotion_data.get("status") else True, - ) - + raise HTTPException(status_code=400, detail='Promotion code already exists') + discount_type = promotion_data.get('discount_type') + discount_value = float(promotion_data.get('discount_value', 0)) + if discount_type == 'percentage' and discount_value > 100: + raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') + promotion = Promotion(code=code, name=promotion_data.get('name'), description=promotion_data.get('description'), discount_type=DiscountType(discount_type), discount_value=discount_value, min_booking_amount=float(promotion_data['min_booking_amount']) if promotion_data.get('min_booking_amount') else None, max_discount_amount=float(promotion_data['max_discount_amount']) if promotion_data.get('max_discount_amount') else None, start_date=datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data.get('start_date') else None, end_date=datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data.get('end_date') else None, usage_limit=promotion_data.get('usage_limit'), used_count=0, is_active=promotion_data.get('status') == 'active' if promotion_data.get('status') else True) db.add(promotion) db.commit() db.refresh(promotion) - - return { - "status": "success", - "message": "Promotion created successfully", - "data": {"promotion": promotion} - } + return {'status': 'success', 'message': 'Promotion created successfully', 'data': {'promotion': promotion}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))]) -async def update_promotion( - id: int, - promotion_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Update promotion (Admin only)""" +@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def update_promotion(id: int, promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: promotion = db.query(Promotion).filter(Promotion.id == id).first() if not promotion: - raise HTTPException(status_code=404, detail="Promotion not found") - - # Check if new code exists (excluding current) - code = promotion_data.get("code") + raise HTTPException(status_code=404, detail='Promotion not found') + code = promotion_data.get('code') if code and code != promotion.code: - existing = db.query(Promotion).filter( - Promotion.code == code, - Promotion.id != id - ).first() + existing = db.query(Promotion).filter(Promotion.code == code, Promotion.id != id).first() if existing: - raise HTTPException(status_code=400, detail="Promotion code already exists") - - # Validate discount value - discount_type = promotion_data.get("discount_type", promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type) - discount_value = promotion_data.get("discount_value") + raise HTTPException(status_code=400, detail='Promotion code already exists') + discount_type = promotion_data.get('discount_type', promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type) + discount_value = promotion_data.get('discount_value') if discount_value is not None: discount_value = float(discount_value) - if discount_type == "percentage" and discount_value > 100: - raise HTTPException( - status_code=400, - detail="Percentage discount cannot exceed 100%" - ) - - # Update fields - if "code" in promotion_data: - promotion.code = promotion_data["code"] - if "name" in promotion_data: - promotion.name = promotion_data["name"] - if "description" in promotion_data: - promotion.description = promotion_data["description"] - if "discount_type" in promotion_data: - promotion.discount_type = DiscountType(promotion_data["discount_type"]) - if "discount_value" in promotion_data: + if discount_type == 'percentage' and discount_value > 100: + raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') + if 'code' in promotion_data: + promotion.code = promotion_data['code'] + if 'name' in promotion_data: + promotion.name = promotion_data['name'] + if 'description' in promotion_data: + promotion.description = promotion_data['description'] + if 'discount_type' in promotion_data: + promotion.discount_type = DiscountType(promotion_data['discount_type']) + if 'discount_value' in promotion_data: promotion.discount_value = discount_value - if "min_booking_amount" in promotion_data: - promotion.min_booking_amount = float(promotion_data["min_booking_amount"]) if promotion_data["min_booking_amount"] else None - if "max_discount_amount" in promotion_data: - promotion.max_discount_amount = float(promotion_data["max_discount_amount"]) if promotion_data["max_discount_amount"] else None - if "start_date" in promotion_data: - promotion.start_date = datetime.fromisoformat(promotion_data["start_date"].replace('Z', '+00:00')) if promotion_data["start_date"] else None - if "end_date" in promotion_data: - promotion.end_date = datetime.fromisoformat(promotion_data["end_date"].replace('Z', '+00:00')) if promotion_data["end_date"] else None - if "usage_limit" in promotion_data: - promotion.usage_limit = promotion_data["usage_limit"] - if "status" in promotion_data: - promotion.is_active = promotion_data["status"] == "active" - + if 'min_booking_amount' in promotion_data: + promotion.min_booking_amount = float(promotion_data['min_booking_amount']) if promotion_data['min_booking_amount'] else None + if 'max_discount_amount' in promotion_data: + promotion.max_discount_amount = float(promotion_data['max_discount_amount']) if promotion_data['max_discount_amount'] else None + if 'start_date' in promotion_data: + promotion.start_date = datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data['start_date'] else None + if 'end_date' in promotion_data: + promotion.end_date = datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data['end_date'] else None + if 'usage_limit' in promotion_data: + promotion.usage_limit = promotion_data['usage_limit'] + if 'status' in promotion_data: + promotion.is_active = promotion_data['status'] == 'active' db.commit() db.refresh(promotion) - - return { - "status": "success", - "message": "Promotion updated successfully", - "data": {"promotion": promotion} - } + return {'status': 'success', 'message': 'Promotion updated successfully', 'data': {'promotion': promotion}} except HTTPException: raise 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_promotion( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete promotion (Admin only)""" +@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_promotion(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: promotion = db.query(Promotion).filter(Promotion.id == id).first() if not promotion: - raise HTTPException(status_code=404, detail="Promotion not found") - + raise HTTPException(status_code=404, detail='Promotion not found') db.delete(promotion) db.commit() - - return { - "status": "success", - "message": "Promotion deleted successfully" - } + return {'status': 'success', 'message': 'Promotion deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/report_routes.py b/Backend/src/routes/report_routes.py index bfebd8b3..4f9feb49 100644 --- a/Backend/src/routes/report_routes.py +++ b/Backend/src/routes/report_routes.py @@ -3,7 +3,6 @@ from sqlalchemy.orm import Session from sqlalchemy import func, and_ from typing import 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 @@ -12,55 +11,34 @@ from ..models.payment import Payment, PaymentStatus from ..models.room import Room from ..models.service_usage import ServiceUsage from ..models.service import Service +router = APIRouter(prefix='/reports', tags=['reports']) -router = APIRouter(prefix="/reports", tags=["reports"]) - - -@router.get("") -async def get_reports( - from_date: Optional[str] = Query(None, alias="from"), - to_date: Optional[str] = Query(None, alias="to"), - type: Optional[str] = Query(None), - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Get comprehensive reports (Admin/Staff only)""" +@router.get('') +async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_date: Optional[str]=Query(None, alias='to'), type: Optional[str]=Query(None), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: - # Parse dates if provided start_date = None end_date = None if from_date: try: - start_date = datetime.strptime(from_date, "%Y-%m-%d") + start_date = datetime.strptime(from_date, '%Y-%m-%d') except ValueError: start_date = datetime.fromisoformat(from_date.replace('Z', '+00:00')) if to_date: try: - end_date = datetime.strptime(to_date, "%Y-%m-%d") - # Set to end of day + end_date = datetime.strptime(to_date, '%Y-%m-%d') end_date = end_date.replace(hour=23, minute=59, second=59) except ValueError: end_date = datetime.fromisoformat(to_date.replace('Z', '+00:00')) - - # Base queries booking_query = db.query(Booking) payment_query = db.query(Payment).filter(Payment.payment_status == PaymentStatus.completed) - - # Apply date filters if start_date: booking_query = booking_query.filter(Booking.created_at >= start_date) payment_query = payment_query.filter(Payment.payment_date >= start_date) if end_date: booking_query = booking_query.filter(Booking.created_at <= end_date) payment_query = payment_query.filter(Payment.payment_date <= end_date) - - # Total bookings total_bookings = booking_query.count() - - # Total revenue total_revenue = payment_query.with_entities(func.sum(Payment.amount)).scalar() or 0.0 - - # Total customers (unique users with bookings) total_customers = db.query(func.count(func.distinct(Booking.user_id))).scalar() or 0 if start_date or end_date: customer_query = db.query(func.count(func.distinct(Booking.user_id))) @@ -69,415 +47,126 @@ async def get_reports( if end_date: customer_query = customer_query.filter(Booking.created_at <= end_date) total_customers = customer_query.scalar() or 0 - - # Available rooms - available_rooms = db.query(Room).filter(Room.status == "available").count() - - # Occupied rooms (rooms with active bookings) - 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 (daily breakdown) + available_rooms = db.query(Room).filter(Room.status == 'available').count() + occupied_rooms = db.query(func.count(func.distinct(Booking.room_id))).filter(Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in])).scalar() or 0 revenue_by_date = [] if start_date and end_date: - daily_revenue_query = db.query( - func.date(Payment.payment_date).label('date'), - func.sum(Payment.amount).label('revenue'), - func.count(func.distinct(Payment.booking_id)).label('bookings') - ).filter(Payment.payment_status == PaymentStatus.completed) - + daily_revenue_query = db.query(func.date(Payment.payment_date).label('date'), func.sum(Payment.amount).label('revenue'), func.count(func.distinct(Payment.booking_id)).label('bookings')).filter(Payment.payment_status == PaymentStatus.completed) if start_date: daily_revenue_query = daily_revenue_query.filter(Payment.payment_date >= start_date) if end_date: daily_revenue_query = daily_revenue_query.filter(Payment.payment_date <= end_date) - - daily_revenue_query = daily_revenue_query.group_by( - func.date(Payment.payment_date) - ).order_by(func.date(Payment.payment_date)) - + daily_revenue_query = daily_revenue_query.group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date)) daily_data = daily_revenue_query.all() - 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 + revenue_by_date = [{'date': str(date), 'revenue': float(revenue or 0), 'bookings': int(bookings or 0)} for date, revenue, bookings in daily_data] bookings_by_status = {} for status in BookingStatus: count = booking_query.filter(Booking.status == status).count() status_name = status.value if hasattr(status, 'value') else str(status) bookings_by_status[status_name] = count - - # Top rooms (by revenue) - 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) if start_date: top_rooms_query = top_rooms_query.filter(Booking.created_at >= start_date) if end_date: top_rooms_query = top_rooms_query.filter(Booking.created_at <= end_date) - - top_rooms_data = top_rooms_query.group_by(Room.id, Room.room_number).order_by( - func.sum(Payment.amount).desc() - ).limit(10).all() - - top_rooms = [ - { - "room_id": room_id, - "room_number": room_number, - "bookings": int(bookings or 0), - "revenue": float(revenue or 0) - } - for room_id, room_number, bookings, revenue in top_rooms_data - ] - - # Service usage statistics - service_usage_query = db.query( - Service.id, - Service.name, - func.count(ServiceUsage.id).label('usage_count'), - func.sum(ServiceUsage.total_price).label('total_revenue') - ).join(ServiceUsage, Service.id == ServiceUsage.service_id) - + top_rooms_data = top_rooms_query.group_by(Room.id, Room.room_number).order_by(func.sum(Payment.amount).desc()).limit(10).all() + top_rooms = [{'room_id': room_id, 'room_number': room_number, 'bookings': int(bookings or 0), 'revenue': float(revenue or 0)} for room_id, room_number, bookings, revenue in top_rooms_data] + service_usage_query = db.query(Service.id, Service.name, func.count(ServiceUsage.id).label('usage_count'), func.sum(ServiceUsage.total_price).label('total_revenue')).join(ServiceUsage, Service.id == ServiceUsage.service_id) if start_date: service_usage_query = service_usage_query.filter(ServiceUsage.usage_date >= start_date) if 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 = [ - { - "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, - } - } + service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by(func.sum(ServiceUsage.total_price).desc()).limit(10).all() + service_usage = [{'service_id': service_id, 'service_name': service_name, 'usage_count': int(usage_count or 0), 'total_revenue': float(total_revenue or 0)} for service_id, service_name, usage_count, total_revenue in service_usage_data] + return {'status': 'success', 'success': True, 'data': {'total_bookings': total_bookings, 'total_revenue': float(total_revenue), 'total_customers': int(total_customers), 'available_rooms': available_rooms, 'occupied_rooms': occupied_rooms, 'revenue_by_date': revenue_by_date if revenue_by_date else None, 'bookings_by_status': bookings_by_status, 'top_rooms': top_rooms if top_rooms else None, 'service_usage': service_usage if service_usage else None}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/dashboard") -async def get_dashboard_stats( - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Get dashboard statistics (Admin/Staff only)""" +@router.get('/dashboard') +async def get_dashboard_stats(current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: - # Total bookings total_bookings = db.query(Booking).count() - - # Active bookings - active_bookings = db.query(Booking).filter( - Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in]) - ).count() - - # Total revenue (from completed payments) - total_revenue = db.query(func.sum(Payment.amount)).filter( - Payment.payment_status == PaymentStatus.completed - ).scalar() or 0.0 - - # Today's revenue + active_bookings = db.query(Booking).filter(Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count() + total_revenue = db.query(func.sum(Payment.amount)).filter(Payment.payment_status == PaymentStatus.completed).scalar() or 0.0 today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0) - today_revenue = db.query(func.sum(Payment.amount)).filter( - and_( - Payment.payment_status == PaymentStatus.completed, - Payment.payment_date >= today_start - ) - ).scalar() or 0.0 - - # Total rooms + today_revenue = db.query(func.sum(Payment.amount)).filter(and_(Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= today_start)).scalar() or 0.0 total_rooms = db.query(Room).count() - - # Available rooms - available_rooms = db.query(Room).filter(Room.status == "available").count() - - # Recent bookings (last 7 days) + available_rooms = db.query(Room).filter(Room.status == 'available').count() week_ago = datetime.utcnow() - timedelta(days=7) - recent_bookings = db.query(Booking).filter( - Booking.created_at >= week_ago - ).count() - - # Pending payments - 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, - } - } + recent_bookings = db.query(Booking).filter(Booking.created_at >= week_ago).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}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/customer/dashboard") -async def get_customer_dashboard_stats( - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Get customer dashboard statistics""" +@router.get('/customer/dashboard') +async def get_customer_dashboard_stats(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: from datetime import datetime, timedelta - - # Total bookings count for user - total_bookings = db.query(Booking).filter( - Booking.user_id == current_user.id - ).count() - - # Total spending (sum of completed payments from user's bookings) - 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 - - # Currently staying (checked_in bookings) + total_bookings = db.query(Booking).filter(Booking.user_id == current_user.id).count() + user_bookings = db.query(Booking.id).filter(Booking.user_id == current_user.id).subquery() + total_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed)).scalar() or 0.0 now = datetime.utcnow() - currently_staying = db.query(Booking).filter( - and_( - Booking.user_id == current_user.id, - Booking.status == BookingStatus.checked_in, - Booking.check_in_date <= now, - Booking.check_out_date >= now - ) - ).count() - - # Upcoming bookings (confirmed/pending with check_in_date in future) - upcoming_bookings_query = db.query(Booking).filter( - and_( - Booking.user_id == current_user.id, - Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]), - Booking.check_in_date > now - ) - ).order_by(Booking.check_in_date.asc()).limit(5).all() - + currently_staying = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status == BookingStatus.checked_in, Booking.check_in_date <= now, Booking.check_out_date >= now)).count() + upcoming_bookings_query = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]), Booking.check_in_date > now)).order_by(Booking.check_in_date.asc()).limit(5).all() upcoming_bookings = [] for booking in upcoming_bookings_query: - booking_dict = { - "id": booking.id, - "booking_number": booking.booking_number, - "check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None, - "check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None, - "status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, - "total_price": float(booking.total_price) if booking.total_price else 0.0, - } - + booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'check_in_date': booking.check_in_date.isoformat() if booking.check_in_date else None, 'check_out_date': booking.check_out_date.isoformat() if booking.check_out_date else None, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'total_price': float(booking.total_price) if booking.total_price else 0.0} if booking.room: - booking_dict["room"] = { - "id": booking.room.id, - "room_number": booking.room.room_number, - "room_type": { - "name": booking.room.room_type.name if booking.room.room_type else None - } - } - + booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'room_type': {'name': booking.room.room_type.name if booking.room.room_type else None}} upcoming_bookings.append(booking_dict) - - # Recent activity (last 5 bookings ordered by created_at) - recent_bookings_query = db.query(Booking).filter( - Booking.user_id == current_user.id - ).order_by(Booking.created_at.desc()).limit(5).all() - + recent_bookings_query = db.query(Booking).filter(Booking.user_id == current_user.id).order_by(Booking.created_at.desc()).limit(5).all() recent_activity = [] for booking in recent_bookings_query: activity_type = None if booking.status == BookingStatus.checked_out: - activity_type = "Check-out" + activity_type = 'Check-out' elif booking.status == BookingStatus.checked_in: - activity_type = "Check-in" + activity_type = 'Check-in' elif booking.status == BookingStatus.confirmed: - activity_type = "Booking Confirmed" + activity_type = 'Booking Confirmed' elif booking.status == BookingStatus.pending: - activity_type = "Booking" + activity_type = 'Booking' else: - activity_type = "Booking" - - activity_dict = { - "action": activity_type, - "booking_id": booking.id, - "booking_number": booking.booking_number, - "created_at": booking.created_at.isoformat() if booking.created_at else None, - } - + activity_type = 'Booking' + activity_dict = {'action': activity_type, 'booking_id': booking.id, 'booking_number': booking.booking_number, 'created_at': booking.created_at.isoformat() if booking.created_at else None} if booking.room: - activity_dict["room"] = { - "room_number": booking.room.room_number, - } - + activity_dict['room'] = {'room_number': booking.room.room_number} recent_activity.append(activity_dict) - - # Calculate percentage change (placeholder - can be enhanced) - # For now, compare last month vs this month last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0) last_month_end = now.replace(day=1, hour=0, minute=0, second=0) - timedelta(seconds=1) - - last_month_bookings = db.query(Booking).filter( - and_( - Booking.user_id == current_user.id, - Booking.created_at >= last_month_start, - Booking.created_at <= last_month_end - ) - ).count() - - this_month_bookings = db.query(Booking).filter( - and_( - Booking.user_id == current_user.id, - Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0), - Booking.created_at <= now - ) - ).count() - + last_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= last_month_start, Booking.created_at <= last_month_end)).count() + this_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0), Booking.created_at <= now)).count() booking_change_percentage = 0 if last_month_bookings > 0: - booking_change_percentage = ((this_month_bookings - last_month_bookings) / last_month_bookings) * 100 - - last_month_spending = db.query(func.sum(Payment.amount)).filter( - and_( - Payment.booking_id.in_(db.query(user_bookings.c.id)), - Payment.payment_status == PaymentStatus.completed, - Payment.payment_date >= last_month_start, - Payment.payment_date <= last_month_end - ) - ).scalar() or 0.0 - - this_month_spending = db.query(func.sum(Payment.amount)).filter( - and_( - Payment.booking_id.in_(db.query(user_bookings.c.id)), - Payment.payment_status == PaymentStatus.completed, - Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0), - Payment.payment_date <= now - ) - ).scalar() or 0.0 - + booking_change_percentage = (this_month_bookings - last_month_bookings) / last_month_bookings * 100 + last_month_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= last_month_start, Payment.payment_date <= last_month_end)).scalar() or 0.0 + this_month_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0), Payment.payment_date <= now)).scalar() or 0.0 spending_change_percentage = 0 if last_month_spending > 0: - spending_change_percentage = ((this_month_spending - last_month_spending) / last_month_spending) * 100 - - return { - "status": "success", - "success": True, - "data": { - "total_bookings": total_bookings, - "total_spending": float(total_spending), - "currently_staying": currently_staying, - "upcoming_bookings": upcoming_bookings, - "recent_activity": recent_activity, - "booking_change_percentage": round(booking_change_percentage, 1), - "spending_change_percentage": round(spending_change_percentage, 1), - } - } + 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)}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/revenue") -async def get_revenue_report( - 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 revenue report (Admin/Staff only)""" +@router.get('/revenue') +async def get_revenue_report(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)): try: - query = db.query(Payment).filter( - Payment.payment_status == PaymentStatus.completed - ) - + query = db.query(Payment).filter(Payment.payment_status == PaymentStatus.completed) if start_date: start = datetime.fromisoformat(start_date.replace('Z', '+00:00')) query = query.filter(Payment.payment_date >= start) - if end_date: end = datetime.fromisoformat(end_date.replace('Z', '+00:00')) query = query.filter(Payment.payment_date <= end) - - # Total revenue - total_revenue = db.query(func.sum(Payment.amount)).filter( - Payment.payment_status == PaymentStatus.completed - ).scalar() or 0.0 - - # Revenue by payment method - revenue_by_method = db.query( - Payment.payment_method, - func.sum(Payment.amount).label('total') - ).filter( - Payment.payment_status == PaymentStatus.completed - ).group_by(Payment.payment_method).all() - + total_revenue = db.query(func.sum(Payment.amount)).filter(Payment.payment_status == PaymentStatus.completed).scalar() or 0.0 + revenue_by_method = db.query(Payment.payment_method, func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(Payment.payment_method).all() method_breakdown = {} for method, total in revenue_by_method: method_name = method.value if hasattr(method, 'value') else str(method) method_breakdown[method_name] = float(total or 0) - - # Revenue by date (daily breakdown) - daily_revenue = db.query( - func.date(Payment.payment_date).label('date'), - func.sum(Payment.amount).label('total') - ).filter( - Payment.payment_status == PaymentStatus.completed - ).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all() - - daily_breakdown = [ - { - "date": date.isoformat() if isinstance(date, datetime) else str(date), - "revenue": float(total or 0) - } - for date, total in daily_revenue - ] - - return { - "status": "success", - "data": { - "total_revenue": float(total_revenue), - "revenue_by_method": method_breakdown, - "daily_breakdown": daily_breakdown, - } - } + daily_revenue = db.query(func.date(Payment.payment_date).label('date'), func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all() + daily_breakdown = [{'date': date.isoformat() if isinstance(date, datetime) else str(date), 'revenue': float(total or 0)} for date, total in daily_revenue] + return {'status': 'success', 'data': {'total_revenue': float(total_revenue), 'revenue_by_method': method_breakdown, 'daily_breakdown': daily_breakdown}} except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/review_routes.py b/Backend/src/routes/review_routes.py index 7f7361cd..95c1ee51 100644 --- a/Backend/src/routes/review_routes.py +++ b/Backend/src/routes/review_routes.py @@ -1,251 +1,117 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from typing import Optional - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.review import Review, ReviewStatus from ..models.room import Room +router = APIRouter(prefix='/reviews', tags=['reviews']) -router = APIRouter(prefix="/reviews", tags=["reviews"]) - - -@router.get("/room/{room_id}") -async def get_room_reviews(room_id: int, db: Session = Depends(get_db)): - """Get reviews for a room""" +@router.get('/room/{room_id}') +async def get_room_reviews(room_id: int, db: Session=Depends(get_db)): try: - reviews = db.query(Review).filter( - Review.room_id == room_id, - Review.status == ReviewStatus.approved - ).order_by(Review.created_at.desc()).all() - + reviews = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved).order_by(Review.created_at.desc()).all() result = [] for review in reviews: - review_dict = { - "id": review.id, - "user_id": review.user_id, - "room_id": review.room_id, - "rating": review.rating, - "comment": review.comment, - "status": review.status.value if isinstance(review.status, ReviewStatus) else review.status, - "created_at": review.created_at.isoformat() if review.created_at else None, - } - + review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} if review.user: - review_dict["user"] = { - "id": review.user.id, - "full_name": review.user.full_name, - "email": review.user.email, - } - + review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email} result.append(review_dict) - - return { - "status": "success", - "data": {"reviews": result} - } + return {'status': 'success', 'data': {'reviews': result}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/", dependencies=[Depends(authorize_roles("admin"))]) -async def get_all_reviews( - status_filter: Optional[str] = Query(None, alias="status"), - page: int = Query(1, ge=1), - limit: int = Query(10, ge=1, le=100), - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Get all reviews (Admin only)""" +@router.get('/', dependencies=[Depends(authorize_roles('admin'))]) +async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: query = db.query(Review) - if status_filter: try: query = query.filter(Review.status == ReviewStatus(status_filter)) except ValueError: pass - total = query.count() offset = (page - 1) * limit reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all() - result = [] for review in reviews: - review_dict = { - "id": review.id, - "user_id": review.user_id, - "room_id": review.room_id, - "rating": review.rating, - "comment": review.comment, - "status": review.status.value if isinstance(review.status, ReviewStatus) else review.status, - "created_at": review.created_at.isoformat() if review.created_at else None, - } - + review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} if review.user: - review_dict["user"] = { - "id": review.user.id, - "full_name": review.user.full_name, - "email": review.user.email, - "phone": review.user.phone, - } - + review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email, 'phone': review.user.phone} if review.room: - review_dict["room"] = { - "id": review.room.id, - "room_number": review.room.room_number, - } - + review_dict['room'] = {'id': review.room.id, 'room_number': review.room.room_number} result.append(review_dict) - - return { - "status": "success", - "data": { - "reviews": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'reviews': 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.post("/") -async def create_review( - review_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create new review""" +@router.post('/') +async def create_review(review_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - room_id = review_data.get("room_id") - rating = review_data.get("rating") - comment = review_data.get("comment") - - # Check if room exists + room_id = review_data.get('room_id') + rating = review_data.get('rating') + comment = review_data.get('comment') room = db.query(Room).filter(Room.id == room_id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Check if user already reviewed this room - existing = db.query(Review).filter( - Review.user_id == current_user.id, - Review.room_id == room_id - ).first() - + raise HTTPException(status_code=404, detail='Room not found') + existing = db.query(Review).filter(Review.user_id == current_user.id, Review.room_id == room_id).first() if existing: - raise HTTPException( - status_code=400, - detail="You have already reviewed this room" - ) - - # Create review - review = Review( - user_id=current_user.id, - room_id=room_id, - rating=rating, - comment=comment, - status=ReviewStatus.pending, - ) - + raise HTTPException(status_code=400, detail='You have already reviewed this room') + review = Review(user_id=current_user.id, room_id=room_id, rating=rating, comment=comment, status=ReviewStatus.pending) db.add(review) db.commit() db.refresh(review) - - return { - "status": "success", - "message": "Review submitted successfully and is pending approval", - "data": {"review": review} - } + return {'status': 'success', 'message': 'Review submitted successfully and is pending approval', 'data': {'review': review}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}/approve", dependencies=[Depends(authorize_roles("admin"))]) -async def approve_review( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Approve review (Admin only)""" +@router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))]) +async def approve_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: review = db.query(Review).filter(Review.id == id).first() if not review: - raise HTTPException(status_code=404, detail="Review not found") - + raise HTTPException(status_code=404, detail='Review not found') review.status = ReviewStatus.approved db.commit() db.refresh(review) - - return { - "status": "success", - "message": "Review approved successfully", - "data": {"review": review} - } + return {'status': 'success', 'message': 'Review approved successfully', 'data': {'review': review}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}/reject", dependencies=[Depends(authorize_roles("admin"))]) -async def reject_review( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Reject review (Admin only)""" +@router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))]) +async def reject_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: review = db.query(Review).filter(Review.id == id).first() if not review: - raise HTTPException(status_code=404, detail="Review not found") - + raise HTTPException(status_code=404, detail='Review not found') review.status = ReviewStatus.rejected db.commit() db.refresh(review) - - return { - "status": "success", - "message": "Review rejected successfully", - "data": {"review": review} - } + return {'status': 'success', 'message': 'Review rejected successfully', 'data': {'review': review}} except HTTPException: raise 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_review( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete review (Admin only)""" +@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: review = db.query(Review).filter(Review.id == id).first() if not review: - raise HTTPException(status_code=404, detail="Review not found") - + raise HTTPException(status_code=404, detail='Review not found') db.delete(review) db.commit() - - return { - "status": "success", - "message": "Review deleted successfully" - } + return {'status': 'success', 'message': 'Review deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/room_routes.py b/Backend/src/routes/room_routes.py index d8a93732..8128ac6d 100644 --- a/Backend/src/routes/room_routes.py +++ b/Backend/src/routes/room_routes.py @@ -3,7 +3,6 @@ from sqlalchemy.orm import Session from sqlalchemy import and_, or_, func from typing import List, Optional from datetime import datetime - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User @@ -15,885 +14,382 @@ from ..services.room_service import get_rooms_with_ratings, get_amenities_list, import os import aiofiles from pathlib import Path +router = APIRouter(prefix='/rooms', tags=['rooms']) -router = APIRouter(prefix="/rooms", tags=["rooms"]) - - -@router.get("/") -async def get_rooms( - request: Request, - type: Optional[str] = Query(None), - minPrice: Optional[float] = Query(None), - maxPrice: Optional[float] = Query(None), - capacity: Optional[int] = Query(None), - page: int = Query(1, ge=1), - limit: int = Query(10, ge=1, le=100), - sort: Optional[str] = Query(None), - featured: Optional[bool] = Query(None), - db: Session = Depends(get_db) -): - """Get all rooms with filters""" +@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)): try: - # Build where clause for rooms where_clause = {} room_type_where = {} - if featured is not None: - where_clause["featured"] = featured - + where_clause['featured'] = featured if type: - room_type_where["name"] = f"%{type}%" - + room_type_where['name'] = f'%{type}%' if capacity: - room_type_where["capacity"] = capacity - + room_type_where['capacity'] = capacity if minPrice or maxPrice: if minPrice: - room_type_where["base_price_min"] = minPrice + room_type_where['base_price_min'] = minPrice if maxPrice: - room_type_where["base_price_max"] = maxPrice - - # Build query + room_type_where['base_price_max'] = maxPrice query = db.query(Room).join(RoomType) - - # Apply filters - if where_clause.get("featured") is not None: - query = query.filter(Room.featured == where_clause["featured"]) - - if room_type_where.get("name"): - query = query.filter(RoomType.name.like(room_type_where["name"])) - - if room_type_where.get("capacity"): - query = query.filter(RoomType.capacity >= room_type_where["capacity"]) - - if room_type_where.get("base_price_min"): - query = query.filter(RoomType.base_price >= room_type_where["base_price_min"]) - - if room_type_where.get("base_price_max"): - query = query.filter(RoomType.base_price <= room_type_where["base_price_max"]) - - # Get total count + if where_clause.get('featured') is not None: + query = query.filter(Room.featured == where_clause['featured']) + if room_type_where.get('name'): + query = query.filter(RoomType.name.like(room_type_where['name'])) + if room_type_where.get('capacity'): + query = query.filter(RoomType.capacity >= room_type_where['capacity']) + if room_type_where.get('base_price_min'): + query = query.filter(RoomType.base_price >= room_type_where['base_price_min']) + if room_type_where.get('base_price_max'): + query = query.filter(RoomType.base_price <= room_type_where['base_price_max']) total = query.count() - - # Apply sorting - if sort == "newest" or sort == "created_at": + if sort == 'newest' or sort == 'created_at': query = query.order_by(Room.created_at.desc()) else: query = query.order_by(Room.featured.desc(), Room.created_at.desc()) - - # Apply pagination offset = (page - 1) * limit rooms = query.offset(offset).limit(limit).all() - - # Get base URL base_url = get_base_url(request) - - # Get rooms with ratings rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url) - - return { - "status": "success", - "data": { - "rooms": rooms_with_ratings, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'rooms': rooms_with_ratings, '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("/amenities") -async def get_amenities(db: Session = Depends(get_db)): - """Get all available amenities""" +@router.get('/amenities') +async def get_amenities(db: Session=Depends(get_db)): try: amenities = await get_amenities_list(db) - return {"status": "success", "data": {"amenities": amenities}} + return {'status': 'success', 'data': {'amenities': amenities}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/available") -async def search_available_rooms( - request: Request, - from_date: str = Query(..., alias="from"), - to_date: str = Query(..., alias="to"), - roomId: Optional[int] = Query(None, alias="roomId"), - type: Optional[str] = Query(None), - capacity: Optional[int] = Query(None), - page: int = Query(1, ge=1), - limit: int = Query(12, ge=1, le=100), - db: Session = Depends(get_db) -): - """Search for available rooms or check specific room availability""" +@router.get('/available') +async def search_available_rooms(request: Request, from_date: str=Query(..., alias='from'), to_date: str=Query(..., alias='to'), roomId: Optional[int]=Query(None, alias='roomId'), type: Optional[str]=Query(None), capacity: Optional[int]=Query(None), page: int=Query(1, ge=1), limit: int=Query(12, ge=1, le=100), db: Session=Depends(get_db)): try: - # Parse dates - handle both date-only and datetime formats try: if 'T' in from_date or 'Z' in from_date or '+' in from_date: check_in = datetime.fromisoformat(from_date.replace('Z', '+00:00')) else: check_in = datetime.strptime(from_date, '%Y-%m-%d') except ValueError: - raise HTTPException(status_code=400, detail=f"Invalid from date format: {from_date}") - + raise HTTPException(status_code=400, detail=f'Invalid from date format: {from_date}') try: if 'T' in to_date or 'Z' in to_date or '+' in to_date: check_out = datetime.fromisoformat(to_date.replace('Z', '+00:00')) else: check_out = datetime.strptime(to_date, '%Y-%m-%d') except ValueError: - raise HTTPException(status_code=400, detail=f"Invalid to date format: {to_date}") - - # If checking a specific room, handle it differently + raise HTTPException(status_code=400, detail=f'Invalid to date format: {to_date}') if roomId: - # Check if room exists room = db.query(Room).filter(Room.id == roomId).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Check if room is available + raise HTTPException(status_code=404, detail='Room not found') if room.status != RoomStatus.available: - return { - "status": "success", - "data": { - "available": False, - "message": "Room is not available", - "room_id": roomId - } - } - - # Check for overlapping bookings - 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() - + return {'status': 'success', 'data': {'available': False, 'message': 'Room is not available', 'room_id': roomId}} + overlapping = db.query(Booking).filter(and_(Booking.room_id == roomId, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first() if overlapping: - return { - "status": "success", - "data": { - "available": False, - "message": "Room is already booked for the selected dates", - "room_id": roomId - } - } - - return { - "status": "success", - "data": { - "available": True, - "message": "Room is available", - "room_id": roomId - } - } - - # Original search functionality + return {'status': 'success', 'data': {'available': False, 'message': 'Room is already booked for the selected dates', 'room_id': roomId}} + return {'status': 'success', 'data': {'available': True, 'message': 'Room is available', 'room_id': roomId}} if check_in >= check_out: - raise HTTPException( - status_code=400, - detail="Check-out date must be after check-in date" - ) - - # Build room type filter + raise HTTPException(status_code=400, detail='Check-out date must be after check-in date') query = db.query(Room).join(RoomType).filter(Room.status == RoomStatus.available) - if type: - query = query.filter(RoomType.name.like(f"%{type}%")) - + query = query.filter(RoomType.name.like(f'%{type}%')) if capacity: query = query.filter(RoomType.capacity >= capacity) - - # Exclude rooms with overlapping bookings - 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))) - - # Get total total = query.count() - - # Apply sorting and pagination query = query.order_by(Room.featured.desc(), Room.created_at.desc()) offset = (page - 1) * limit rooms = query.offset(offset).limit(limit).all() - - # Get base URL base_url = get_base_url(request) - - # Get rooms with ratings rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url) - - return { - "status": "success", - "data": { - "rooms": rooms_with_ratings, - "search": { - "from": from_date, - "to": to_date, - "type": type, - "capacity": capacity, - }, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'rooms': rooms_with_ratings, 'search': {'from': from_date, 'to': to_date, 'type': type, 'capacity': capacity}, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} 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("/id/{id}") -async def get_room_by_id(id: int, request: Request, db: Session = Depends(get_db)): - """Get room by ID""" +@router.get('/id/{id}') +async def get_room_by_id(id: int, request: Request, db: Session=Depends(get_db)): try: room = db.query(Room).filter(Room.id == id).first() - if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Get review stats - review_stats = db.query( - func.avg(Review.rating).label('average_rating'), - func.count(Review.id).label('total_reviews') - ).filter( - and_( - Review.room_id == room.id, - Review.status == ReviewStatus.approved - ) - ).first() - + raise HTTPException(status_code=404, detail='Room not found') + review_stats = db.query(func.avg(Review.rating).label('average_rating'), func.count(Review.id).label('total_reviews')).filter(and_(Review.room_id == room.id, Review.status == ReviewStatus.approved)).first() base_url = get_base_url(request) - - room_dict = { - "id": room.id, - "room_type_id": room.room_type_id, - "room_number": room.room_number, - "floor": room.floor, - "status": room.status.value if isinstance(room.status, RoomStatus) else room.status, - "price": float(room.price) if room.price is not None and room.price > 0 else None, - "featured": room.featured, - "description": room.description, - "capacity": room.capacity, - "room_size": room.room_size, - "view": room.view, - "amenities": room.amenities, - "created_at": room.created_at.isoformat() if room.created_at else None, - "updated_at": room.updated_at.isoformat() if room.updated_at else None, - "average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, - "total_reviews": review_stats.total_reviews or 0 if review_stats else 0, - } - - # Normalize images + room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price is not None and room.price > 0 else None, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities, 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None, 'average_rating': round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, 'total_reviews': review_stats.total_reviews or 0 if review_stats else 0} try: - room_dict["images"] = normalize_images(room.images, base_url) + room_dict['images'] = normalize_images(room.images, base_url) except: - room_dict["images"] = [] - - # Add room type + room_dict['images'] = [] if room.room_type: - room_dict["room_type"] = { - "id": room.room_type.id, - "name": room.room_type.name, - "description": room.room_type.description, - "base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0, - "capacity": room.room_type.capacity, - "amenities": room.room_type.amenities, - "images": [] # RoomType doesn't have images column in DB - } - - return { - "status": "success", - "data": {"room": room_dict} - } + room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities, 'images': []} + return {'status': 'success', 'data': {'room': room_dict}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{room_number}") -async def get_room_by_number(room_number: str, request: Request, db: Session = Depends(get_db)): - """Get room by room number""" +@router.get('/{room_number}') +async def get_room_by_number(room_number: str, request: Request, db: Session=Depends(get_db)): try: room = db.query(Room).filter(Room.room_number == room_number).first() - if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Get review stats - review_stats = db.query( - func.avg(Review.rating).label('average_rating'), - func.count(Review.id).label('total_reviews') - ).filter( - and_( - Review.room_id == room.id, - Review.status == ReviewStatus.approved - ) - ).first() - + raise HTTPException(status_code=404, detail='Room not found') + review_stats = db.query(func.avg(Review.rating).label('average_rating'), func.count(Review.id).label('total_reviews')).filter(and_(Review.room_id == room.id, Review.status == ReviewStatus.approved)).first() base_url = get_base_url(request) - - room_dict = { - "id": room.id, - "room_type_id": room.room_type_id, - "room_number": room.room_number, - "floor": room.floor, - "status": room.status.value if isinstance(room.status, RoomStatus) else room.status, - "price": float(room.price) if room.price is not None and room.price > 0 else None, - "featured": room.featured, - "description": room.description, - "capacity": room.capacity, - "room_size": room.room_size, - "view": room.view, - "amenities": room.amenities, - "created_at": room.created_at.isoformat() if room.created_at else None, - "updated_at": room.updated_at.isoformat() if room.updated_at else None, - "average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, - "total_reviews": review_stats.total_reviews or 0 if review_stats else 0, - } - - # Normalize images + room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price is not None and room.price > 0 else None, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities, 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None, 'average_rating': round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, 'total_reviews': review_stats.total_reviews or 0 if review_stats else 0} try: - room_dict["images"] = normalize_images(room.images, base_url) + room_dict['images'] = normalize_images(room.images, base_url) except: - room_dict["images"] = [] - - # Add room type + room_dict['images'] = [] if room.room_type: - room_dict["room_type"] = { - "id": room.room_type.id, - "name": room.room_type.name, - "description": room.room_type.description, - "base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0, - "capacity": room.room_type.capacity, - "amenities": room.room_type.amenities, - "images": [] # RoomType doesn't have images column in DB - } - - return { - "status": "success", - "data": {"room": room_dict} - } + room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities, 'images': []} + return {'status': 'success', 'data': {'room': room_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_room( - room_data: dict, - request: Request, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Create new room (Admin only)""" +@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) +async def create_room(room_data: dict, request: Request, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Check if room type exists - room_type = db.query(RoomType).filter(RoomType.id == room_data.get("room_type_id")).first() + room_type = db.query(RoomType).filter(RoomType.id == room_data.get('room_type_id')).first() if not room_type: - raise HTTPException(status_code=404, detail="Room type not found") - - # Check if room number exists - existing = db.query(Room).filter(Room.room_number == room_data.get("room_number")).first() + raise HTTPException(status_code=404, detail='Room type not found') + existing = db.query(Room).filter(Room.room_number == room_data.get('room_number')).first() if existing: - raise HTTPException(status_code=400, detail="Room number already exists") - - # Ensure amenities is always a list - amenities_value = room_data.get("amenities", []) + raise HTTPException(status_code=400, detail='Room number already exists') + amenities_value = room_data.get('amenities', []) if amenities_value is None: amenities_value = [] elif not isinstance(amenities_value, list): amenities_value = [] - - room = Room( - room_type_id=room_data.get("room_type_id"), - room_number=room_data.get("room_number"), - floor=room_data.get("floor"), - status=RoomStatus(room_data.get("status", "available")), - featured=room_data.get("featured", False), - price=room_data.get("price", room_type.base_price), - description=room_data.get("description"), - capacity=room_data.get("capacity"), - room_size=room_data.get("room_size"), - view=room_data.get("view"), - amenities=amenities_value, - ) - + room = Room(room_type_id=room_data.get('room_type_id'), room_number=room_data.get('room_number'), floor=room_data.get('floor'), status=RoomStatus(room_data.get('status', 'available')), featured=room_data.get('featured', False), price=room_data.get('price', room_type.base_price), description=room_data.get('description'), capacity=room_data.get('capacity'), room_size=room_data.get('room_size'), view=room_data.get('view'), amenities=amenities_value) db.add(room) db.commit() db.refresh(room) - - # Get base URL for proper response base_url = get_base_url(request) - - # Serialize room data - room_dict = { - "id": room.id, - "room_type_id": room.room_type_id, - "room_number": room.room_number, - "floor": room.floor, - "status": room.status.value if isinstance(room.status, RoomStatus) else room.status, - "price": float(room.price) if room.price is not None and room.price > 0 else None, - "featured": room.featured, - "description": room.description, - "capacity": room.capacity, - "room_size": room.room_size, - "view": room.view, - "amenities": room.amenities if room.amenities else [], - "created_at": room.created_at.isoformat() if room.created_at else None, - "updated_at": room.updated_at.isoformat() if room.updated_at else None, - } - - # Normalize images + room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price is not None and room.price > 0 else None, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities if room.amenities else [], 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None} try: - room_dict["images"] = normalize_images(room.images, base_url) + room_dict['images'] = normalize_images(room.images, base_url) except: - room_dict["images"] = [] - - # Add room type info + room_dict['images'] = [] if room.room_type: - room_dict["room_type"] = { - "id": room.room_type.id, - "name": room.room_type.name, - "description": room.room_type.description, - "base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0, - "capacity": room.room_type.capacity, - "amenities": room.room_type.amenities if room.room_type.amenities else [], - "images": [] - } - - return { - "status": "success", - "message": "Room created successfully", - "data": {"room": room_dict} - } + room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities if room.room_type.amenities else [], 'images': []} + return {'status': 'success', 'message': 'Room created successfully', 'data': {'room': room_dict}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))]) -async def update_room( - id: int, - room_data: dict, - request: Request, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Update room (Admin only)""" +@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def update_room(id: int, room_data: dict, request: Request, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: room = db.query(Room).filter(Room.id == id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - if room_data.get("room_type_id"): - room_type = db.query(RoomType).filter(RoomType.id == room_data["room_type_id"]).first() + raise HTTPException(status_code=404, detail='Room not found') + if room_data.get('room_type_id'): + room_type = db.query(RoomType).filter(RoomType.id == room_data['room_type_id']).first() if not room_type: - raise HTTPException(status_code=404, detail="Room type not found") - - # Update fields - if "room_type_id" in room_data: - room.room_type_id = room_data["room_type_id"] - if "room_number" in room_data: - room.room_number = room_data["room_number"] - if "floor" in room_data: - room.floor = room_data["floor"] - if "status" in room_data: - room.status = RoomStatus(room_data["status"]) - if "featured" in room_data: - room.featured = room_data["featured"] - if "price" in room_data: - room.price = room_data["price"] - if "description" in room_data: - room.description = room_data["description"] - if "capacity" in room_data: - room.capacity = room_data["capacity"] - if "room_size" in room_data: - room.room_size = room_data["room_size"] - if "view" in room_data: - room.view = room_data["view"] - if "amenities" in room_data: - # Ensure amenities is always a list - amenities_value = room_data["amenities"] + raise HTTPException(status_code=404, detail='Room type not found') + if 'room_type_id' in room_data: + room.room_type_id = room_data['room_type_id'] + if 'room_number' in room_data: + room.room_number = room_data['room_number'] + if 'floor' in room_data: + room.floor = room_data['floor'] + if 'status' in room_data: + room.status = RoomStatus(room_data['status']) + if 'featured' in room_data: + room.featured = room_data['featured'] + if 'price' in room_data: + room.price = room_data['price'] + if 'description' in room_data: + room.description = room_data['description'] + if 'capacity' in room_data: + room.capacity = room_data['capacity'] + if 'room_size' in room_data: + room.room_size = room_data['room_size'] + if 'view' in room_data: + room.view = room_data['view'] + if 'amenities' in room_data: + amenities_value = room_data['amenities'] if amenities_value is None: room.amenities = [] elif isinstance(amenities_value, list): room.amenities = amenities_value else: room.amenities = [] - db.commit() db.refresh(room) - - # Get base URL for proper response base_url = get_base_url(request) - - # Serialize room data similar to get_room_by_id - room_dict = { - "id": room.id, - "room_type_id": room.room_type_id, - "room_number": room.room_number, - "floor": room.floor, - "status": room.status.value if isinstance(room.status, RoomStatus) else room.status, - "price": float(room.price) if room.price is not None and room.price > 0 else None, - "featured": room.featured, - "description": room.description, - "capacity": room.capacity, - "room_size": room.room_size, - "view": room.view, - "amenities": room.amenities if room.amenities else [], - "created_at": room.created_at.isoformat() if room.created_at else None, - "updated_at": room.updated_at.isoformat() if room.updated_at else None, - } - - # Normalize images + room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price is not None and room.price > 0 else None, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities if room.amenities else [], 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None} try: - room_dict["images"] = normalize_images(room.images, base_url) + room_dict['images'] = normalize_images(room.images, base_url) except: - room_dict["images"] = [] - - # Add room type info + room_dict['images'] = [] if room.room_type: - room_dict["room_type"] = { - "id": room.room_type.id, - "name": room.room_type.name, - "description": room.room_type.description, - "base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0, - "capacity": room.room_type.capacity, - "amenities": room.room_type.amenities if room.room_type.amenities else [], - "images": [] - } - - return { - "status": "success", - "message": "Room updated successfully", - "data": {"room": room_dict} - } + room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities if room.room_type.amenities else [], 'images': []} + return {'status': 'success', 'message': 'Room updated successfully', 'data': {'room': room_dict}} except HTTPException: raise 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_room( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete room (Admin only)""" +@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_room(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: room = db.query(Room).filter(Room.id == id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - + raise HTTPException(status_code=404, detail='Room not found') db.delete(room) db.commit() - - return { - "status": "success", - "message": "Room deleted successfully" - } + return {'status': 'success', 'message': 'Room deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/bulk-delete", dependencies=[Depends(authorize_roles("admin"))]) -async def bulk_delete_rooms( - room_ids: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Bulk delete rooms (Admin only)""" +@router.post('/bulk-delete', dependencies=[Depends(authorize_roles('admin'))]) +async def bulk_delete_rooms(room_ids: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - ids = room_ids.get("ids", []) + ids = room_ids.get('ids', []) if not ids or not isinstance(ids, list): - raise HTTPException(status_code=400, detail="Invalid room IDs provided") - + raise HTTPException(status_code=400, detail='Invalid room IDs provided') if len(ids) == 0: - raise HTTPException(status_code=400, detail="No room IDs provided") - - # Validate all IDs are integers + raise HTTPException(status_code=400, detail='No room IDs provided') try: ids = [int(id) for id in ids] except (ValueError, TypeError): - raise HTTPException(status_code=400, detail="All room IDs must be integers") - - # Check if all rooms exist + raise HTTPException(status_code=400, detail='All room IDs must be integers') rooms = db.query(Room).filter(Room.id.in_(ids)).all() found_ids = [room.id for room in rooms] not_found_ids = [id for id in ids if id not in found_ids] - if not_found_ids: - raise HTTPException( - status_code=404, - detail=f"Rooms with IDs {not_found_ids} not found" - ) - - # Delete all rooms + raise HTTPException(status_code=404, detail=f'Rooms with IDs {not_found_ids} not found') deleted_count = db.query(Room).filter(Room.id.in_(ids)).delete(synchronize_session=False) db.commit() - - return { - "status": "success", - "message": f"Successfully deleted {deleted_count} room(s)", - "data": { - "deleted_count": deleted_count, - "deleted_ids": ids - } - } + return {'status': 'success', 'message': f'Successfully deleted {deleted_count} room(s)', 'data': {'deleted_count': deleted_count, 'deleted_ids': ids}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))]) -async def upload_room_images( - id: int, - images: List[UploadFile] = File(...), - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Upload room images (Admin/Staff only)""" +@router.post('/{id}/images', dependencies=[Depends(authorize_roles('admin', 'staff'))]) +async def upload_room_images(id: int, images: List[UploadFile]=File(...), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: room = db.query(Room).filter(Room.id == id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Create uploads directory - upload_dir = Path(__file__).parent.parent.parent / "uploads" / "rooms" + raise HTTPException(status_code=404, detail='Room not found') + upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'rooms' upload_dir.mkdir(parents=True, exist_ok=True) - image_urls = [] for image in images: - # Validate file type if not image.content_type or not image.content_type.startswith('image/'): continue - - # Validate filename if not image.filename: continue - - # Generate filename import uuid ext = Path(image.filename).suffix or '.jpg' - filename = f"room-{uuid.uuid4()}{ext}" + filename = f'room-{uuid.uuid4()}{ext}' file_path = upload_dir / filename - - # Save file async with aiofiles.open(file_path, 'wb') as f: content = await image.read() if not content: continue await f.write(content) - - image_urls.append(f"/uploads/rooms/{filename}") - - # Update room images (images are stored on Room, not RoomType) + image_urls.append(f'/uploads/rooms/{filename}') existing_images = room.images or [] updated_images = existing_images + image_urls room.images = updated_images db.commit() - - return { - "success": True, - "status": "success", - "message": "Images uploaded successfully", - "data": {"images": updated_images} - } + return {'success': True, 'status': 'success', 'message': 'Images uploaded successfully', 'data': {'images': updated_images}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.delete("/{id}/images", dependencies=[Depends(authorize_roles("admin", "staff"))]) -async def delete_room_images( - id: int, - image_url: str = Query(..., description="Image URL or path to delete"), - current_user: User = Depends(authorize_roles("admin", "staff")), - db: Session = Depends(get_db) -): - """Delete room images (Admin/Staff only)""" +@router.delete('/{id}/images', dependencies=[Depends(authorize_roles('admin', 'staff'))]) +async def delete_room_images(id: int, image_url: str=Query(..., description='Image URL or path to delete'), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)): try: room = db.query(Room).filter(Room.id == id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Normalize the incoming image_url to extract the path - # Handle both full URLs and relative paths + raise HTTPException(status_code=404, detail='Room not found') normalized_url = image_url if image_url.startswith('http://') or image_url.startswith('https://'): - # Extract path from URL from urllib.parse import urlparse parsed = urlparse(image_url) normalized_url = parsed.path - - # Normalize paths for comparison (ensure leading slash) if not normalized_url.startswith('/'): - normalized_url = f"/{normalized_url}" - - # Get filename from normalized path + normalized_url = f'/{normalized_url}' filename = Path(normalized_url).name - - # Update room images - compare by filename or full path existing_images = room.images or [] updated_images = [] - for img in existing_images: - # Normalize stored image path - stored_path = img if img.startswith('/') else f"/{img}" + stored_path = img if img.startswith('/') else f'/{img}' stored_filename = Path(stored_path).name - - # Compare by filename or full path - # Keep images that don't match - if (img != normalized_url and - stored_path != normalized_url and - stored_filename != filename): + if img != normalized_url and stored_path != normalized_url and (stored_filename != filename): updated_images.append(img) - - # Delete file from disk - file_path = Path(__file__).parent.parent.parent / "uploads" / "rooms" / filename + file_path = Path(__file__).parent.parent.parent / 'uploads' / 'rooms' / filename if file_path.exists(): file_path.unlink() - room.images = updated_images db.commit() - - return { - "status": "success", - "message": "Image deleted successfully", - "data": {"images": updated_images} - } + return {'status': 'success', 'message': 'Image deleted successfully', 'data': {'images': updated_images}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}/booked-dates") -async def get_room_booked_dates( - id: int, - db: Session = Depends(get_db) -): - """Get all booked dates for a specific room""" +@router.get('/{id}/booked-dates') +async def get_room_booked_dates(id: int, db: Session=Depends(get_db)): try: - # Check if room exists room = db.query(Room).filter(Room.id == id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - # Get all non-cancelled bookings for this room - bookings = db.query(Booking).filter( - and_( - Booking.room_id == id, - Booking.status != BookingStatus.cancelled - ) - ).all() - - # Generate list of all booked dates + raise HTTPException(status_code=404, detail='Room not found') + bookings = db.query(Booking).filter(and_(Booking.room_id == id, Booking.status != BookingStatus.cancelled)).all() booked_dates = [] for booking in bookings: - # Parse dates check_in = booking.check_in_date check_out = booking.check_out_date - - # Generate all dates between check-in and check-out (exclusive of check-out) current_date = check_in.date() end_date = check_out.date() - while current_date < end_date: booked_dates.append(current_date.isoformat()) - # Move to next day from datetime import timedelta current_date += timedelta(days=1) - - # Remove duplicates and sort booked_dates = sorted(list(set(booked_dates))) - - return { - "status": "success", - "data": { - "room_id": id, - "booked_dates": booked_dates - } - } + return {'status': 'success', 'data': {'room_id': id, 'booked_dates': booked_dates}} except HTTPException: raise except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}/reviews") -async def get_room_reviews_route( - id: int, - db: Session = Depends(get_db) -): - """Get reviews for a specific room""" +@router.get('/{id}/reviews') +async def get_room_reviews_route(id: int, db: Session=Depends(get_db)): from ..models.review import Review, ReviewStatus try: room = db.query(Room).filter(Room.id == id).first() if not room: - raise HTTPException(status_code=404, detail="Room not found") - - reviews = db.query(Review).filter( - Review.room_id == id, - Review.status == ReviewStatus.approved - ).order_by(Review.created_at.desc()).all() - + raise HTTPException(status_code=404, detail='Room not found') + reviews = db.query(Review).filter(Review.room_id == id, Review.status == ReviewStatus.approved).order_by(Review.created_at.desc()).all() result = [] for review in reviews: - review_dict = { - "id": review.id, - "user_id": review.user_id, - "room_id": review.room_id, - "rating": review.rating, - "comment": review.comment, - "status": review.status.value if isinstance(review.status, ReviewStatus) else review.status, - "created_at": review.created_at.isoformat() if review.created_at else None, - } + review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} if review.user: - review_dict["user"] = { - "id": review.user.id, - "full_name": review.user.full_name, - "email": review.user.email, - } + review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email} result.append(review_dict) - - return { - "status": "success", - "data": {"reviews": result} - } + return {'status': 'success', 'data': {'reviews': result}} except HTTPException: raise except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/service_booking_routes.py b/Backend/src/routes/service_booking_routes.py index c50f8d68..2d66de0b 100644 --- a/Backend/src/routes/service_booking_routes.py +++ b/Backend/src/routes/service_booking_routes.py @@ -21,22 +21,18 @@ from ..config.settings import settings router = APIRouter(prefix="/service-bookings", tags=["service-bookings"]) - def generate_service_booking_number() -> str: - """Generate unique service booking number""" prefix = "SB" timestamp = datetime.utcnow().strftime("%Y%m%d") random_suffix = random.randint(1000, 9999) return f"{prefix}{timestamp}{random_suffix}" - @router.post("/") async def create_service_booking( booking_data: dict, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Create a new service booking""" try: services = booking_data.get("services", []) total_amount = float(booking_data.get("total_amount", 0)) @@ -48,7 +44,7 @@ async def create_service_booking( if total_amount <= 0: raise HTTPException(status_code=400, detail="Total amount must be greater than 0") - # Validate services and calculate total + calculated_total = 0 service_items_data = [] @@ -59,7 +55,7 @@ async def create_service_booking( if not service_id: raise HTTPException(status_code=400, detail="Service ID is required for each item") - # Check if service exists and is active + service = db.query(Service).filter(Service.id == service_id).first() if not service: raise HTTPException(status_code=404, detail=f"Service with ID {service_id} not found") @@ -78,17 +74,17 @@ async def create_service_booking( "total_price": item_total }) - # Verify calculated total matches provided total (with small tolerance for floating point) + if abs(calculated_total - total_amount) > 0.01: raise HTTPException( status_code=400, detail=f"Total amount mismatch. Calculated: {calculated_total}, Provided: {total_amount}" ) - # Generate booking number + booking_number = generate_service_booking_number() - # Create service booking + service_booking = ServiceBooking( booking_number=booking_number, user_id=current_user.id, @@ -98,9 +94,9 @@ async def create_service_booking( ) db.add(service_booking) - db.flush() # Flush to get the ID + db.flush() - # Create service booking items + for item_data in service_items_data: booking_item = ServiceBookingItem( service_booking_id=service_booking.id, @@ -114,12 +110,12 @@ async def create_service_booking( db.commit() db.refresh(service_booking) - # Load relationships + service_booking = db.query(ServiceBooking).options( joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service) ).filter(ServiceBooking.id == service_booking.id).first() - # Format response + booking_dict = { "id": service_booking.id, "booking_number": service_booking.booking_number, @@ -157,13 +153,11 @@ async def create_service_booking( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - @router.get("/me") async def get_my_service_bookings( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Get all service bookings for current user""" try: bookings = db.query(ServiceBooking).options( joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service) @@ -204,14 +198,12 @@ async def get_my_service_bookings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.get("/{id}") async def get_service_booking_by_id( id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Get service booking by ID""" try: booking = db.query(ServiceBooking).options( joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service) @@ -220,7 +212,7 @@ async def get_service_booking_by_id( if not booking: raise HTTPException(status_code=404, detail="Service booking not found") - # Check access + if booking.user_id != current_user.id and current_user.role_id != 1: raise HTTPException(status_code=403, detail="Forbidden") @@ -259,7 +251,6 @@ async def get_service_booking_by_id( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.post("/{id}/payment/stripe/create-intent") async def create_service_stripe_payment_intent( id: int, @@ -267,9 +258,8 @@ async def create_service_stripe_payment_intent( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Create Stripe payment intent for service booking""" try: - # Check if Stripe is configured + secret_key = get_stripe_secret_key(db) if not secret_key: secret_key = settings.STRIPE_SECRET_KEY @@ -286,7 +276,7 @@ async def create_service_stripe_payment_intent( if amount <= 0: raise HTTPException(status_code=400, detail="Amount must be greater than 0") - # Verify service booking exists and user has access + booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first() if not booking: raise HTTPException(status_code=404, detail="Service booking not found") @@ -294,22 +284,22 @@ async def create_service_stripe_payment_intent( if booking.user_id != current_user.id and current_user.role_id != 1: raise HTTPException(status_code=403, detail="Forbidden") - # Verify amount matches booking total + if abs(float(booking.total_amount) - amount) > 0.01: raise HTTPException( status_code=400, detail=f"Amount mismatch. Booking total: {booking.total_amount}, Provided: {amount}" ) - # Create payment intent + intent = StripeService.create_payment_intent( amount=amount, currency=currency, - description=f"Service Booking #{booking.booking_number}", + description=f"Service Booking db=db ) - # Get publishable key + publishable_key = get_stripe_publishable_key(db) if not publishable_key: publishable_key = settings.STRIPE_PUBLISHABLE_KEY @@ -333,7 +323,6 @@ async def create_service_stripe_payment_intent( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.post("/{id}/payment/stripe/confirm") async def confirm_service_stripe_payment( id: int, @@ -341,14 +330,13 @@ async def confirm_service_stripe_payment( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Confirm Stripe payment for service booking""" try: payment_intent_id = payment_data.get("payment_intent_id") if not payment_intent_id: raise HTTPException(status_code=400, detail="payment_intent_id is required") - # Verify service booking exists and user has access + booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first() if not booking: raise HTTPException(status_code=404, detail="Service booking not found") @@ -356,7 +344,7 @@ async def confirm_service_stripe_payment( if booking.user_id != current_user.id and current_user.role_id != 1: raise HTTPException(status_code=403, detail="Forbidden") - # Retrieve and verify payment intent + intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db) if intent_data["status"] != "succeeded": @@ -365,15 +353,15 @@ async def confirm_service_stripe_payment( detail=f"Payment intent status is {intent_data['status']}, expected 'succeeded'" ) - # Verify amount matches - amount_paid = intent_data["amount"] / 100 # Convert from cents + + amount_paid = intent_data["amount"] / 100 if abs(float(booking.total_amount) - amount_paid) > 0.01: raise HTTPException( status_code=400, detail="Payment amount does not match booking total" ) - # Create payment record + payment = ServicePayment( service_booking_id=booking.id, amount=booking.total_amount, @@ -386,7 +374,7 @@ async def confirm_service_stripe_payment( db.add(payment) - # Update booking status + booking.status = ServiceBookingStatus.confirmed db.commit() diff --git a/Backend/src/routes/service_routes.py b/Backend/src/routes/service_routes.py index 86f33871..d0ba3886 100644 --- a/Backend/src/routes/service_routes.py +++ b/Backend/src/routes/service_routes.py @@ -2,276 +2,133 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import Optional - from ..config.database import get_db from ..middleware.auth import get_current_user, authorize_roles from ..models.user import User from ..models.service import Service from ..models.service_usage import ServiceUsage from ..models.booking import Booking, BookingStatus +router = APIRouter(prefix='/services', tags=['services']) -router = APIRouter(prefix="/services", tags=["services"]) - - -@router.get("/") -async def get_services( - 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), - db: Session = Depends(get_db) -): - """Get all services with filters""" +@router.get('/') +async def get_services(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), db: Session=Depends(get_db)): try: query = db.query(Service) - - # Filter by search (name or description) if search: - query = query.filter( - or_( - Service.name.like(f"%{search}%"), - Service.description.like(f"%{search}%") - ) - ) - - # Filter by status (is_active) + query = query.filter(or_(Service.name.like(f'%{search}%'), Service.description.like(f'%{search}%'))) if status_filter: - is_active = status_filter == "active" + is_active = status_filter == 'active' query = query.filter(Service.is_active == is_active) - total = query.count() offset = (page - 1) * limit services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all() - result = [] for service in services: - service_dict = { - "id": service.id, - "name": service.name, - "description": service.description, - "price": float(service.price) if service.price else 0.0, - "category": service.category, - "is_active": service.is_active, - "created_at": service.created_at.isoformat() if service.created_at else None, - } + service_dict = {'id': service.id, 'name': service.name, 'description': service.description, 'price': float(service.price) if service.price else 0.0, 'category': service.category, 'is_active': service.is_active, 'created_at': service.created_at.isoformat() if service.created_at else None} result.append(service_dict) - - return { - "status": "success", - "data": { - "services": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'services': 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_service_by_id(id: int, db: Session = Depends(get_db)): - """Get service by ID""" +@router.get('/{id}') +async def get_service_by_id(id: int, db: Session=Depends(get_db)): try: service = db.query(Service).filter(Service.id == id).first() if not service: - raise HTTPException(status_code=404, detail="Service not found") - - service_dict = { - "id": service.id, - "name": service.name, - "description": service.description, - "price": float(service.price) if service.price else 0.0, - "category": service.category, - "is_active": service.is_active, - "created_at": service.created_at.isoformat() if service.created_at else None, - } - - return { - "status": "success", - "data": {"service": service_dict} - } + raise HTTPException(status_code=404, detail='Service not found') + service_dict = {'id': service.id, 'name': service.name, 'description': service.description, 'price': float(service.price) if service.price else 0.0, 'category': service.category, 'is_active': service.is_active, 'created_at': service.created_at.isoformat() if service.created_at else None} + return {'status': 'success', 'data': {'service': service_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_service( - service_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Create new service (Admin only)""" +@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) +async def create_service(service_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - name = service_data.get("name") - - # Check if name exists + name = service_data.get('name') existing = db.query(Service).filter(Service.name == name).first() if existing: - raise HTTPException(status_code=400, detail="Service name already exists") - - service = Service( - name=name, - description=service_data.get("description"), - price=float(service_data.get("price", 0)), - category=service_data.get("category"), - is_active=service_data.get("status") == "active" if service_data.get("status") else True, - ) - + raise HTTPException(status_code=400, detail='Service name already exists') + service = Service(name=name, description=service_data.get('description'), price=float(service_data.get('price', 0)), category=service_data.get('category'), is_active=service_data.get('status') == 'active' if service_data.get('status') else True) db.add(service) db.commit() db.refresh(service) - - return { - "status": "success", - "message": "Service created successfully", - "data": {"service": service} - } + return {'status': 'success', 'message': 'Service created successfully', 'data': {'service': service}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))]) -async def update_service( - id: int, - service_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Update service (Admin only)""" +@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def update_service(id: int, service_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: service = db.query(Service).filter(Service.id == id).first() if not service: - raise HTTPException(status_code=404, detail="Service not found") - - # Check if new name exists (excluding current) - name = service_data.get("name") + raise HTTPException(status_code=404, detail='Service not found') + name = service_data.get('name') if name and name != service.name: - existing = db.query(Service).filter( - Service.name == name, - Service.id != id - ).first() + existing = db.query(Service).filter(Service.name == name, Service.id != id).first() if existing: - raise HTTPException(status_code=400, detail="Service name already exists") - - # Update fields - if "name" in service_data: - service.name = service_data["name"] - if "description" in service_data: - service.description = service_data["description"] - if "price" in service_data: - service.price = float(service_data["price"]) - if "category" in service_data: - service.category = service_data["category"] - if "status" in service_data: - service.is_active = service_data["status"] == "active" - + raise HTTPException(status_code=400, detail='Service name already exists') + if 'name' in service_data: + service.name = service_data['name'] + if 'description' in service_data: + service.description = service_data['description'] + if 'price' in service_data: + service.price = float(service_data['price']) + if 'category' in service_data: + service.category = service_data['category'] + if 'status' in service_data: + service.is_active = service_data['status'] == 'active' db.commit() db.refresh(service) - - return { - "status": "success", - "message": "Service updated successfully", - "data": {"service": service} - } + return {'status': 'success', 'message': 'Service updated successfully', 'data': {'service': service}} except HTTPException: raise 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_service( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete service (Admin only)""" +@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_service(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: service = db.query(Service).filter(Service.id == id).first() if not service: - raise HTTPException(status_code=404, detail="Service not found") - - # Check if service is used in active bookings - active_usage = db.query(ServiceUsage).join(Booking).filter( - ServiceUsage.service_id == id, - Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in]) - ).count() - + raise HTTPException(status_code=404, detail='Service not found') + active_usage = db.query(ServiceUsage).join(Booking).filter(ServiceUsage.service_id == id, Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count() if active_usage > 0: - raise HTTPException( - status_code=400, - detail="Cannot delete service that is used in active bookings" - ) - + raise HTTPException(status_code=400, detail='Cannot delete service that is used in active bookings') db.delete(service) db.commit() - - return { - "status": "success", - "message": "Service deleted successfully" - } + return {'status': 'success', 'message': 'Service deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.post("/use") -async def use_service( - usage_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Add service to booking""" +@router.post('/use') +async def use_service(usage_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - booking_id = usage_data.get("booking_id") - service_id = usage_data.get("service_id") - quantity = usage_data.get("quantity", 1) - - # Check if booking exists + booking_id = usage_data.get('booking_id') + service_id = usage_data.get('service_id') + quantity = usage_data.get('quantity', 1) booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise HTTPException(status_code=404, detail="Booking not found") - - # Check if service exists and is active + raise HTTPException(status_code=404, detail='Booking not found') service = db.query(Service).filter(Service.id == service_id).first() if not service or not service.is_active: - raise HTTPException(status_code=404, detail="Service not found or inactive") - - # Calculate total price + raise HTTPException(status_code=404, detail='Service not found or inactive') total_price = float(service.price) * quantity - - # Create service usage - service_usage = ServiceUsage( - booking_id=booking_id, - service_id=service_id, - quantity=quantity, - unit_price=service.price, - total_price=total_price, - ) - + service_usage = ServiceUsage(booking_id=booking_id, service_id=service_id, quantity=quantity, unit_price=service.price, total_price=total_price) db.add(service_usage) db.commit() db.refresh(service_usage) - - return { - "status": "success", - "message": "Service added to booking successfully", - "data": {"bookingService": service_usage} - } + return {'status': 'success', 'message': 'Service added to booking successfully', 'data': {'bookingService': service_usage}} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/routes/system_settings_routes.py b/Backend/src/routes/system_settings_routes.py index bb87387b..b56b4513 100644 --- a/Backend/src/routes/system_settings_routes.py +++ b/Backend/src/routes/system_settings_routes.py @@ -16,9 +16,7 @@ from ..models.system_settings import SystemSettings from ..utils.mailer import send_email from ..services.room_service import get_base_url - def normalize_image_url(image_url: str, base_url: str) -> str: - """Normalize image URL to absolute URL""" if not image_url: return image_url if image_url.startswith('http://') or image_url.startswith('https://'): @@ -31,19 +29,17 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"]) - @router.get("/currency") async def get_platform_currency( db: Session = Depends(get_db) ): - """Get platform currency setting (public endpoint for frontend)""" try: setting = db.query(SystemSettings).filter( SystemSettings.key == "platform_currency" ).first() if not setting: - # Default to VND if not set + return { "status": "success", "data": { @@ -64,25 +60,23 @@ async def get_platform_currency( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.put("/currency") async def update_platform_currency( currency_data: dict, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Update platform currency (Admin only)""" try: currency = currency_data.get("currency", "").upper() - # Validate currency code + if not currency or len(currency) != 3 or not currency.isalpha(): raise HTTPException( status_code=400, detail="Invalid currency code. Must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)" ) - # Get or create setting + setting = db.query(SystemSettings).filter( SystemSettings.key == "platform_currency" ).first() @@ -117,13 +111,11 @@ async def update_platform_currency( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - @router.get("/") async def get_all_settings( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Get all system settings (Admin only)""" try: settings = db.query(SystemSettings).all() @@ -146,13 +138,11 @@ async def get_all_settings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.get("/stripe") async def get_stripe_settings( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Get Stripe payment settings (Admin only)""" try: secret_key_setting = db.query(SystemSettings).filter( SystemSettings.key == "stripe_secret_key" @@ -166,7 +156,7 @@ async def get_stripe_settings( SystemSettings.key == "stripe_webhook_secret" ).first() - # Mask secret keys for security (only show last 4 characters) + def mask_key(key_value: str) -> str: if not key_value or len(key_value) < 4: return "" @@ -206,41 +196,39 @@ async def get_stripe_settings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.put("/stripe") async def update_stripe_settings( stripe_data: dict, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Update Stripe payment settings (Admin only)""" try: secret_key = stripe_data.get("stripe_secret_key", "").strip() publishable_key = stripe_data.get("stripe_publishable_key", "").strip() webhook_secret = stripe_data.get("stripe_webhook_secret", "").strip() - # Validate secret key format (should start with sk_) + if secret_key and not secret_key.startswith("sk_"): raise HTTPException( status_code=400, detail="Invalid Stripe secret key format. Must start with 'sk_'" ) - # Validate publishable key format (should start with pk_) + if publishable_key and not publishable_key.startswith("pk_"): raise HTTPException( status_code=400, detail="Invalid Stripe publishable key format. Must start with 'pk_'" ) - # Validate webhook secret format (should start with whsec_) + if webhook_secret and not webhook_secret.startswith("whsec_"): raise HTTPException( status_code=400, detail="Invalid Stripe webhook secret format. Must start with 'whsec_'" ) - # Update or create secret key setting + if secret_key: setting = db.query(SystemSettings).filter( SystemSettings.key == "stripe_secret_key" @@ -258,7 +246,7 @@ async def update_stripe_settings( ) db.add(setting) - # Update or create publishable key setting + if publishable_key: setting = db.query(SystemSettings).filter( SystemSettings.key == "stripe_publishable_key" @@ -276,7 +264,7 @@ async def update_stripe_settings( ) db.add(setting) - # Update or create webhook secret setting + if webhook_secret: setting = db.query(SystemSettings).filter( SystemSettings.key == "stripe_webhook_secret" @@ -296,7 +284,7 @@ async def update_stripe_settings( db.commit() - # Return masked values + def mask_key(key_value: str) -> str: if not key_value or len(key_value) < 4: return "" @@ -322,13 +310,11 @@ async def update_stripe_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - @router.get("/paypal") async def get_paypal_settings( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Get PayPal payment settings (Admin only)""" try: client_id_setting = db.query(SystemSettings).filter( SystemSettings.key == "paypal_client_id" @@ -342,7 +328,7 @@ async def get_paypal_settings( SystemSettings.key == "paypal_mode" ).first() - # Mask secret for security (only show last 4 characters) + def mask_key(key_value: str) -> str: if not key_value or len(key_value) < 4: return "" @@ -378,27 +364,25 @@ async def get_paypal_settings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.put("/paypal") async def update_paypal_settings( paypal_data: dict, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Update PayPal payment settings (Admin only)""" try: client_id = paypal_data.get("paypal_client_id", "").strip() client_secret = paypal_data.get("paypal_client_secret", "").strip() mode = paypal_data.get("paypal_mode", "sandbox").strip().lower() - # Validate mode + if mode and mode not in ["sandbox", "live"]: raise HTTPException( status_code=400, detail="Invalid PayPal mode. Must be 'sandbox' or 'live'" ) - # Update or create client ID setting + if client_id: setting = db.query(SystemSettings).filter( SystemSettings.key == "paypal_client_id" @@ -416,7 +400,7 @@ async def update_paypal_settings( ) db.add(setting) - # Update or create client secret setting + if client_secret: setting = db.query(SystemSettings).filter( SystemSettings.key == "paypal_client_secret" @@ -434,7 +418,7 @@ async def update_paypal_settings( ) db.add(setting) - # Update or create mode setting + if mode: setting = db.query(SystemSettings).filter( SystemSettings.key == "paypal_mode" @@ -454,7 +438,7 @@ async def update_paypal_settings( db.commit() - # Return masked values + def mask_key(key_value: str) -> str: if not key_value or len(key_value) < 4: return "" @@ -478,15 +462,13 @@ async def update_paypal_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - @router.get("/smtp") async def get_smtp_settings( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Get SMTP email server settings (Admin only)""" try: - # Get all SMTP settings + smtp_settings = {} setting_keys = [ "smtp_host", @@ -505,7 +487,7 @@ async def get_smtp_settings( if setting: smtp_settings[key] = setting.value - # Mask password for security (only show last 4 characters if set) + def mask_password(password_value: str) -> str: if not password_value or len(password_value) < 4: return "" @@ -525,7 +507,7 @@ async def get_smtp_settings( "has_password": bool(smtp_settings.get("smtp_password")), } - # Get updated_at and updated_by from any setting (prefer password setting if exists) + password_setting = db.query(SystemSettings).filter( SystemSettings.key == "smtp_password" ).first() @@ -534,7 +516,7 @@ async def get_smtp_settings( result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None else: - # Try to get from any other SMTP setting + any_setting = db.query(SystemSettings).filter( SystemSettings.key.in_(setting_keys) ).first() @@ -552,14 +534,12 @@ async def get_smtp_settings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.put("/smtp") async def update_smtp_settings( smtp_data: dict, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Update SMTP email server settings (Admin only)""" try: smtp_host = smtp_data.get("smtp_host", "").strip() smtp_port = smtp_data.get("smtp_port", "").strip() @@ -569,7 +549,7 @@ async def update_smtp_settings( smtp_from_name = smtp_data.get("smtp_from_name", "").strip() smtp_use_tls = smtp_data.get("smtp_use_tls", True) - # Validate required fields if provided + if smtp_host and not smtp_host: raise HTTPException( status_code=400, @@ -591,14 +571,14 @@ async def update_smtp_settings( ) if smtp_from_email: - # Basic email validation + if "@" not in smtp_from_email or "." not in smtp_from_email.split("@")[1]: raise HTTPException( status_code=400, detail="Invalid email address format for 'From Email'" ) - # Helper function to update or create setting + def update_setting(key: str, value: str, description: str): setting = db.query(SystemSettings).filter( SystemSettings.key == key @@ -616,7 +596,7 @@ async def update_smtp_settings( ) db.add(setting) - # Update or create settings (only update if value is provided) + if smtp_host: update_setting( "smtp_host", @@ -659,7 +639,7 @@ async def update_smtp_settings( "Default 'From' name for outgoing emails" ) - # Update TLS setting (convert boolean to string) + if smtp_use_tls is not None: update_setting( "smtp_use_tls", @@ -669,13 +649,13 @@ async def update_smtp_settings( db.commit() - # Return updated settings with masked password + def mask_password(password_value: str) -> str: if not password_value or len(password_value) < 4: return "" return "*" * (len(password_value) - 4) + password_value[-4:] - # Get updated settings + updated_settings = {} for key in ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_email", "smtp_from_name", "smtp_use_tls"]: setting = db.query(SystemSettings).filter( @@ -698,7 +678,7 @@ async def update_smtp_settings( "has_password": bool(updated_settings.get("smtp_password")), } - # Get updated_by from password setting if it exists + password_setting = db.query(SystemSettings).filter( SystemSettings.key == "smtp_password" ).first() @@ -717,131 +697,28 @@ async def update_smtp_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - class TestEmailRequest(BaseModel): email: EmailStr - @router.post("/smtp/test") async def test_smtp_email( request: TestEmailRequest, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Send a test email to verify SMTP settings (Admin only)""" try: test_email = str(request.email) admin_name = str(current_user.full_name or current_user.email or "Admin") timestamp_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") - # Create test email HTML content - test_html = f""" - - - - - - - -
-

✅ SMTP Test Email

-
-
-
-
🎉
-

Email Configuration Test Successful!

-
- -

This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.

- -
- 📧 Test Details: - -
- -

If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.

- -

What's next?

- - - -
- - - """ + + test_html = f - # Plain text version - test_text = f""" -SMTP Test Email -This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly. - -Test Details: -- Recipient: {test_email} -- Sent by: {admin_name} -- Time: {timestamp_str} - -If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server. - -This is an automated test email from Hotel Booking System -If you did not request this test, please ignore this email. - """.strip() + test_text = f +.strip() - # Send the test email + await send_email( to=test_email, subject="SMTP Test Email - Hotel Booking System", @@ -860,13 +737,13 @@ If you did not request this test, please ignore this email. } } except HTTPException: - # Re-raise HTTP exceptions (like validation errors from send_email) + raise except Exception as e: error_msg = str(e) logger.error(f"Error sending test email: {type(e).__name__}: {error_msg}", exc_info=True) - # Provide more user-friendly error messages + if "SMTP mailer not configured" in error_msg: raise HTTPException( status_code=400, @@ -888,7 +765,6 @@ If you did not request this test, please ignore this email. detail=f"Failed to send test email: {error_msg}" ) - class UpdateCompanySettingsRequest(BaseModel): company_name: Optional[str] = None company_tagline: Optional[str] = None @@ -897,12 +773,10 @@ class UpdateCompanySettingsRequest(BaseModel): company_address: Optional[str] = None tax_rate: Optional[float] = None - @router.get("/company") async def get_company_settings( db: Session = Depends(get_db) ): - """Get company settings (public endpoint for frontend)""" try: setting_keys = [ "company_name", @@ -925,7 +799,7 @@ async def get_company_settings( else: settings_dict[key] = None - # Get updated_at and updated_by from logo setting if exists + logo_setting = db.query(SystemSettings).filter( SystemSettings.key == "company_logo_url" ).first() @@ -954,14 +828,12 @@ async def get_company_settings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.put("/company") async def update_company_settings( request_data: UpdateCompanySettingsRequest, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Update company settings (Admin only)""" try: db_settings = {} @@ -979,18 +851,18 @@ async def update_company_settings( db_settings["tax_rate"] = str(request_data.tax_rate) for key, value in db_settings.items(): - # Find or create setting + setting = db.query(SystemSettings).filter( SystemSettings.key == key ).first() if setting: - # Update existing + setting.value = value if value else None setting.updated_at = datetime.utcnow() setting.updated_by_id = current_user.id else: - # Create new + setting = SystemSettings( key=key, value=value if value else None, @@ -1000,7 +872,7 @@ async def update_company_settings( db.commit() - # Get updated settings + updated_settings = {} for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate"]: setting = db.query(SystemSettings).filter( @@ -1011,7 +883,7 @@ async def update_company_settings( else: updated_settings[key] = None - # Get updated_at and updated_by + logo_setting = db.query(SystemSettings).filter( SystemSettings.key == "company_logo_url" ).first() @@ -1048,7 +920,6 @@ async def update_company_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - @router.post("/company/logo") async def upload_company_logo( request: Request, @@ -1056,28 +927,27 @@ async def upload_company_logo( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Upload company logo (Admin only)""" try: - # Validate file type + if not image.content_type or not image.content_type.startswith('image/'): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="File must be an image" ) - # Validate file size (max 2MB) + content = await image.read() - if len(content) > 2 * 1024 * 1024: # 2MB + if len(content) > 2 * 1024 * 1024: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Logo file size must be less than 2MB" ) - # Create uploads directory + upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company" upload_dir.mkdir(parents=True, exist_ok=True) - # Delete old logo if exists + old_logo_setting = db.query(SystemSettings).filter( SystemSettings.key == "company_logo_url" ).first() @@ -1090,20 +960,20 @@ async def upload_company_logo( except Exception as e: logger.warning(f"Could not delete old logo: {e}") - # Generate filename + ext = Path(image.filename).suffix or '.png' - # Always use logo.png to ensure we only have one logo + filename = "logo.png" file_path = upload_dir / filename - # Save file + async with aiofiles.open(file_path, 'wb') as f: await f.write(content) - # Store the URL in system_settings + image_url = f"/uploads/company/{filename}" - # Update or create setting + logo_setting = db.query(SystemSettings).filter( SystemSettings.key == "company_logo_url" ).first() @@ -1122,7 +992,7 @@ async def upload_company_logo( db.commit() - # Return the image URL + base_url = get_base_url(request) full_url = normalize_image_url(image_url, base_url) @@ -1142,7 +1012,6 @@ async def upload_company_logo( logger.error(f"Error uploading logo: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) - @router.post("/company/favicon") async def upload_company_favicon( request: Request, @@ -1150,9 +1019,8 @@ async def upload_company_favicon( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Upload company favicon (Admin only)""" try: - # Validate file type (favicon can be ico, png, svg) + if not image.content_type: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -1161,7 +1029,7 @@ async def upload_company_favicon( allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico'] if image.content_type not in allowed_types: - # Check filename extension as fallback + filename_lower = (image.filename or '').lower() if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']): raise HTTPException( @@ -1169,19 +1037,19 @@ async def upload_company_favicon( detail="Favicon must be .ico, .png, or .svg file" ) - # Validate file size (max 500KB) + content = await image.read() - if len(content) > 500 * 1024: # 500KB + if len(content) > 500 * 1024: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Favicon file size must be less than 500KB" ) - # Create uploads directory + upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company" upload_dir.mkdir(parents=True, exist_ok=True) - # Delete old favicon if exists + old_favicon_setting = db.query(SystemSettings).filter( SystemSettings.key == "company_favicon_url" ).first() @@ -1194,7 +1062,7 @@ async def upload_company_favicon( except Exception as e: logger.warning(f"Could not delete old favicon: {e}") - # Generate filename - preserve extension but use standard name + filename_lower = (image.filename or '').lower() if filename_lower.endswith('.ico'): filename = "favicon.ico" @@ -1205,14 +1073,14 @@ async def upload_company_favicon( file_path = upload_dir / filename - # Save file + async with aiofiles.open(file_path, 'wb') as f: await f.write(content) - # Store the URL in system_settings + image_url = f"/uploads/company/{filename}" - # Update or create setting + favicon_setting = db.query(SystemSettings).filter( SystemSettings.key == "company_favicon_url" ).first() @@ -1231,7 +1099,7 @@ async def upload_company_favicon( db.commit() - # Return the image URL + base_url = get_base_url(request) full_url = normalize_image_url(image_url, base_url) @@ -1251,12 +1119,10 @@ async def upload_company_favicon( logger.error(f"Error uploading favicon: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) - @router.get("/recaptcha") async def get_recaptcha_settings( db: Session = Depends(get_db) ): - """Get reCAPTCHA settings (Public endpoint for frontend)""" try: site_key_setting = db.query(SystemSettings).filter( SystemSettings.key == "recaptcha_site_key" @@ -1284,13 +1150,11 @@ async def get_recaptcha_settings( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.get("/recaptcha/admin") async def get_recaptcha_settings_admin( current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Get reCAPTCHA settings (Admin only - includes secret key)""" try: site_key_setting = db.query(SystemSettings).filter( SystemSettings.key == "recaptcha_site_key" @@ -1304,7 +1168,7 @@ async def get_recaptcha_settings_admin( SystemSettings.key == "recaptcha_enabled" ).first() - # Mask secret for security (only show last 4 characters) + def mask_key(key_value: str) -> str: if not key_value or len(key_value) < 4: return "" @@ -1340,20 +1204,18 @@ async def get_recaptcha_settings_admin( except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - @router.put("/recaptcha") async def update_recaptcha_settings( recaptcha_data: dict, current_user: User = Depends(authorize_roles("admin")), db: Session = Depends(get_db) ): - """Update reCAPTCHA settings (Admin only)""" try: site_key = recaptcha_data.get("recaptcha_site_key", "").strip() secret_key = recaptcha_data.get("recaptcha_secret_key", "").strip() enabled = recaptcha_data.get("recaptcha_enabled", False) - # Update or create site key setting + if site_key: setting = db.query(SystemSettings).filter( SystemSettings.key == "recaptcha_site_key" @@ -1371,7 +1233,7 @@ async def update_recaptcha_settings( ) db.add(setting) - # Update or create secret key setting + if secret_key: setting = db.query(SystemSettings).filter( SystemSettings.key == "recaptcha_secret_key" @@ -1389,7 +1251,7 @@ async def update_recaptcha_settings( ) db.add(setting) - # Update or create enabled setting + setting = db.query(SystemSettings).filter( SystemSettings.key == "recaptcha_enabled" ).first() @@ -1408,7 +1270,7 @@ async def update_recaptcha_settings( db.commit() - # Return masked values + def mask_key(key_value: str) -> str: if not key_value or len(key_value) < 4: return "" @@ -1432,13 +1294,11 @@ async def update_recaptcha_settings( db.rollback() raise HTTPException(status_code=500, detail=str(e)) - @router.post("/recaptcha/verify") async def verify_recaptcha( verification_data: dict, db: Session = Depends(get_db) ): - """Verify reCAPTCHA token (Public endpoint)""" try: token = verification_data.get("token", "").strip() @@ -1448,7 +1308,7 @@ async def verify_recaptcha( detail="reCAPTCHA token is required" ) - # Get reCAPTCHA settings + enabled_setting = db.query(SystemSettings).filter( SystemSettings.key == "recaptcha_enabled" ).first() @@ -1457,13 +1317,13 @@ async def verify_recaptcha( SystemSettings.key == "recaptcha_secret_key" ).first() - # Check if reCAPTCHA is enabled + is_enabled = False if enabled_setting: is_enabled = enabled_setting.value.lower() == "true" if enabled_setting.value else False if not is_enabled: - # If disabled, always return success + return { "status": "success", "data": { @@ -1478,7 +1338,7 @@ async def verify_recaptcha( detail="reCAPTCHA secret key is not configured" ) - # Verify with Google reCAPTCHA API + import httpx async with httpx.AsyncClient() as client: @@ -1498,8 +1358,8 @@ async def verify_recaptcha( "status": "success", "data": { "verified": True, - "score": result.get("score"), # For v3 - "action": result.get("action") # For v3 + "score": result.get("score"), + "action": result.get("action") } } else: diff --git a/Backend/src/routes/user_routes.py b/Backend/src/routes/user_routes.py index d908d115..3ceb586e 100644 --- a/Backend/src/routes/user_routes.py +++ b/Backend/src/routes/user_routes.py @@ -3,323 +3,136 @@ from sqlalchemy.orm import Session from sqlalchemy import or_ from typing import Optional import bcrypt - 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.booking import Booking, BookingStatus +router = APIRouter(prefix='/users', tags=['users']) -router = APIRouter(prefix="/users", tags=["users"]) - - -@router.get("/", dependencies=[Depends(authorize_roles("admin"))]) -async def get_users( - search: Optional[str] = Query(None), - role: Optional[str] = Query(None), - status_filter: Optional[str] = Query(None, alias="status"), - page: int = Query(1, ge=1), - limit: int = Query(10, ge=1, le=100), - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Get all users with filters and pagination (Admin only)""" +@router.get('/', dependencies=[Depends(authorize_roles('admin'))]) +async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: query = db.query(User) - - # Filter by search (full_name, email, phone) if search: - query = query.filter( - or_( - User.full_name.like(f"%{search}%"), - User.email.like(f"%{search}%"), - User.phone.like(f"%{search}%") - ) - ) - - # Filter by role + query = query.filter(or_(User.full_name.like(f'%{search}%'), User.email.like(f'%{search}%'), User.phone.like(f'%{search}%'))) if role: - role_map = {"admin": 1, "staff": 2, "customer": 3} + role_map = {'admin': 1, 'staff': 2, 'customer': 3} if role in role_map: query = query.filter(User.role_id == role_map[role]) - - # Filter by status if status_filter: - is_active = status_filter == "active" + is_active = status_filter == 'active' query = query.filter(User.is_active == is_active) - - # Get total count total = query.count() - - # Apply pagination offset = (page - 1) * limit users = query.order_by(User.created_at.desc()).offset(offset).limit(limit).all() - - # Transform users result = [] for user in users: - user_dict = { - "id": user.id, - "email": user.email, - "full_name": user.full_name, - "phone": user.phone, - "phone_number": user.phone, # For frontend compatibility - "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) - - return { - "status": "success", - "data": { - "users": result, - "pagination": { - "total": total, - "page": page, - "limit": limit, - "totalPages": (total + limit - 1) // limit, - }, - }, - } + return {'status': 'success', 'data': {'users': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) - -@router.get("/{id}", dependencies=[Depends(authorize_roles("admin"))]) -async def get_user_by_id( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Get user by ID (Admin only)""" +@router.get('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: user = db.query(User).filter(User.id == id).first() - if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Get recent bookings - bookings = db.query(Booking).filter( - Booking.user_id == id - ).order_by(Booking.created_at.desc()).limit(5).all() - - user_dict = { - "id": user.id, - "email": user.email, - "full_name": user.full_name, - "phone": user.phone, - "phone_number": user.phone, - "address": user.address, - "avatar": user.avatar, - "currency": getattr(user, 'currency', 'VND'), - "is_active": user.is_active, - "status": "active" if user.is_active else "inactive", - "role_id": user.role_id, - "role": user.role.name if user.role else "customer", - "created_at": user.created_at.isoformat() if user.created_at else None, - "updated_at": user.updated_at.isoformat() if user.updated_at else None, - "bookings": [ - { - "id": b.id, - "booking_number": b.booking_number, - "status": b.status.value if isinstance(b.status, BookingStatus) else b.status, - "created_at": b.created_at.isoformat() if b.created_at else None, - } - for b in bookings - ], - } - - return { - "status": "success", - "data": {"user": user_dict} - } + raise HTTPException(status_code=404, detail='User not found') + bookings = db.query(Booking).filter(Booking.user_id == id).order_by(Booking.created_at.desc()).limit(5).all() + user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'address': user.address, 'avatar': user.avatar, 'currency': getattr(user, 'currency', 'VND'), 'is_active': user.is_active, 'status': 'active' if user.is_active else 'inactive', 'role_id': user.role_id, 'role': user.role.name if user.role else 'customer', 'created_at': user.created_at.isoformat() if user.created_at else None, 'updated_at': user.updated_at.isoformat() if user.updated_at else None, 'bookings': [{'id': b.id, 'booking_number': b.booking_number, 'status': b.status.value if isinstance(b.status, BookingStatus) else b.status, 'created_at': b.created_at.isoformat() if b.created_at else None} for b in bookings]} + return {'status': 'success', 'data': {'user': user_dict}} 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_user( - user_data: dict, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Create new user (Admin only)""" +@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) +async def create_user(user_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - email = user_data.get("email") - password = user_data.get("password") - full_name = user_data.get("full_name") - phone_number = user_data.get("phone_number") - role = user_data.get("role", "customer") - status = user_data.get("status", "active") - - # Map role string to role_id - role_map = {"admin": 1, "staff": 2, "customer": 3} + email = user_data.get('email') + password = user_data.get('password') + full_name = user_data.get('full_name') + phone_number = user_data.get('phone_number') + role = user_data.get('role', 'customer') + status = user_data.get('status', 'active') + role_map = {'admin': 1, 'staff': 2, 'customer': 3} role_id = role_map.get(role, 3) - - # Check if email exists existing = db.query(User).filter(User.email == email).first() if existing: - raise HTTPException(status_code=400, detail="Email already exists") - - # Hash password + raise HTTPException(status_code=400, detail='Email already exists') password_bytes = password.encode('utf-8') salt = bcrypt.gensalt() hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') - - # Create user - user = User( - email=email, - password=hashed_password, - full_name=full_name, - phone=phone_number, - role_id=role_id, - is_active=status == "active", - ) - + user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=status == 'active') db.add(user) db.commit() db.refresh(user) - - # Remove password from response - 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} - } + 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}} except HTTPException: raise except Exception as e: db.rollback() raise HTTPException(status_code=500, detail=str(e)) - -@router.put("/{id}") -async def update_user( - id: int, - user_data: dict, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): - """Update user""" +@router.put('/{id}') +async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): try: - # Users can only update themselves unless they're admin if current_user.role_id != 1 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() if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Check if email is being changed and if it's taken - email = user_data.get("email") + raise HTTPException(status_code=404, detail='User not found') + email = user_data.get('email') if email and email != user.email: existing = db.query(User).filter(User.email == email).first() if existing: - raise HTTPException(status_code=400, detail="Email already exists") - - # Map role string to role_id (only admin can change role) - role_map = {"admin": 1, "staff": 2, "customer": 3} - - # Update fields - if "full_name" in user_data: - user.full_name = user_data["full_name"] - if "email" in user_data and current_user.role_id == 1: - user.email = user_data["email"] - if "phone_number" in user_data: - user.phone = user_data["phone_number"] - if "role" in user_data and current_user.role_id == 1: - user.role_id = role_map.get(user_data["role"], 3) - if "status" in user_data and current_user.role_id == 1: - user.is_active = user_data["status"] == "active" - if "currency" in user_data: - currency = user_data["currency"] + raise HTTPException(status_code=400, detail='Email already exists') + role_map = {'admin': 1, 'staff': 2, 'customer': 3} + if 'full_name' in user_data: + user.full_name = user_data['full_name'] + if 'email' in user_data and current_user.role_id == 1: + user.email = user_data['email'] + if 'phone_number' in user_data: + user.phone = user_data['phone_number'] + if 'role' in user_data and current_user.role_id == 1: + user.role_id = role_map.get(user_data['role'], 3) + if 'status' in user_data and current_user.role_id == 1: + user.is_active = user_data['status'] == 'active' + if 'currency' in user_data: + currency = user_data['currency'] if len(currency) == 3 and currency.isalpha(): user.currency = currency.upper() - if "password" in user_data: - password_bytes = user_data["password"].encode('utf-8') + if 'password' in user_data: + password_bytes = user_data['password'].encode('utf-8') salt = bcrypt.gensalt() user.password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') - db.commit() db.refresh(user) - - # Remove password from response - 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} - } + 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}} except HTTPException: raise 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_user( - id: int, - current_user: User = Depends(authorize_roles("admin")), - db: Session = Depends(get_db) -): - """Delete user (Admin only)""" +@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) +async def delete_user(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: user = db.query(User).filter(User.id == id).first() if not user: - raise HTTPException(status_code=404, detail="User not found") - - # Check if user has active bookings - active_bookings = db.query(Booking).filter( - Booking.user_id == id, - Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in]) - ).count() - + raise HTTPException(status_code=404, detail='User not found') + active_bookings = db.query(Booking).filter(Booking.user_id == id, Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count() if active_bookings > 0: - raise HTTPException( - status_code=400, - detail="Cannot delete user with active bookings" - ) - + raise HTTPException(status_code=400, detail='Cannot delete user with active bookings') db.delete(user) db.commit() - - return { - "status": "success", - "message": "User deleted successfully" - } + return {'status': 'success', 'message': 'User deleted successfully'} except HTTPException: raise except Exception as e: db.rollback() - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException(status_code=500, detail=str(e)) \ No newline at end of file diff --git a/Backend/src/schemas/admin_privacy.py b/Backend/src/schemas/admin_privacy.py index 7000a19b..c9f813f4 100644 --- a/Backend/src/schemas/admin_privacy.py +++ b/Backend/src/schemas/admin_privacy.py @@ -1,68 +1,32 @@ from datetime import datetime from typing import Optional - from pydantic import BaseModel, Field - class CookiePolicySettings(BaseModel): - """ - Admin-configurable global cookie policy. - Controls which categories can be used in the application. - """ - - analytics_enabled: bool = Field( - default=True, - description="If false, analytics cookies/scripts should not be used at all.", - ) - marketing_enabled: bool = Field( - default=True, - description="If false, marketing cookies/scripts should not be used at all.", - ) - preferences_enabled: bool = Field( - default=True, - description="If false, preference cookies should not be used at all.", - ) - + analytics_enabled: bool = Field(default=True, description='If false, analytics cookies/scripts should not be used at all.') + marketing_enabled: bool = Field(default=True, description='If false, marketing cookies/scripts should not be used at all.') + preferences_enabled: bool = Field(default=True, description='If false, preference cookies should not be used at all.') class CookiePolicySettingsResponse(BaseModel): - status: str = Field(default="success") + status: str = Field(default='success') data: CookiePolicySettings updated_at: Optional[datetime] = None updated_by: Optional[str] = None - class CookieIntegrationSettings(BaseModel): - """ - IDs for well-known third-party integrations, configured by admin. - """ - - ga_measurement_id: Optional[str] = Field( - default=None, description="Google Analytics 4 measurement ID (e.g. G-XXXXXXX)." - ) - fb_pixel_id: Optional[str] = Field( - default=None, description="Meta (Facebook) Pixel ID." - ) - + ga_measurement_id: Optional[str] = Field(default=None, description='Google Analytics 4 measurement ID (e.g. G-XXXXXXX).') + fb_pixel_id: Optional[str] = Field(default=None, description='Meta (Facebook) Pixel ID.') class CookieIntegrationSettingsResponse(BaseModel): - status: str = Field(default="success") + status: str = Field(default='success') data: CookieIntegrationSettings updated_at: Optional[datetime] = None updated_by: Optional[str] = None - class PublicPrivacyConfig(BaseModel): - """ - Publicly consumable privacy configuration for the frontend. - Does not expose any secrets, only IDs and flags. - """ - policy: CookiePolicySettings integrations: CookieIntegrationSettings - class PublicPrivacyConfigResponse(BaseModel): - status: str = Field(default="success") - data: PublicPrivacyConfig - - + status: str = Field(default='success') + data: PublicPrivacyConfig \ No newline at end of file diff --git a/Backend/src/schemas/auth.py b/Backend/src/schemas/auth.py index 6f0e28f3..a5b7e9da 100644 --- a/Backend/src/schemas/auth.py +++ b/Backend/src/schemas/auth.py @@ -1,64 +1,58 @@ from pydantic import BaseModel, EmailStr, Field, validator from typing import Optional - class RegisterRequest(BaseModel): name: str = Field(..., min_length=2, max_length=50) email: EmailStr password: str = Field(..., min_length=8) phone: Optional[str] = None - @validator("password") + @validator('password') def validate_password(cls, v): if len(v) < 8: - raise ValueError("Password must be at least 8 characters") - if not any(c.isupper() for c in v): - raise ValueError("Password must contain at least one uppercase letter") - if not any(c.islower() for c in v): - raise ValueError("Password must contain at least one lowercase letter") - if not any(c.isdigit() for c in v): - raise ValueError("Password must contain at least one number") + raise ValueError('Password must be at least 8 characters') + if not any((c.isupper() for c in v)): + raise ValueError('Password must contain at least one uppercase letter') + if not any((c.islower() for c in v)): + raise ValueError('Password must contain at least one lowercase letter') + if not any((c.isdigit() for c in v)): + raise ValueError('Password must contain at least one number') return v - @validator("phone") + @validator('phone') def validate_phone(cls, v): - if v and not v.isdigit() or (v and len(v) not in [10, 11]): - raise ValueError("Phone must be 10-11 digits") + if v and (not v.isdigit()) or (v and len(v) not in [10, 11]): + raise ValueError('Phone must be 10-11 digits') return v - class LoginRequest(BaseModel): email: EmailStr password: str rememberMe: Optional[bool] = False mfaToken: Optional[str] = None - class RefreshTokenRequest(BaseModel): refreshToken: Optional[str] = None - class ForgotPasswordRequest(BaseModel): email: EmailStr - class ResetPasswordRequest(BaseModel): token: str password: str = Field(..., min_length=8) - @validator("password") + @validator('password') def validate_password(cls, v): if len(v) < 8: - raise ValueError("Password must be at least 8 characters") - if not any(c.isupper() for c in v): - raise ValueError("Password must contain at least one uppercase letter") - if not any(c.islower() for c in v): - raise ValueError("Password must contain at least one lowercase letter") - if not any(c.isdigit() for c in v): - raise ValueError("Password must contain at least one number") + raise ValueError('Password must be at least 8 characters') + if not any((c.isupper() for c in v)): + raise ValueError('Password must contain at least one uppercase letter') + if not any((c.islower() for c in v)): + raise ValueError('Password must contain at least one lowercase letter') + if not any((c.isdigit() for c in v)): + raise ValueError('Password must contain at least one number') return v - class UserResponse(BaseModel): id: int name: str @@ -71,38 +65,30 @@ class UserResponse(BaseModel): class Config: from_attributes = True - class AuthResponse(BaseModel): user: UserResponse token: str refreshToken: Optional[str] = None - class TokenResponse(BaseModel): token: str - class MessageResponse(BaseModel): status: str message: str - class MFAInitResponse(BaseModel): secret: str - qr_code: str # Base64 data URL - + qr_code: str class EnableMFARequest(BaseModel): secret: str verification_token: str - class VerifyMFARequest(BaseModel): token: str is_backup_code: Optional[bool] = False - class MFAStatusResponse(BaseModel): mfa_enabled: bool - backup_codes_count: int - + backup_codes_count: int \ No newline at end of file diff --git a/Backend/src/schemas/privacy.py b/Backend/src/schemas/privacy.py index b03d35d9..48162b2c 100644 --- a/Backend/src/schemas/privacy.py +++ b/Backend/src/schemas/privacy.py @@ -1,70 +1,24 @@ from datetime import datetime from typing import Optional - from pydantic import BaseModel, Field - class CookieCategoryPreferences(BaseModel): - """ - Granular consent for different cookie categories. - - - necessary: required for the site to function (always true, not revocable) - - analytics: usage analytics, performance tracking - - marketing: advertising, remarketing cookies - - preferences: UI / language / personalization preferences - """ - - necessary: bool = Field( - default=True, - description="Strictly necessary cookies (always enabled as they are required for core functionality).", - ) - analytics: bool = Field( - default=False, description="Allow anonymous analytics and performance cookies." - ) - marketing: bool = Field( - default=False, description="Allow marketing and advertising cookies." - ) - preferences: bool = Field( - default=False, - description="Allow preference cookies (e.g. language, layout settings).", - ) - + necessary: bool = Field(default=True, description='Strictly necessary cookies (always enabled as they are required for core functionality).') + analytics: bool = Field(default=False, description='Allow anonymous analytics and performance cookies.') + marketing: bool = Field(default=False, description='Allow marketing and advertising cookies.') + preferences: bool = Field(default=False, description='Allow preference cookies (e.g. language, layout settings).') class CookieConsent(BaseModel): - """ - Persisted cookie consent state. - Stored in an HttpOnly cookie and exposed via the API. - """ - - version: int = Field( - default=1, description="Consent schema version for future migrations." - ) - updated_at: datetime = Field( - default_factory=datetime.utcnow, description="Last time consent was updated." - ) - has_decided: bool = Field( - default=False, - description="Whether the user has actively made a consent choice.", - ) - categories: CookieCategoryPreferences = Field( - default_factory=CookieCategoryPreferences, - description="Granular per-category consent.", - ) - + version: int = Field(default=1, description='Consent schema version for future migrations.') + updated_at: datetime = Field(default_factory=datetime.utcnow, description='Last time consent was updated.') + has_decided: bool = Field(default=False, description='Whether the user has actively made a consent choice.') + categories: CookieCategoryPreferences = Field(default_factory=CookieCategoryPreferences, description='Granular per-category consent.') class CookieConsentResponse(BaseModel): - status: str = Field(default="success") + status: str = Field(default='success') data: CookieConsent - class UpdateCookieConsentRequest(BaseModel): - """ - Request body for updating cookie consent. - 'necessary' is ignored on write and always treated as True by the server. - """ - analytics: Optional[bool] = None marketing: Optional[bool] = None - preferences: Optional[bool] = None - - + preferences: Optional[bool] = None \ No newline at end of file diff --git a/Backend/src/services/audit_service.py b/Backend/src/services/audit_service.py index 98d52b2b..cfcc404a 100644 --- a/Backend/src/services/audit_service.py +++ b/Backend/src/services/audit_service.py @@ -1,82 +1,20 @@ -""" -Audit logging service for tracking important actions -""" from sqlalchemy.orm import Session from typing import Optional, Dict, Any from datetime import datetime from ..models.audit_log import AuditLog from ..config.logging_config import get_logger - logger = get_logger(__name__) - class AuditService: - """Service for creating audit log entries""" - + @staticmethod - async def log_action( - db: Session, - action: str, - resource_type: str, - user_id: Optional[int] = None, - resource_id: Optional[int] = None, - ip_address: Optional[str] = None, - user_agent: Optional[str] = None, - request_id: Optional[str] = None, - details: Optional[Dict[str, Any]] = None, - status: str = "success", - error_message: Optional[str] = None - ): - """ - Create an audit log entry - - Args: - db: Database session - action: Action performed (e.g., "user.created", "booking.cancelled") - resource_type: Type of resource (e.g., "user", "booking") - user_id: ID of user who performed the action - resource_id: ID of the resource affected - ip_address: IP address of the request - user_agent: User agent string - request_id: Request ID for tracing - details: Additional context as dictionary - status: Status of the action (success, failed, error) - error_message: Error message if action failed - """ + async def log_action(db: Session, action: str, resource_type: str, user_id: Optional[int]=None, resource_id: Optional[int]=None, ip_address: Optional[str]=None, user_agent: Optional[str]=None, request_id: Optional[str]=None, details: Optional[Dict[str, Any]]=None, status: str='success', error_message: Optional[str]=None): try: - audit_log = AuditLog( - user_id=user_id, - action=action, - resource_type=resource_type, - resource_id=resource_id, - ip_address=ip_address, - user_agent=user_agent, - request_id=request_id, - details=details, - status=status, - error_message=error_message - ) - + audit_log = AuditLog(user_id=user_id, action=action, resource_type=resource_type, resource_id=resource_id, ip_address=ip_address, user_agent=user_agent, request_id=request_id, details=details, status=status, error_message=error_message) db.add(audit_log) db.commit() - - logger.info( - f"Audit log created: {action} on {resource_type}", - extra={ - "action": action, - "resource_type": resource_type, - "resource_id": resource_id, - "user_id": user_id, - "status": status, - "request_id": request_id - } - ) + logger.info(f'Audit log created: {action} on {resource_type}', extra={'action': action, 'resource_type': resource_type, 'resource_id': resource_id, 'user_id': user_id, 'status': status, 'request_id': request_id}) except Exception as e: - logger.error(f"Failed to create audit log: {str(e)}", exc_info=True) + logger.error(f'Failed to create audit log: {str(e)}', exc_info=True) db.rollback() - # Don't raise exception - audit logging failures shouldn't break the app - - -# Global audit service instance -audit_service = AuditService() - +audit_service = AuditService() \ No newline at end of file diff --git a/Backend/src/services/auth_service.py b/Backend/src/services/auth_service.py index f8fc7cf0..57cf5ab6 100644 --- a/Backend/src/services/auth_service.py +++ b/Backend/src/services/auth_service.py @@ -22,17 +22,15 @@ import os logger = logging.getLogger(__name__) - class AuthService: def __init__(self): - # Use settings, fallback to env vars, then to defaults for development + self.jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345") self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") or (self.jwt_secret + "-refresh") self.jwt_expires_in = os.getenv("JWT_EXPIRES_IN", "1h") self.jwt_refresh_expires_in = os.getenv("JWT_REFRESH_EXPIRES_IN", "7d") def generate_tokens(self, user_id: int) -> dict: - """Generate JWT tokens""" access_token = jwt.encode( {"userId": user_id}, self.jwt_secret, @@ -48,24 +46,20 @@ class AuthService: return {"accessToken": access_token, "refreshToken": refresh_token} def verify_access_token(self, token: str) -> dict: - """Verify JWT access token""" return jwt.decode(token, self.jwt_secret, algorithms=["HS256"]) def verify_refresh_token(self, token: str) -> dict: - """Verify JWT refresh token""" return jwt.decode(token, self.jwt_refresh_secret, algorithms=["HS256"]) def hash_password(self, password: str) -> str: - """Hash password using bcrypt""" - # bcrypt has 72 byte limit, but it handles truncation automatically + password_bytes = password.encode('utf-8') - # Generate salt and hash password + salt = bcrypt.gensalt() hashed = bcrypt.hashpw(password_bytes, salt) return hashed.decode('utf-8') def verify_password(self, plain_password: str, hashed_password: str) -> bool: - """Verify password using bcrypt""" try: password_bytes = plain_password.encode('utf-8') hashed_bytes = hashed_password.encode('utf-8') @@ -74,7 +68,6 @@ class AuthService: return False def format_user_response(self, user: User) -> dict: - """Format user response""" return { "id": user.id, "name": user.full_name, @@ -88,34 +81,28 @@ class AuthService: } async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict: - """Register new user""" - # Check if email exists + existing_user = db.query(User).filter(User.email == email).first() if existing_user: raise ValueError("Email already registered") - # Hash password hashed_password = self.hash_password(password) - # Create user (default role_id = 3 for customer) user = User( full_name=name, email=email, password=hashed_password, phone=phone, - role_id=3 # Customer role + role_id=3 ) db.add(user) db.commit() db.refresh(user) - # Load role user.role = db.query(Role).filter(Role.id == user.role_id).first() - # Generate tokens tokens = self.generate_tokens(user.id) - # Save refresh token (expires in 7 days) expires_at = datetime.utcnow() + timedelta(days=7) refresh_token = RefreshToken( user_id=user.id, @@ -125,7 +112,6 @@ class AuthService: db.add(refresh_token) db.commit() - # Send welcome email (non-blocking) try: client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") email_html = welcome_email_template(user.full_name, user.email, client_url) @@ -145,62 +131,52 @@ class AuthService: } async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict: - """Login user with optional MFA verification""" - # Normalize email (lowercase and strip whitespace) + email = email.lower().strip() if email else "" if not email: raise ValueError("Invalid email or password") - # Find user with role and password + user = db.query(User).filter(User.email == email).first() if not user: logger.warning(f"Login attempt with non-existent email: {email}") raise ValueError("Invalid email or password") - # Check if user is active if not user.is_active: logger.warning(f"Login attempt for inactive user: {email}") raise ValueError("Account is disabled. Please contact support.") - # Load role user.role = db.query(Role).filter(Role.id == user.role_id).first() - # Check password if not self.verify_password(password, user.password): logger.warning(f"Login attempt with invalid password for user: {email}") raise ValueError("Invalid email or password") - # Check if MFA is enabled if user.mfa_enabled: if not mfa_token: - # Return special response indicating MFA is required + return { "requires_mfa": True, "user_id": user.id } - # Verify MFA token + from ..services.mfa_service import mfa_service - is_backup_code = len(mfa_token) == 8 # Backup codes are 8 characters + is_backup_code = len(mfa_token) == 8 if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code): raise ValueError("Invalid MFA token") - # Generate tokens tokens = self.generate_tokens(user.id) - # Calculate expiry based on remember_me expiry_days = 7 if remember_me else 1 expires_at = datetime.utcnow() + timedelta(days=expiry_days) - # Delete old/expired refresh tokens for this user to prevent duplicates - # This ensures we don't have multiple active tokens and prevents unique constraint violations try: db.query(RefreshToken).filter( RefreshToken.user_id == user.id ).delete() - db.flush() # Flush to ensure deletion happens before insert + db.flush() - # Save new refresh token refresh_token = RefreshToken( user_id=user.id, token=tokens["refreshToken"], @@ -211,7 +187,6 @@ class AuthService: except Exception as e: db.rollback() logger.error(f"Error saving refresh token for user {user.id}: {str(e)}", exc_info=True) - # If there's still a duplicate, try to delete and retry once try: db.query(RefreshToken).filter( RefreshToken.token == tokens["refreshToken"] @@ -236,14 +211,11 @@ class AuthService: } async def refresh_access_token(self, db: Session, refresh_token_str: str) -> dict: - """Refresh access token""" if not refresh_token_str: raise ValueError("Refresh token is required") - # Verify refresh token decoded = self.verify_refresh_token(refresh_token_str) - # Check if refresh token exists in database stored_token = db.query(RefreshToken).filter( RefreshToken.token == refresh_token_str, RefreshToken.user_id == decoded["userId"] @@ -252,13 +224,11 @@ class AuthService: if not stored_token: raise ValueError("Invalid refresh token") - # Check if token is expired if datetime.utcnow() > stored_token.expires_at: db.delete(stored_token) db.commit() raise ValueError("Refresh token expired") - # Generate new access token access_token = jwt.encode( {"userId": decoded["userId"]}, self.jwt_secret, @@ -268,19 +238,16 @@ class AuthService: return {"token": access_token} async def logout(self, db: Session, refresh_token_str: str) -> bool: - """Logout user""" if refresh_token_str: db.query(RefreshToken).filter(RefreshToken.token == refresh_token_str).delete() db.commit() return True async def get_profile(self, db: Session, user_id: int) -> dict: - """Get user profile""" user = db.query(User).filter(User.id == user_id).first() if not user: raise ValueError("User not found") - # Load role user.role = db.query(Role).filter(Role.id == user.role_id).first() return self.format_user_response(user) @@ -296,25 +263,22 @@ class AuthService: current_password: Optional[str] = None, currency: Optional[str] = None ) -> dict: - """Update user profile""" user = db.query(User).filter(User.id == user_id).first() if not user: raise ValueError("User not found") - # If password is being changed, verify current password if password: if not current_password: raise ValueError("Current password is required to change password") if not self.verify_password(current_password, user.password): raise ValueError("Current password is incorrect") - # Hash new password + user.password = self.hash_password(password) - # Update other fields if full_name is not None: user.full_name = full_name if email is not None: - # Check if email is already taken by another user + existing_user = db.query(User).filter( User.email == email, User.id != user_id @@ -325,7 +289,7 @@ class AuthService: if phone_number is not None: user.phone = phone_number if currency is not None: - # Validate currency code (ISO 4217, 3 characters) + if len(currency) == 3 and currency.isalpha(): user.currency = currency.upper() else: @@ -334,36 +298,29 @@ class AuthService: db.commit() db.refresh(user) - # Load role user.role = db.query(Role).filter(Role.id == user.role_id).first() return self.format_user_response(user) def generate_reset_token(self) -> tuple: - """Generate reset token""" reset_token = secrets.token_hex(32) hashed_token = hashlib.sha256(reset_token.encode()).hexdigest() return reset_token, hashed_token async def forgot_password(self, db: Session, email: str) -> dict: - """Forgot Password - Send reset link""" - # Find user by email + user = db.query(User).filter(User.email == email).first() - # Always return success to prevent email enumeration if not user: return { "success": True, "message": "If email exists, reset link has been sent" } - # Generate reset token reset_token, hashed_token = self.generate_reset_token() - # Delete old tokens db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete() - # Save token (expires in 1 hour) expires_at = datetime.utcnow() + timedelta(hours=1) reset_token_obj = PasswordResetToken( user_id=user.id, @@ -373,31 +330,17 @@ class AuthService: db.add(reset_token_obj) db.commit() - # Build reset URL client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") reset_url = f"{client_url}/reset-password/{reset_token}" - # Try to send email try: logger.info(f"Attempting to send password reset email to {user.email}") logger.info(f"Reset URL: {reset_url}") email_html = password_reset_email_template(reset_url) - # Create plain text version for better email deliverability - plain_text = f""" -Password Reset Request -You (or someone) has requested to reset your password for your Hotel Booking account. - -Click the link below to reset your password. This link will expire in 1 hour: - -{reset_url} - -If you did not request this, please ignore this email. - -Best regards, -Hotel Booking Team - """.strip() + plain_text = f +.strip() await send_email( to=user.email, @@ -408,7 +351,6 @@ Hotel Booking Team logger.info(f"Password reset email sent successfully to {user.email} with reset URL: {reset_url}") except Exception as e: logger.error(f"Failed to send password reset email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True) - # Still return success to prevent email enumeration, but log the error return { "success": True, @@ -416,14 +358,11 @@ Hotel Booking Team } async def reset_password(self, db: Session, token: str, password: str) -> dict: - """Reset Password - Update password with token""" if not token or not password: raise ValueError("Token and password are required") - # Hash the token to compare hashed_token = hashlib.sha256(token.encode()).hexdigest() - # Find valid token reset_token = db.query(PasswordResetToken).filter( PasswordResetToken.token == hashed_token, PasswordResetToken.expires_at > datetime.utcnow(), @@ -433,27 +372,21 @@ Hotel Booking Team if not reset_token: raise ValueError("Invalid or expired reset token") - # Find user user = db.query(User).filter(User.id == reset_token.user_id).first() if not user: raise ValueError("User not found") - # Check if new password matches old password if self.verify_password(password, user.password): raise ValueError("New password must be different from the old password") - # Hash new password hashed_password = self.hash_password(password) - # Update password user.password = hashed_password db.commit() - # Mark token as used reset_token.used = True db.commit() - # Send confirmation email (non-blocking) try: logger.info(f"Attempting to send password changed confirmation email to {user.email}") email_html = password_changed_email_template(user.email) @@ -471,6 +404,5 @@ Hotel Booking Team "message": "Password has been reset successfully" } - auth_service = AuthService() diff --git a/Backend/src/services/currency_service.py b/Backend/src/services/currency_service.py index f45d8536..beba7cfe 100644 --- a/Backend/src/services/currency_service.py +++ b/Backend/src/services/currency_service.py @@ -1,101 +1,42 @@ -""" -Currency conversion service -Handles currency conversion between different currencies -""" from typing import Dict from decimal import Decimal - -# Base currency is VND (Vietnamese Dong) -# Exchange rates relative to VND (1 VND = base) -# These are approximate rates - in production, fetch from an API like exchangerate-api.com -EXCHANGE_RATES: Dict[str, Decimal] = { - 'VND': Decimal('1.0'), # Base currency - 'USD': Decimal('0.000041'), # 1 VND = 0.000041 USD (approx 24,000 VND = 1 USD) - 'EUR': Decimal('0.000038'), # 1 VND = 0.000038 EUR (approx 26,000 VND = 1 EUR) - 'GBP': Decimal('0.000033'), # 1 VND = 0.000033 GBP (approx 30,000 VND = 1 GBP) - 'JPY': Decimal('0.0061'), # 1 VND = 0.0061 JPY (approx 164 VND = 1 JPY) - 'CNY': Decimal('0.00029'), # 1 VND = 0.00029 CNY (approx 3,400 VND = 1 CNY) - 'KRW': Decimal('0.055'), # 1 VND = 0.055 KRW (approx 18 VND = 1 KRW) - 'SGD': Decimal('0.000055'), # 1 VND = 0.000055 SGD (approx 18,000 VND = 1 SGD) - 'THB': Decimal('0.0015'), # 1 VND = 0.0015 THB (approx 667 VND = 1 THB) - 'AUD': Decimal('0.000062'), # 1 VND = 0.000062 AUD (approx 16,000 VND = 1 AUD) - 'CAD': Decimal('0.000056'), # 1 VND = 0.000056 CAD (approx 18,000 VND = 1 CAD) -} - -# Supported currencies list +EXCHANGE_RATES: Dict[str, Decimal] = {'VND': Decimal('1.0'), 'USD': Decimal('0.000041'), 'EUR': Decimal('0.000038'), 'GBP': Decimal('0.000033'), 'JPY': Decimal('0.0061'), 'CNY': Decimal('0.00029'), 'KRW': Decimal('0.055'), 'SGD': Decimal('0.000055'), 'THB': Decimal('0.0015'), 'AUD': Decimal('0.000062'), 'CAD': Decimal('0.000056')} SUPPORTED_CURRENCIES = list(EXCHANGE_RATES.keys()) - class CurrencyService: - """Service for currency conversion""" - + @staticmethod def get_supported_currencies() -> list: - """Get list of supported currency codes""" return SUPPORTED_CURRENCIES - + @staticmethod def convert_amount(amount: float, from_currency: str, to_currency: str) -> float: - """ - Convert amount from one currency to another - - Args: - amount: Amount to convert - from_currency: Source currency code (ISO 4217) - to_currency: Target currency code (ISO 4217) - - Returns: - Converted amount - """ from_currency = from_currency.upper() to_currency = to_currency.upper() - if from_currency == to_currency: return amount - if from_currency not in EXCHANGE_RATES: - raise ValueError(f"Unsupported source currency: {from_currency}") + raise ValueError(f'Unsupported source currency: {from_currency}') if to_currency not in EXCHANGE_RATES: - raise ValueError(f"Unsupported target currency: {to_currency}") - - # Convert to VND first, then to target currency + raise ValueError(f'Unsupported target currency: {to_currency}') amount_vnd = Decimal(str(amount)) / EXCHANGE_RATES[from_currency] converted_amount = amount_vnd * EXCHANGE_RATES[to_currency] - return float(converted_amount) - + @staticmethod def get_exchange_rate(from_currency: str, to_currency: str) -> float: - """ - Get exchange rate between two currencies - - Args: - from_currency: Source currency code - to_currency: Target currency code - - Returns: - Exchange rate (1 from_currency = X to_currency) - """ from_currency = from_currency.upper() to_currency = to_currency.upper() - if from_currency == to_currency: return 1.0 - if from_currency not in EXCHANGE_RATES: - raise ValueError(f"Unsupported source currency: {from_currency}") + raise ValueError(f'Unsupported source currency: {from_currency}') if to_currency not in EXCHANGE_RATES: - raise ValueError(f"Unsupported target currency: {to_currency}") - - # Rate = (1 / from_rate) * to_rate + raise ValueError(f'Unsupported target currency: {to_currency}') rate = EXCHANGE_RATES[to_currency] / EXCHANGE_RATES[from_currency] return float(rate) - + @staticmethod def format_currency_code(currency: str) -> str: - """Format currency code to uppercase""" return currency.upper() if currency else 'VND' - - -currency_service = CurrencyService() - +currency_service = CurrencyService() \ No newline at end of file diff --git a/Backend/src/services/invoice_service.py b/Backend/src/services/invoice_service.py index 73fe769e..735cf69e 100644 --- a/Backend/src/services/invoice_service.py +++ b/Backend/src/services/invoice_service.py @@ -1,6 +1,3 @@ -""" -Invoice service for managing invoices -""" from sqlalchemy.orm import Session from sqlalchemy import func, and_, or_ from typing import Optional, Dict, Any, List @@ -10,110 +7,46 @@ from ..models.booking import Booking from ..models.payment import Payment, PaymentStatus from ..models.user import User - -def generate_invoice_number(db: Session, is_proforma: bool = False) -> str: - """Generate a unique invoice number""" - # Format: INV-YYYYMMDD-XXXX or PRO-YYYYMMDD-XXXX for proforma - prefix = "PRO" if is_proforma else "INV" - today = datetime.utcnow().strftime("%Y%m%d") - - # Get the last invoice number for today - last_invoice = db.query(Invoice).filter( - Invoice.invoice_number.like(f"{prefix}-{today}-%") - ).order_by(Invoice.invoice_number.desc()).first() - +def generate_invoice_number(db: Session, is_proforma: bool=False) -> str: + prefix = 'PRO' if is_proforma else 'INV' + today = datetime.utcnow().strftime('%Y%m%d') + last_invoice = db.query(Invoice).filter(Invoice.invoice_number.like(f'{prefix}-{today}-%')).order_by(Invoice.invoice_number.desc()).first() if last_invoice: - # Extract the sequence number and increment try: - sequence = int(last_invoice.invoice_number.split("-")[-1]) + sequence = int(last_invoice.invoice_number.split('-')[-1]) sequence += 1 except (ValueError, IndexError): sequence = 1 else: sequence = 1 - - return f"{prefix}-{today}-{sequence:04d}" - + return f'{prefix}-{today}-{sequence:04d}' class InvoiceService: - """Service for managing invoices""" - + @staticmethod - def create_invoice_from_booking( - booking_id: int, - db: Session, - created_by_id: Optional[int] = None, - tax_rate: float = 0.0, - discount_amount: float = 0.0, - due_days: int = 30, - is_proforma: bool = False, - invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit) - **kwargs - ) -> Dict[str, Any]: - """ - Create an invoice from a booking - - Args: - booking_id: Booking ID - db: Database session - created_by_id: User ID who created the invoice - tax_rate: Tax rate percentage (default: 0.0) - discount_amount: Discount amount (default: 0.0) - due_days: Number of days until due date (default: 30) - **kwargs: Additional invoice fields (company info, notes, etc.) - - Returns: - Invoice dictionary - """ + def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, **kwargs) -> Dict[str, Any]: from sqlalchemy.orm import selectinload - - booking = db.query(Booking).options( - selectinload(Booking.service_usages).selectinload("service"), - selectinload(Booking.room).selectinload("room_type"), - selectinload(Booking.payments) - ).filter(Booking.id == booking_id).first() + booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload('service'), selectinload(Booking.room).selectinload('room_type'), selectinload(Booking.payments)).filter(Booking.id == booking_id).first() if not booking: - raise ValueError("Booking not found") - + raise ValueError('Booking not found') user = db.query(User).filter(User.id == booking.user_id).first() if not user: - raise ValueError("User not found") - - # Generate invoice number + raise ValueError('User not found') invoice_number = generate_invoice_number(db, is_proforma=is_proforma) - - # If invoice_amount is specified, we need to adjust item calculations - # This will be handled in the item creation section below - - # Calculate amounts - subtotal will be recalculated after adding items - # Initial subtotal is booking total (room + services) or invoice_amount if specified booking_total = float(booking.total_price) if invoice_amount is not None: subtotal = float(invoice_amount) - # For partial invoices, ensure discount is proportional - # If discount_amount seems too large (greater than subtotal), recalculate proportionally if invoice_amount < booking_total and discount_amount > 0: - # Check if discount seems disproportionate (greater than 50% of subtotal suggests it's the full discount) if discount_amount > subtotal * 0.5: - # Recalculate proportionally from booking's original discount proportion = float(invoice_amount) / booking_total original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount discount_amount = original_discount * proportion else: subtotal = booking_total - - # Calculate tax and total amounts tax_amount = (subtotal - discount_amount) * (tax_rate / 100) total_amount = subtotal + tax_amount - discount_amount - - # Calculate amount paid from completed payments - amount_paid = sum( - float(p.amount) for p in booking.payments - if p.payment_status == PaymentStatus.completed - ) + amount_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)) balance_due = total_amount - amount_paid - - # Determine status if balance_due <= 0: status = InvoiceStatus.paid paid_date = datetime.utcnow() @@ -123,252 +56,99 @@ class InvoiceService: else: status = InvoiceStatus.draft paid_date = None - - # Create invoice - invoice = Invoice( - invoice_number=invoice_number, - booking_id=booking_id, - user_id=booking.user_id, - issue_date=datetime.utcnow(), - due_date=datetime.utcnow() + timedelta(days=due_days), - paid_date=paid_date, - subtotal=subtotal, - tax_rate=tax_rate, - tax_amount=tax_amount, - discount_amount=discount_amount, - total_amount=total_amount, - amount_paid=amount_paid, - balance_due=balance_due, - status=status, - is_proforma=is_proforma, - company_name=kwargs.get("company_name"), - company_address=kwargs.get("company_address"), - company_phone=kwargs.get("company_phone"), - company_email=kwargs.get("company_email"), - company_tax_id=kwargs.get("company_tax_id"), - company_logo_url=kwargs.get("company_logo_url"), - customer_name=user.full_name or f"{user.email}", - customer_email=user.email, - customer_address=user.address, - customer_phone=user.phone, - customer_tax_id=kwargs.get("customer_tax_id"), - notes=kwargs.get("notes"), - terms_and_conditions=kwargs.get("terms_and_conditions"), - payment_instructions=kwargs.get("payment_instructions"), - created_by_id=created_by_id, - ) - + invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=kwargs.get('company_name'), company_address=kwargs.get('company_address'), company_phone=kwargs.get('company_phone'), company_email=kwargs.get('company_email'), company_tax_id=kwargs.get('company_tax_id'), company_logo_url=kwargs.get('company_logo_url'), customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id) db.add(invoice) - db.flush() # Flush to get invoice.id before creating invoice items - - # Create invoice items from booking - # Calculate room price (total_price includes services, so subtract services) - services_total = sum( - float(su.total_price) for su in booking.service_usages - ) + db.flush() + services_total = sum((float(su.total_price) for su in booking.service_usages)) booking_total = float(booking.total_price) room_price = booking_total - services_total - - # Calculate number of nights nights = (booking.check_out_date - booking.check_in_date).days if nights <= 0: nights = 1 - - # If invoice_amount is specified (for partial invoices), calculate proportion if invoice_amount is not None and invoice_amount < booking_total: - # Calculate proportion for partial invoice proportion = float(invoice_amount) / booking_total room_price = room_price * proportion services_total = services_total * proportion - item_description_suffix = f" (Partial: {proportion * 100:.0f}%)" + item_description_suffix = f' (Partial: {proportion * 100:.0f}%)' else: - item_description_suffix = "" - - # Room item - room_item = InvoiceItem( - invoice_id=invoice.id, - description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''}){item_description_suffix}", - quantity=nights, - unit_price=room_price / nights if nights > 0 else room_price, - tax_rate=tax_rate, - discount_amount=0.0, - line_total=room_price, - room_id=booking.room_id, - ) + item_description_suffix = '' + room_item = InvoiceItem(invoice_id=invoice.id, description=f'Room: {booking.room.room_number} - {(booking.room.room_type.name if booking.room.room_type else 'N/A')} ({nights} night{('s' if nights > 1 else '')}){item_description_suffix}', quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, line_total=room_price, room_id=booking.room_id) db.add(room_item) - - # Add service items if any for service_usage in booking.service_usages: service_item_price = float(service_usage.total_price) if invoice_amount is not None and invoice_amount < booking_total: - # Apply proportion to service items proportion = float(invoice_amount) / booking_total service_item_price = service_item_price * proportion - - service_item = InvoiceItem( - invoice_id=invoice.id, - description=f"Service: {service_usage.service.name}{item_description_suffix}", - quantity=float(service_usage.quantity), - unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, - tax_rate=tax_rate, - discount_amount=0.0, - line_total=service_item_price, - service_id=service_usage.service_id, - ) + service_item = InvoiceItem(invoice_id=invoice.id, description=f'Service: {service_usage.service.name}{item_description_suffix}', quantity=float(service_usage.quantity), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, line_total=service_item_price, service_id=service_usage.service_id) db.add(service_item) - - # Recalculate subtotal from items (room + services) subtotal = room_price + services_total - - # Recalculate tax and total amounts tax_amount = (subtotal - discount_amount) * (tax_rate / 100) total_amount = subtotal + tax_amount - discount_amount balance_due = total_amount - amount_paid - - # Update invoice with correct amounts invoice.subtotal = subtotal invoice.tax_amount = tax_amount invoice.total_amount = total_amount invoice.balance_due = balance_due - db.commit() db.refresh(invoice) - return InvoiceService.invoice_to_dict(invoice) - + @staticmethod - def update_invoice( - invoice_id: int, - db: Session, - updated_by_id: Optional[int] = None, - **kwargs - ) -> Dict[str, Any]: - """ - Update an invoice - - Args: - invoice_id: Invoice ID - db: Database session - updated_by_id: User ID who updated the invoice - **kwargs: Fields to update - - Returns: - Updated invoice dictionary - """ + def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, **kwargs) -> Dict[str, Any]: invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: - raise ValueError("Invoice not found") - - # Update allowed fields - allowed_fields = [ - "company_name", "company_address", "company_phone", "company_email", - "company_tax_id", "company_logo_url", "notes", "terms_and_conditions", - "payment_instructions", "status", "due_date", "tax_rate", "discount_amount" - ] - + raise ValueError('Invoice not found') + allowed_fields = ['company_name', 'company_address', 'company_phone', 'company_email', 'company_tax_id', 'company_logo_url', 'notes', 'terms_and_conditions', 'payment_instructions', 'status', 'due_date', 'tax_rate', 'discount_amount'] for field in allowed_fields: if field in kwargs: setattr(invoice, field, kwargs[field]) - - # Recalculate if tax_rate or discount_amount changed - if "tax_rate" in kwargs or "discount_amount" in kwargs: - tax_rate = kwargs.get("tax_rate", invoice.tax_rate) - discount_amount = kwargs.get("discount_amount", invoice.discount_amount) - + if 'tax_rate' in kwargs or 'discount_amount' in kwargs: + tax_rate = kwargs.get('tax_rate', invoice.tax_rate) + discount_amount = kwargs.get('discount_amount', invoice.discount_amount) invoice.tax_amount = (invoice.subtotal - discount_amount) * (float(tax_rate) / 100) invoice.total_amount = invoice.subtotal + invoice.tax_amount - discount_amount invoice.balance_due = invoice.total_amount - invoice.amount_paid - - # Update status based on balance if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid: invoice.status = InvoiceStatus.paid invoice.paid_date = datetime.utcnow() elif invoice.balance_due > 0 and invoice.status == InvoiceStatus.paid: invoice.status = InvoiceStatus.sent invoice.paid_date = None - invoice.updated_by_id = updated_by_id invoice.updated_at = datetime.utcnow() - db.commit() db.refresh(invoice) - return InvoiceService.invoice_to_dict(invoice) - + @staticmethod - def mark_invoice_as_paid( - invoice_id: int, - db: Session, - amount: Optional[float] = None, - updated_by_id: Optional[int] = None - ) -> Dict[str, Any]: - """ - Mark an invoice as paid - - Args: - invoice_id: Invoice ID - db: Database session - amount: Payment amount (if None, uses balance_due) - updated_by_id: User ID who marked as paid - - Returns: - Updated invoice dictionary - """ + def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None) -> Dict[str, Any]: invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: - raise ValueError("Invoice not found") - + raise ValueError('Invoice not found') payment_amount = amount if amount is not None else float(invoice.balance_due) invoice.amount_paid += payment_amount invoice.balance_due = invoice.total_amount - invoice.amount_paid - if invoice.balance_due <= 0: invoice.status = InvoiceStatus.paid invoice.paid_date = datetime.utcnow() else: invoice.status = InvoiceStatus.sent - invoice.updated_by_id = updated_by_id invoice.updated_at = datetime.utcnow() - db.commit() db.refresh(invoice) - return InvoiceService.invoice_to_dict(invoice) - + @staticmethod def get_invoice(invoice_id: int, db: Session) -> Optional[Dict[str, Any]]: - """Get invoice by ID""" invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() if not invoice: return None return InvoiceService.invoice_to_dict(invoice) - + @staticmethod - def get_invoices( - db: Session, - user_id: Optional[int] = None, - booking_id: Optional[int] = None, - status: Optional[str] = None, - page: int = 1, - limit: int = 10 - ) -> Dict[str, Any]: - """ - Get invoices with filters - - Args: - db: Database session - user_id: Filter by user ID - booking_id: Filter by booking ID - status: Filter by status - page: Page number - limit: Items per page - - Returns: - Dictionary with invoices and pagination info - """ + def get_invoices(db: Session, user_id: Optional[int]=None, booking_id: Optional[int]=None, status: Optional[str]=None, page: int=1, limit: int=10) -> Dict[str, Any]: query = db.query(Invoice) - if user_id: query = query.filter(Invoice.user_id == user_id) if booking_id: @@ -379,80 +159,17 @@ class InvoiceService: query = query.filter(Invoice.status == status_enum) except ValueError: pass - - # Get total count total = query.count() - - # Apply pagination offset = (page - 1) * limit invoices = query.order_by(Invoice.created_at.desc()).offset(offset).limit(limit).all() - - return { - "invoices": [InvoiceService.invoice_to_dict(inv) for inv in invoices], - "total": total, - "page": page, - "limit": limit, - "total_pages": (total + limit - 1) // limit - } - + return {'invoices': [InvoiceService.invoice_to_dict(inv) for inv in invoices], 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit} + @staticmethod def invoice_to_dict(invoice: Invoice) -> Dict[str, Any]: - """Convert invoice model to dictionary""" - # Extract promotion code from notes if present promotion_code = None - if invoice.notes and "Promotion Code:" in invoice.notes: + if invoice.notes and 'Promotion Code:' in invoice.notes: try: - promotion_code = invoice.notes.split("Promotion Code:")[1].split("\n")[0].strip() + promotion_code = invoice.notes.split('Promotion Code:')[1].split('\n')[0].strip() except: pass - - return { - "id": invoice.id, - "invoice_number": invoice.invoice_number, - "booking_id": invoice.booking_id, - "user_id": invoice.user_id, - "issue_date": invoice.issue_date.isoformat() if invoice.issue_date else None, - "due_date": invoice.due_date.isoformat() if invoice.due_date else None, - "paid_date": invoice.paid_date.isoformat() if invoice.paid_date else None, - "subtotal": float(invoice.subtotal) if invoice.subtotal else 0.0, - "tax_rate": float(invoice.tax_rate) if invoice.tax_rate else 0.0, - "tax_amount": float(invoice.tax_amount) if invoice.tax_amount else 0.0, - "discount_amount": float(invoice.discount_amount) if invoice.discount_amount else 0.0, - "total_amount": float(invoice.total_amount) if invoice.total_amount else 0.0, - "amount_paid": float(invoice.amount_paid) if invoice.amount_paid else 0.0, - "balance_due": float(invoice.balance_due) if invoice.balance_due else 0.0, - "status": invoice.status.value if invoice.status else None, - "company_name": invoice.company_name, - "company_address": invoice.company_address, - "company_phone": invoice.company_phone, - "company_email": invoice.company_email, - "company_tax_id": invoice.company_tax_id, - "company_logo_url": invoice.company_logo_url, - "customer_name": invoice.customer_name, - "customer_email": invoice.customer_email, - "customer_address": invoice.customer_address, - "customer_phone": invoice.customer_phone, - "customer_tax_id": invoice.customer_tax_id, - "notes": invoice.notes, - "terms_and_conditions": invoice.terms_and_conditions, - "payment_instructions": invoice.payment_instructions, - "is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False, - "promotion_code": promotion_code, - "items": [ - { - "id": item.id, - "description": item.description, - "quantity": float(item.quantity) if item.quantity else 0.0, - "unit_price": float(item.unit_price) if item.unit_price else 0.0, - "tax_rate": float(item.tax_rate) if item.tax_rate else 0.0, - "discount_amount": float(item.discount_amount) if item.discount_amount else 0.0, - "line_total": float(item.line_total) if item.line_total else 0.0, - "room_id": item.room_id, - "service_id": item.service_id, - } - for item in invoice.items - ], - "created_at": invoice.created_at.isoformat() if invoice.created_at else None, - "updated_at": invoice.updated_at.isoformat() if invoice.updated_at else None, - } - + return {'id': invoice.id, 'invoice_number': invoice.invoice_number, 'booking_id': invoice.booking_id, 'user_id': invoice.user_id, 'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None, 'due_date': invoice.due_date.isoformat() if invoice.due_date else None, 'paid_date': invoice.paid_date.isoformat() if invoice.paid_date else None, 'subtotal': float(invoice.subtotal) if invoice.subtotal else 0.0, 'tax_rate': float(invoice.tax_rate) if invoice.tax_rate else 0.0, 'tax_amount': float(invoice.tax_amount) if invoice.tax_amount else 0.0, 'discount_amount': float(invoice.discount_amount) if invoice.discount_amount else 0.0, 'total_amount': float(invoice.total_amount) if invoice.total_amount else 0.0, 'amount_paid': float(invoice.amount_paid) if invoice.amount_paid else 0.0, 'balance_due': float(invoice.balance_due) if invoice.balance_due else 0.0, 'status': invoice.status.value if invoice.status else None, 'company_name': invoice.company_name, 'company_address': invoice.company_address, 'company_phone': invoice.company_phone, 'company_email': invoice.company_email, 'company_tax_id': invoice.company_tax_id, 'company_logo_url': invoice.company_logo_url, 'customer_name': invoice.customer_name, 'customer_email': invoice.customer_email, 'customer_address': invoice.customer_address, 'customer_phone': invoice.customer_phone, 'customer_tax_id': invoice.customer_tax_id, 'notes': invoice.notes, 'terms_and_conditions': invoice.terms_and_conditions, 'payment_instructions': invoice.payment_instructions, 'is_proforma': invoice.is_proforma if hasattr(invoice, 'is_proforma') else False, 'promotion_code': promotion_code, 'items': [{'id': item.id, 'description': item.description, 'quantity': float(item.quantity) if item.quantity else 0.0, 'unit_price': float(item.unit_price) if item.unit_price else 0.0, 'tax_rate': float(item.tax_rate) if item.tax_rate else 0.0, 'discount_amount': float(item.discount_amount) if item.discount_amount else 0.0, 'line_total': float(item.line_total) if item.line_total else 0.0, 'room_id': item.room_id, 'service_id': item.service_id} for item in invoice.items], 'created_at': invoice.created_at.isoformat() if invoice.created_at else None, 'updated_at': invoice.updated_at.isoformat() if invoice.updated_at else None} \ No newline at end of file diff --git a/Backend/src/services/mfa_service.py b/Backend/src/services/mfa_service.py index 6b252e89..e3e00fc2 100644 --- a/Backend/src/services/mfa_service.py +++ b/Backend/src/services/mfa_service.py @@ -1,7 +1,3 @@ -""" -Multi-Factor Authentication (MFA) Service -Handles TOTP-based MFA functionality -""" import pyotp import qrcode import secrets @@ -13,287 +9,119 @@ from typing import List, Optional, Dict, Tuple from sqlalchemy.orm import Session from ..models.user import User import logging - logger = logging.getLogger(__name__) - class MFAService: - """Service for managing Multi-Factor Authentication""" @staticmethod def generate_secret() -> str: - """Generate a new TOTP secret""" return pyotp.random_base32() @staticmethod - def generate_qr_code(secret: str, email: str, app_name: str = "Hotel Booking") -> str: - """ - Generate QR code data URL for TOTP setup - - Args: - secret: TOTP secret key - email: User's email address - app_name: Application name for the authenticator app - - Returns: - Base64 encoded QR code image data URL - """ - # Create provisioning URI for authenticator apps - totp_uri = pyotp.totp.TOTP(secret).provisioning_uri( - name=email, - issuer_name=app_name - ) - - # Generate QR code - qr = qrcode.QRCode( - version=1, - error_correction=qrcode.constants.ERROR_CORRECT_L, - box_size=10, - border=4, - ) + def generate_qr_code(secret: str, email: str, app_name: str='Hotel Booking') -> str: + totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=app_name) + qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4) qr.add_data(totp_uri) qr.make(fit=True) - - # Create image - img = qr.make_image(fill_color="black", back_color="white") - - # Convert to base64 data URL + img = qr.make_image(fill_color='black', back_color='white') buffer = io.BytesIO() img.save(buffer, format='PNG') img_data = base64.b64encode(buffer.getvalue()).decode() - - return f"data:image/png;base64,{img_data}" + return f'data:image/png;base64,{img_data}' @staticmethod - def generate_backup_codes(count: int = 10) -> List[str]: - """ - Generate backup codes for MFA recovery - - Args: - count: Number of backup codes to generate (default: 10) - - Returns: - List of backup codes (8-character alphanumeric) - """ + def generate_backup_codes(count: int=10) -> List[str]: codes = [] for _ in range(count): - # Generate 8-character alphanumeric code code = secrets.token_urlsafe(6).upper()[:8] codes.append(code) return codes @staticmethod def hash_backup_code(code: str) -> str: - """ - Hash a backup code for storage (SHA-256) - - Args: - code: Plain backup code - - Returns: - Hashed backup code - """ return hashlib.sha256(code.encode()).hexdigest() @staticmethod def verify_backup_code(code: str, hashed_codes: List[str]) -> bool: - """ - Verify if a backup code matches any hashed code - - Args: - code: Plain backup code to verify - hashed_codes: List of hashed backup codes - - Returns: - True if code matches, False otherwise - """ code_hash = MFAService.hash_backup_code(code) return code_hash in hashed_codes @staticmethod def verify_totp(token: str, secret: str) -> bool: - """ - Verify a TOTP token - - Args: - token: 6-digit TOTP token from authenticator app - secret: User's TOTP secret - - Returns: - True if token is valid, False otherwise - """ try: totp = pyotp.TOTP(secret) - # Allow tokens from current and previous/next time window for clock skew return totp.verify(token, valid_window=1) except Exception as e: - logger.error(f"Error verifying TOTP: {str(e)}") + logger.error(f'Error verifying TOTP: {str(e)}') return False @staticmethod - def enable_mfa( - db: Session, - user_id: int, - secret: str, - verification_token: str - ) -> Tuple[bool, List[str]]: - """ - Enable MFA for a user after verifying the token - - Args: - db: Database session - user_id: User ID - secret: TOTP secret - verification_token: Token from authenticator app to verify - - Returns: - Tuple of (success, backup_codes) - """ + def enable_mfa(db: Session, user_id: int, secret: str, verification_token: str) -> Tuple[bool, List[str]]: user = db.query(User).filter(User.id == user_id).first() if not user: - raise ValueError("User not found") - - # Verify the token before enabling + raise ValueError('User not found') if not MFAService.verify_totp(verification_token, secret): - raise ValueError("Invalid verification token") - - # Generate backup codes + raise ValueError('Invalid verification token') backup_codes = MFAService.generate_backup_codes() hashed_codes = [MFAService.hash_backup_code(code) for code in backup_codes] - - # Update user user.mfa_enabled = True user.mfa_secret = secret user.mfa_backup_codes = json.dumps(hashed_codes) - db.commit() - - # Return plain backup codes (only shown once) - return True, backup_codes + return (True, backup_codes) @staticmethod def disable_mfa(db: Session, user_id: int) -> bool: - """ - Disable MFA for a user - - Args: - db: Database session - user_id: User ID - - Returns: - True if successful - """ user = db.query(User).filter(User.id == user_id).first() if not user: - raise ValueError("User not found") - + raise ValueError('User not found') user.mfa_enabled = False user.mfa_secret = None user.mfa_backup_codes = None - db.commit() return True @staticmethod - def verify_mfa( - db: Session, - user_id: int, - token: str, - is_backup_code: bool = False - ) -> bool: - """ - Verify MFA token or backup code for a user - - Args: - db: Database session - user_id: User ID - token: TOTP token or backup code - is_backup_code: Whether the token is a backup code - - Returns: - True if verification successful, False otherwise - """ + def verify_mfa(db: Session, user_id: int, token: str, is_backup_code: bool=False) -> bool: user = db.query(User).filter(User.id == user_id).first() if not user: - raise ValueError("User not found") - + raise ValueError('User not found') if not user.mfa_enabled or not user.mfa_secret: - raise ValueError("MFA is not enabled for this user") - + raise ValueError('MFA is not enabled for this user') if is_backup_code: - # Verify backup code if not user.mfa_backup_codes: return False - hashed_codes = json.loads(user.mfa_backup_codes) if not MFAService.verify_backup_code(token, hashed_codes): return False - - # Remove used backup code code_hash = MFAService.hash_backup_code(token) hashed_codes.remove(code_hash) user.mfa_backup_codes = json.dumps(hashed_codes) if hashed_codes else None db.commit() return True else: - # Verify TOTP token return MFAService.verify_totp(token, user.mfa_secret) @staticmethod def regenerate_backup_codes(db: Session, user_id: int) -> List[str]: - """ - Regenerate backup codes for a user - - Args: - db: Database session - user_id: User ID - - Returns: - List of new backup codes (plain, shown once) - """ user = db.query(User).filter(User.id == user_id).first() if not user: - raise ValueError("User not found") - + raise ValueError('User not found') if not user.mfa_enabled: - raise ValueError("MFA is not enabled for this user") - - # Generate new backup codes + raise ValueError('MFA is not enabled for this user') backup_codes = MFAService.generate_backup_codes() hashed_codes = [MFAService.hash_backup_code(code) for code in backup_codes] - user.mfa_backup_codes = json.dumps(hashed_codes) db.commit() - - # Return plain backup codes (only shown once) return backup_codes @staticmethod def get_mfa_status(db: Session, user_id: int) -> Dict: - """ - Get MFA status for a user - - Args: - db: Database session - user_id: User ID - - Returns: - Dictionary with MFA status information - """ user = db.query(User).filter(User.id == user_id).first() if not user: - raise ValueError("User not found") - + raise ValueError('User not found') backup_codes_count = 0 if user.mfa_backup_codes: backup_codes_count = len(json.loads(user.mfa_backup_codes)) - - return { - "mfa_enabled": user.mfa_enabled, - "backup_codes_count": backup_codes_count - } - - -# Create singleton instance -mfa_service = MFAService() - + return {'mfa_enabled': user.mfa_enabled, 'backup_codes_count': backup_codes_count} +mfa_service = MFAService() \ No newline at end of file diff --git a/Backend/src/services/paypal_service.py b/Backend/src/services/paypal_service.py index 24c62f79..f494c60f 100644 --- a/Backend/src/services/paypal_service.py +++ b/Backend/src/services/paypal_service.py @@ -1,6 +1,3 @@ -""" -PayPal payment service for processing PayPal payments -""" import logging from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest @@ -13,434 +10,208 @@ from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime import json - logger = logging.getLogger(__name__) - def get_paypal_client_id(db: Session) -> Optional[str]: - """Get PayPal client ID from database or environment variable""" try: - setting = db.query(SystemSettings).filter( - SystemSettings.key == "paypal_client_id" - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_client_id').first() if setting and setting.value: return setting.value except Exception: pass - # Fallback to environment variable return settings.PAYPAL_CLIENT_ID if settings.PAYPAL_CLIENT_ID else None - def get_paypal_client_secret(db: Session) -> Optional[str]: - """Get PayPal client secret from database or environment variable""" try: - setting = db.query(SystemSettings).filter( - SystemSettings.key == "paypal_client_secret" - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_client_secret').first() if setting and setting.value: return setting.value except Exception: pass - # Fallback to environment variable return settings.PAYPAL_CLIENT_SECRET if settings.PAYPAL_CLIENT_SECRET else None - def get_paypal_mode(db: Session) -> str: - """Get PayPal mode from database or environment variable""" try: - setting = db.query(SystemSettings).filter( - SystemSettings.key == "paypal_mode" - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_mode').first() if setting and setting.value: return setting.value except Exception: pass - # Fallback to environment variable - return settings.PAYPAL_MODE if settings.PAYPAL_MODE else "sandbox" + return settings.PAYPAL_MODE if settings.PAYPAL_MODE else 'sandbox' - -def get_paypal_client(db: Optional[Session] = None) -> PayPalHttpClient: - """ - Get PayPal HTTP client - - Args: - db: Optional database session to get credentials from database - - Returns: - PayPalHttpClient instance - """ +def get_paypal_client(db: Optional[Session]=None) -> PayPalHttpClient: client_id = None client_secret = None - mode = "sandbox" - + mode = 'sandbox' if db: client_id = get_paypal_client_id(db) client_secret = get_paypal_client_secret(db) mode = get_paypal_mode(db) - if not client_id: client_id = settings.PAYPAL_CLIENT_ID if not client_secret: client_secret = settings.PAYPAL_CLIENT_SECRET if not mode: - mode = settings.PAYPAL_MODE or "sandbox" - + mode = settings.PAYPAL_MODE or 'sandbox' if not client_id or not client_secret: - raise ValueError("PayPal credentials are not configured") - - # Create environment based on mode - if mode.lower() == "live": + raise ValueError('PayPal credentials are not configured') + if mode.lower() == 'live': environment = LiveEnvironment(client_id=client_id, client_secret=client_secret) else: environment = SandboxEnvironment(client_id=client_id, client_secret=client_secret) - return PayPalHttpClient(environment) - class PayPalService: - """Service for handling PayPal payments""" - + @staticmethod - def create_order( - amount: float, - currency: str = "USD", - metadata: Optional[Dict[str, Any]] = None, - db: Optional[Session] = None - ) -> Dict[str, Any]: - """ - Create a PayPal order - - Args: - amount: Payment amount in currency units - currency: Currency code (default: USD) - metadata: Additional metadata to attach to the order - db: Optional database session to get credentials from database - - Returns: - Order object with approval URL and order ID - """ + def create_order(amount: float, currency: str='USD', metadata: Optional[Dict[str, Any]]=None, db: Optional[Session]=None) -> Dict[str, Any]: client = get_paypal_client(db) - - # Validate amount if amount <= 0: - raise ValueError("Amount must be greater than 0") + raise ValueError('Amount must be greater than 0') if amount > 100000: raise ValueError(f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000") - - # Create order request request = OrdersCreateRequest() - request.prefer("return=representation") - - # Build order body - order_data = { - "intent": "CAPTURE", - "purchase_units": [ - { - "amount": { - "currency_code": currency.upper(), - "value": f"{amount:.2f}" - }, - "description": metadata.get("description", "Hotel Booking Payment") if metadata else "Hotel Booking Payment", - "custom_id": metadata.get("booking_id") if metadata else None, - } - ], - "application_context": { - "brand_name": "Hotel Booking", - "landing_page": "BILLING", - "user_action": "PAY_NOW", - "return_url": metadata.get("return_url") if metadata else None, - "cancel_url": metadata.get("cancel_url") if metadata else None, - } - } - - # Add metadata if provided + request.prefer('return=representation') + order_data = {'intent': 'CAPTURE', 'purchase_units': [{'amount': {'currency_code': currency.upper(), 'value': f'{amount:.2f}'}, 'description': metadata.get('description', 'Hotel Booking Payment') if metadata else 'Hotel Booking Payment', 'custom_id': metadata.get('booking_id') if metadata else None}], 'application_context': {'brand_name': 'Hotel Booking', 'landing_page': 'BILLING', 'user_action': 'PAY_NOW', 'return_url': metadata.get('return_url') if metadata else None, 'cancel_url': metadata.get('cancel_url') if metadata else None}} if metadata: - order_data["purchase_units"][0]["invoice_id"] = metadata.get("booking_number") - + order_data['purchase_units'][0]['invoice_id'] = metadata.get('booking_number') request.request_body(order_data) - try: response = client.execute(request) order = response.result - - # Extract approval URL approval_url = None for link in order.links: - if link.rel == "approve": + if link.rel == 'approve': approval_url = link.href break - - return { - "id": order.id, - "status": order.status, - "approval_url": approval_url, - "amount": amount, - "currency": currency.upper(), - } + return {'id': order.id, 'status': order.status, 'approval_url': approval_url, 'amount': amount, 'currency': currency.upper()} except Exception as e: error_msg = str(e) - # Try to extract more details from PayPal error if hasattr(e, 'message'): error_msg = e.message elif hasattr(e, 'details') and e.details: error_msg = json.dumps(e.details) - raise ValueError(f"PayPal error: {error_msg}") - + raise ValueError(f'PayPal error: {error_msg}') + @staticmethod - def get_order( - order_id: str, - db: Optional[Session] = None - ) -> Dict[str, Any]: - """ - Retrieve an order by ID - - Args: - order_id: PayPal order ID - db: Optional database session to get credentials from database - - Returns: - Order object - """ + def get_order(order_id: str, db: Optional[Session]=None) -> Dict[str, Any]: client = get_paypal_client(db) - request = OrdersGetRequest(order_id) - try: response = client.execute(request) order = response.result - - # Extract amount from purchase units amount = 0.0 - currency = "USD" + currency = 'USD' if order.purchase_units and len(order.purchase_units) > 0: amount_str = order.purchase_units[0].amount.value currency = order.purchase_units[0].amount.currency_code amount = float(amount_str) - - return { - "id": order.id, - "status": order.status, - "amount": amount, - "currency": currency, - "create_time": order.create_time, - "update_time": order.update_time, - } + return {'id': order.id, 'status': order.status, 'amount': amount, 'currency': currency, 'create_time': order.create_time, 'update_time': order.update_time} except Exception as e: error_msg = str(e) if hasattr(e, 'message'): error_msg = e.message - raise ValueError(f"PayPal error: {error_msg}") - + raise ValueError(f'PayPal error: {error_msg}') + @staticmethod - def capture_order( - order_id: str, - db: Optional[Session] = None - ) -> Dict[str, Any]: - """ - Capture a PayPal order - - Args: - order_id: PayPal order ID - db: Optional database session to get credentials from database - - Returns: - Capture details - """ + def capture_order(order_id: str, db: Optional[Session]=None) -> Dict[str, Any]: client = get_paypal_client(db) - request = OrdersCaptureRequest(order_id) - request.prefer("return=representation") - + request.prefer('return=representation') try: response = client.execute(request) order = response.result - - # Extract capture details capture_id = None amount = 0.0 - currency = "USD" + currency = 'USD' status = order.status - if order.purchase_units and len(order.purchase_units) > 0: payments = order.purchase_units[0].payments - if payments and payments.captures and len(payments.captures) > 0: + if payments and payments.captures and (len(payments.captures) > 0): capture = payments.captures[0] capture_id = capture.id amount_str = capture.amount.value currency = capture.amount.currency_code amount = float(amount_str) status = capture.status - - return { - "order_id": order.id, - "capture_id": capture_id, - "status": status, - "amount": amount, - "currency": currency, - } + return {'order_id': order.id, 'capture_id': capture_id, 'status': status, 'amount': amount, 'currency': currency} except Exception as e: error_msg = str(e) if hasattr(e, 'message'): error_msg = e.message - raise ValueError(f"PayPal error: {error_msg}") - + raise ValueError(f'PayPal error: {error_msg}') + @staticmethod - async def confirm_payment( - order_id: str, - db: Session, - booking_id: Optional[int] = None - ) -> Dict[str, Any]: - """ - Confirm a payment and update database records - - Args: - order_id: PayPal order ID - db: Database session - booking_id: Optional booking ID for metadata lookup - - Returns: - Payment record dictionary - """ + async def confirm_payment(order_id: str, db: Session, booking_id: Optional[int]=None) -> Dict[str, Any]: try: - # First capture the order capture_data = PayPalService.capture_order(order_id, db) - - # Get order details to extract booking_id from metadata if not provided if not booking_id: order_data = PayPalService.get_order(order_id, db) - # Try to get booking_id from custom_id in purchase_units - # Note: We'll need to store booking_id in the order metadata when creating - - # For now, we'll require booking_id to be passed if not booking_id: - raise ValueError("Booking ID is required") - + raise ValueError('Booking ID is required') booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise ValueError("Booking not found") - - # Check capture status - capture_status = capture_data.get("status") - if capture_status not in ["COMPLETED", "PENDING"]: - raise ValueError(f"Payment capture not in a valid state. Status: {capture_status}") - - # Find existing payment or create new one - # First try to find by transaction_id (for already captured payments) - payment = db.query(Payment).filter( - Payment.booking_id == booking_id, - Payment.transaction_id == order_id, - Payment.payment_method == PaymentMethod.paypal - ).first() - - # If not found, try to find pending PayPal payment for this booking + raise ValueError('Booking not found') + capture_status = capture_data.get('status') + if capture_status not in ['COMPLETED', 'PENDING']: + raise ValueError(f'Payment capture not in a valid state. Status: {capture_status}') + payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == order_id, Payment.payment_method == PaymentMethod.paypal).first() if not payment: - payment = db.query(Payment).filter( - Payment.booking_id == booking_id, - Payment.payment_method == PaymentMethod.paypal, - Payment.payment_status == PaymentStatus.pending - ).order_by(Payment.created_at.desc()).first() - - # If still not found, try to find pending deposit payment (for cash bookings with deposit) - # This allows updating the payment_method from the default to paypal + payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_method == PaymentMethod.paypal, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first() if not payment: - 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() - - amount = capture_data["amount"] - capture_id = capture_data.get("capture_id") - + 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() + amount = capture_data['amount'] + capture_id = capture_data.get('capture_id') if payment: - # Update existing payment - if capture_status == "COMPLETED": + if capture_status == 'COMPLETED': payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() - # If pending, keep as pending payment.amount = amount - payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal + payment.payment_method = PaymentMethod.paypal if capture_id: - payment.transaction_id = f"{order_id}|{capture_id}" + payment.transaction_id = f'{order_id}|{capture_id}' else: - # Create new payment record payment_type = PaymentType.full - if booking.requires_deposit and not booking.deposit_paid: + if booking.requires_deposit and (not booking.deposit_paid): payment_type = PaymentType.deposit - - payment_status_enum = PaymentStatus.completed if capture_status == "COMPLETED" else PaymentStatus.pending - payment_date = datetime.utcnow() if capture_status == "COMPLETED" else None - - transaction_id = f"{order_id}|{capture_id}" if capture_id else order_id - - payment = Payment( - booking_id=booking_id, - amount=amount, - payment_method=PaymentMethod.paypal, - payment_type=payment_type, - payment_status=payment_status_enum, - transaction_id=transaction_id, - payment_date=payment_date, - notes=f"PayPal payment - Order: {order_id}, Capture: {capture_id} (Status: {capture_status})", - ) + payment_status_enum = PaymentStatus.completed if capture_status == 'COMPLETED' else PaymentStatus.pending + payment_date = datetime.utcnow() if capture_status == 'COMPLETED' else None + transaction_id = f'{order_id}|{capture_id}' if capture_id else order_id + payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.paypal, payment_type=payment_type, payment_status=payment_status_enum, transaction_id=transaction_id, payment_date=payment_date, notes=f'PayPal payment - Order: {order_id}, Capture: {capture_id} (Status: {capture_status})') db.add(payment) - - # Commit payment first db.commit() db.refresh(payment) - - # Update booking status only if payment is completed if payment.payment_status == PaymentStatus.completed: db.refresh(booking) - - # Calculate total paid from all completed payments (now includes current payment) - # This needs to be calculated before the if/elif blocks - total_paid = sum( - float(p.amount) for p in booking.payments - if p.payment_status == PaymentStatus.completed - ) - - # Update invoice status based on payment + total_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)) from ..models.invoice import Invoice, InvoiceStatus - - # Find invoices for this booking and update their status invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all() for invoice in invoices: - # Update invoice amount_paid and balance_due invoice.amount_paid = total_paid invoice.balance_due = float(invoice.total_amount) - total_paid - - # Update invoice status if invoice.balance_due <= 0: invoice.status = InvoiceStatus.paid invoice.paid_date = datetime.utcnow() elif invoice.amount_paid > 0: invoice.status = InvoiceStatus.sent - booking_was_confirmed = False should_send_email = False if payment.payment_type == PaymentType.deposit: booking.deposit_paid = True - # Restore cancelled bookings or confirm pending bookings if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: booking.status = BookingStatus.confirmed booking_was_confirmed = True should_send_email = True elif booking.status == BookingStatus.confirmed: - # Booking already confirmed, but deposit was just paid should_send_email = True elif payment.payment_type == PaymentType.full: - # Confirm booking and restore cancelled bookings when payment succeeds if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price): if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: booking.status = BookingStatus.confirmed booking_was_confirmed = True should_send_email = True elif booking.status == BookingStatus.confirmed: - # Booking already confirmed, but full payment was just completed should_send_email = True - - # Send booking confirmation email if booking was just confirmed or payment completed if should_send_email: try: from ..utils.mailer import send_email @@ -450,143 +221,74 @@ class PayPalService: from sqlalchemy.orm import selectinload import os from ..config.settings import settings - - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - # Load booking with room details for email - booking_with_room = db.query(Booking).options( - selectinload(Booking.room).selectinload(Room.room_type) - ).filter(Booking.id == booking_id).first() - + booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking_id).first() room = booking_with_room.room if booking_with_room else None - room_type_name = room.room_type.name if room and room.room_type else "Room" - - # Calculate amount paid and remaining due + room_type_name = room.room_type.name if room and room.room_type else 'Room' amount_paid = total_paid payment_type_str = payment.payment_type.value if payment.payment_type else None - - email_html = booking_confirmation_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - 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=False, # Payment completed, no deposit message needed - deposit_amount=None, - amount_paid=amount_paid, - payment_type=payment_type_str, - client_url=client_url, - currency_symbol=currency_symbol - ) + email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', 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=False, deposit_amount=None, amount_paid=amount_paid, payment_type=payment_type_str, client_url=client_url, currency_symbol=currency_symbol) if booking.user: - await send_email( - to=booking.user.email, - subject=f"Booking Confirmed - {booking.booking_number}", - html=email_html - ) - logger.info(f"Booking confirmation email sent to {booking.user.email}") + await send_email(to=booking.user.email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html) + logger.info(f'Booking confirmation email sent to {booking.user.email}') except Exception as email_error: - logger.error(f"Failed to send booking confirmation email: {str(email_error)}") - - # Send invoice email if payment is completed and invoice is now paid + logger.error(f'Failed to send booking confirmation email: {str(email_error)}') from ..utils.mailer import send_email from ..services.invoice_service import InvoiceService from ..routes.booking_routes import _generate_invoice_email_html - - # Load user for email from ..models.user import User user = db.query(User).filter(User.id == booking.user_id).first() - for invoice in invoices: if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0: try: invoice_dict = InvoiceService.invoice_to_dict(invoice) invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma) - invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice" + invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice' if user: - await send_email( - to=user.email, - subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed", - html=invoice_html - ) - logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}") + await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html) + logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}') except Exception as email_error: - logger.error(f"Failed to send invoice email: {str(email_error)}") - - # Send invoice email if payment is completed and invoice is now paid + logger.error(f'Failed to send invoice email: {str(email_error)}') from ..utils.mailer import send_email from ..services.invoice_service import InvoiceService from ..models.invoice import InvoiceStatus from ..routes.booking_routes import _generate_invoice_email_html - - # Load user for email from ..models.user import User user = db.query(User).filter(User.id == booking.user_id).first() - for invoice in invoices: if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0: try: invoice_dict = InvoiceService.invoice_to_dict(invoice) invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma) - invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice" + invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice' if user: - await send_email( - to=user.email, - subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed", - html=invoice_html - ) - logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}") + await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html) + logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}') except Exception as email_error: - logger.error(f"Failed to send invoice email: {str(email_error)}") - + logger.error(f'Failed to send invoice email: {str(email_error)}') db.commit() db.refresh(booking) - - # Safely get enum values + def get_enum_value(enum_obj): if enum_obj is None: return None if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)): return enum_obj.value return enum_obj - - return { - "id": payment.id, - "booking_id": payment.booking_id, - "amount": float(payment.amount) if payment.amount else 0.0, - "payment_method": get_enum_value(payment.payment_method), - "payment_type": get_enum_value(payment.payment_type), - "payment_status": get_enum_value(payment.payment_status), - "transaction_id": payment.transaction_id, - "payment_date": payment.payment_date.isoformat() if payment.payment_date else None, - } - + return {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': get_enum_value(payment.payment_method), 'payment_type': get_enum_value(payment.payment_type), 'payment_status': get_enum_value(payment.payment_status), 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None} except ValueError as e: db.rollback() raise except Exception as e: import traceback error_details = traceback.format_exc() - error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}" - print(f"Error in confirm_payment: {error_msg}") - print(f"Traceback: {error_details}") + error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}' + print(f'Error in confirm_payment: {error_msg}') + print(f'Traceback: {error_details}') db.rollback() - raise ValueError(f"Error confirming payment: {error_msg}") - + raise ValueError(f'Error confirming payment: {error_msg}') \ No newline at end of file diff --git a/Backend/src/services/privacy_admin_service.py b/Backend/src/services/privacy_admin_service.py index 610c4b0b..006e985a 100644 --- a/Backend/src/services/privacy_admin_service.py +++ b/Backend/src/services/privacy_admin_service.py @@ -1,27 +1,16 @@ from sqlalchemy.orm import Session - from ..models.cookie_policy import CookiePolicy from ..models.cookie_integration_config import CookieIntegrationConfig from ..models.user import User -from ..schemas.admin_privacy import ( - CookieIntegrationSettings, - CookiePolicySettings, - PublicPrivacyConfig, -) - +from ..schemas.admin_privacy import CookieIntegrationSettings, CookiePolicySettings, PublicPrivacyConfig class PrivacyAdminService: - """ - Service layer for admin-controlled cookie policy and integrations. - """ - # Policy @staticmethod def get_or_create_policy(db: Session) -> CookiePolicy: policy = db.query(CookiePolicy).first() if policy: return policy - policy = CookiePolicy() db.add(policy) db.commit() @@ -31,16 +20,10 @@ class PrivacyAdminService: @staticmethod def get_policy_settings(db: Session) -> CookiePolicySettings: policy = PrivacyAdminService.get_or_create_policy(db) - return CookiePolicySettings( - analytics_enabled=policy.analytics_enabled, - marketing_enabled=policy.marketing_enabled, - preferences_enabled=policy.preferences_enabled, - ) + return CookiePolicySettings(analytics_enabled=policy.analytics_enabled, marketing_enabled=policy.marketing_enabled, preferences_enabled=policy.preferences_enabled) @staticmethod - def update_policy( - db: Session, settings: CookiePolicySettings, updated_by: User | None - ) -> CookiePolicy: + def update_policy(db: Session, settings: CookiePolicySettings, updated_by: User | None) -> CookiePolicy: policy = PrivacyAdminService.get_or_create_policy(db) policy.analytics_enabled = settings.analytics_enabled policy.marketing_enabled = settings.marketing_enabled @@ -52,7 +35,6 @@ class PrivacyAdminService: db.refresh(policy) return policy - # Integrations @staticmethod def get_or_create_integrations(db: Session) -> CookieIntegrationConfig: config = db.query(CookieIntegrationConfig).first() @@ -67,15 +49,10 @@ class PrivacyAdminService: @staticmethod def get_integration_settings(db: Session) -> CookieIntegrationSettings: cfg = PrivacyAdminService.get_or_create_integrations(db) - return CookieIntegrationSettings( - ga_measurement_id=cfg.ga_measurement_id, - fb_pixel_id=cfg.fb_pixel_id, - ) + return CookieIntegrationSettings(ga_measurement_id=cfg.ga_measurement_id, fb_pixel_id=cfg.fb_pixel_id) @staticmethod - def update_integrations( - db: Session, settings: CookieIntegrationSettings, updated_by: User | None - ) -> CookieIntegrationConfig: + def update_integrations(db: Session, settings: CookieIntegrationSettings, updated_by: User | None) -> CookieIntegrationConfig: cfg = PrivacyAdminService.get_or_create_integrations(db) cfg.ga_measurement_id = settings.ga_measurement_id cfg.fb_pixel_id = settings.fb_pixel_id @@ -91,8 +68,4 @@ class PrivacyAdminService: policy = PrivacyAdminService.get_policy_settings(db) integrations = PrivacyAdminService.get_integration_settings(db) return PublicPrivacyConfig(policy=policy, integrations=integrations) - - -privacy_admin_service = PrivacyAdminService() - - +privacy_admin_service = PrivacyAdminService() \ No newline at end of file diff --git a/Backend/src/services/room_service.py b/Backend/src/services/room_service.py index 806fb97e..dfb77a20 100644 --- a/Backend/src/services/room_service.py +++ b/Backend/src/services/room_service.py @@ -3,17 +3,13 @@ from sqlalchemy import func, and_, or_ from typing import Optional, List, Dict from datetime import datetime import os - from ..models.room import Room, RoomStatus from ..models.room_type import RoomType from ..models.review import Review, ReviewStatus - def normalize_images(images, base_url: str) -> List[str]: - """Normalize image paths to absolute URLs""" if not images: return [] - imgs = images if isinstance(images, str): try: @@ -21,10 +17,8 @@ def normalize_images(images, base_url: str) -> List[str]: imgs = json.loads(images) except: imgs = [s.strip() for s in images.split(',') if s.strip()] - if not isinstance(imgs, list): return [] - result = [] for img in imgs: if not img: @@ -32,372 +26,39 @@ def normalize_images(images, base_url: str) -> List[str]: if img.startswith('http://') or img.startswith('https://'): result.append(img) else: - path_part = img if img.startswith('/') else f"/{img}" - result.append(f"{base_url}{path_part}") - + path_part = img if img.startswith('/') else f'/{img}' + result.append(f'{base_url}{path_part}') return result - def get_base_url(request) -> str: - """Get base URL for image normalization""" - # Try to get from environment first - server_url = os.getenv("SERVER_URL") + server_url = os.getenv('SERVER_URL') if server_url: return server_url.rstrip('/') - - # Get from request host header host = request.headers.get('host', 'localhost:8000') - # Ensure we use the backend port if host doesn't have a port if ':' not in host: - host = f"{host}:8000" - - # Use http or https based on scheme + host = f'{host}:8000' scheme = request.url.scheme if hasattr(request.url, 'scheme') else 'http' - return f"{scheme}://{host}" + return f'{scheme}://{host}' - -async def get_rooms_with_ratings( - db: Session, - rooms: List[Room], - base_url: str -) -> List[Dict]: - """Get rooms with calculated ratings""" +async def get_rooms_with_ratings(db: Session, rooms: List[Room], base_url: str) -> List[Dict]: result = [] - for room in rooms: - # Get review stats - review_stats = db.query( - func.avg(Review.rating).label('average_rating'), - func.count(Review.id).label('total_reviews') - ).filter( - and_( - Review.room_id == room.id, - Review.status == ReviewStatus.approved - ) - ).first() - - room_dict = { - "id": room.id, - "room_type_id": room.room_type_id, - "room_number": room.room_number, - "floor": room.floor, - "status": room.status.value if isinstance(room.status, RoomStatus) else room.status, - "price": float(room.price) if room.price else 0.0, - "featured": room.featured, - "description": room.description, - "capacity": room.capacity, - "room_size": room.room_size, - "view": room.view, - "amenities": room.amenities, - "created_at": room.created_at.isoformat() if room.created_at else None, - "updated_at": room.updated_at.isoformat() if room.updated_at else None, - "average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, - "total_reviews": review_stats.total_reviews or 0 if review_stats else 0, - } - - # Normalize images + review_stats = db.query(func.avg(Review.rating).label('average_rating'), func.count(Review.id).label('total_reviews')).filter(and_(Review.room_id == room.id, Review.status == ReviewStatus.approved)).first() + room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price else 0.0, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities, 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None, 'average_rating': round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, 'total_reviews': review_stats.total_reviews or 0 if review_stats else 0} try: - room_dict["images"] = normalize_images(room.images, base_url) + room_dict['images'] = normalize_images(room.images, base_url) except: - room_dict["images"] = [] - - # Add room type info + room_dict['images'] = [] if room.room_type: - room_dict["room_type"] = { - "id": room.room_type.id, - "name": room.room_type.name, - "description": room.room_type.description, - "base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0, - "capacity": room.room_type.capacity, - "amenities": room.room_type.amenities, - "images": [] # RoomType doesn't have images column in DB - } - + room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities, 'images': []} result.append(room_dict) - return result - def get_predefined_amenities() -> List[str]: - """Get comprehensive list of predefined hotel room amenities""" - return [ - # Basic Amenities - "Free WiFi", - "WiFi", - "High-Speed Internet", - "WiFi in Room", - - # Entertainment - "Flat-Screen TV", - "TV", - "Cable TV", - "Satellite TV", - "Smart TV", - "Netflix", - "Streaming Services", - "DVD Player", - "Stereo System", - "Radio", - "iPod Dock", - - # Climate Control - "Air Conditioning", - "AC", - "Heating", - "Climate Control", - "Ceiling Fan", - "Air Purifier", - - # Bathroom Features - "Private Bathroom", - "Ensuite Bathroom", - "Bathtub", - "Jacuzzi Bathtub", - "Hot Tub", - "Shower", - "Rain Shower", - "Walk-in Shower", - "Bidet", - "Hair Dryer", - "Hairdryer", - "Bathrobes", - "Slippers", - "Toiletries", - "Premium Toiletries", - "Towels", - - # Food & Beverage - "Mini Bar", - "Minibar", - "Refrigerator", - "Fridge", - "Microwave", - "Coffee Maker", - "Electric Kettle", - "Tea Making Facilities", - "Coffee Machine", - "Nespresso Machine", - "Kitchenette", - "Dining Table", - "Room Service", - "Breakfast Included", - "Breakfast", - "Complimentary Water", - "Bottled Water", - - # Furniture & Space - "Desk", - "Writing Desk", - "Office Desk", - "Work Desk", - "Sofa", - "Sitting Area", - "Lounge Area", - "Dining Area", - "Separate Living Area", - "Wardrobe", - "Closet", - "Dresser", - "Mirror", - "Full-Length Mirror", - "Seating Area", - - # Bed & Sleep - "King Size Bed", - "Queen Size Bed", - "Double Bed", - "Twin Beds", - "Single Bed", - "Extra Bedding", - "Pillow Menu", - "Premium Bedding", - "Blackout Curtains", - "Soundproofing", - - # Safety & Security - "Safe", - "In-Room Safe", - "Safety Deposit Box", - "Smoke Detector", - "Fire Extinguisher", - "Security System", - "Key Card Access", - "Door Lock", - "Pepper Spray", - - # Technology - "USB Charging Ports", - "USB Ports", - "USB Outlets", - "Power Outlets", - "Charging Station", - "Laptop Safe", - "HDMI Port", - "Phone", - "Desk Phone", - "Wake-Up Service", - "Alarm Clock", - "Digital Clock", - - # View & Outdoor - "Balcony", - "Private Balcony", - "Terrace", - "Patio", - "City View", - "Ocean View", - "Sea View", - "Mountain View", - "Garden View", - "Pool View", - "Park View", - "Window", - "Large Windows", - "Floor-to-Ceiling Windows", - - # Services - "24-Hour Front Desk", - "24 Hour Front Desk", - "24/7 Front Desk", - "Concierge Service", - "Butler Service", - "Housekeeping", - "Daily Housekeeping", - "Turndown Service", - "Laundry Service", - "Dry Cleaning", - "Ironing Service", - "Luggage Storage", - "Bell Service", - "Valet Parking", - "Parking", - "Free Parking", - "Airport Shuttle", - "Shuttle Service", - "Car Rental", - "Taxi Service", - - # Fitness & Wellness - "Gym Access", - "Fitness Center", - "Fitness Room", - "Spa Access", - "Spa", - "Sauna", - "Steam Room", - "Hot Tub", - "Massage Service", - "Beauty Services", - - # Recreation - "Swimming Pool", - "Pool", - "Indoor Pool", - "Outdoor Pool", - "Infinity Pool", - "Pool Access", - "Golf Course", - "Tennis Court", - "Beach Access", - "Water Sports", - - # Business & Work - "Business Center", - "Meeting Room", - "Conference Room", - "Fax Service", - "Photocopying", - "Printing Service", - "Secretarial Services", - - # Accessibility - "Wheelchair Accessible", - "Accessible Room", - "Elevator Access", - "Ramp Access", - "Accessible Bathroom", - "Lowered Sink", - "Grab Bars", - "Hearing Accessible", - "Visual Alarm", - - # Family & Pets - "Family Room", - "Kids Welcome", - "Baby Crib", - "Extra Bed", - "Crib", - "Childcare Services", - "Pets Allowed", - "Pet Friendly", - - # Additional Features - "Smoking Room", - "Non-Smoking Room", - "No Smoking", - "Interconnecting Rooms", - "Adjoining Rooms", - "Suite", - "Separate Bedroom", - "Kitchen", - "Full Kitchen", - "Dishwasher", - "Oven", - "Stove", - "Washing Machine", - "Dryer", - "Iron", - "Ironing Board", - "Clothes Rack", - "Umbrella", - "Shoe Shine Service", - - # Luxury Features - "Fireplace", - "Jacuzzi", - "Steam Shower", - "Spa Bath", - "Bidet Toilet", - "Smart Home System", - "Lighting Control", - "Curtain Control", - "Automated Systems", - "Personalized Service", - "VIP Treatment", - "Butler", - "Private Entrance", - "Private Elevator", - "Panic Button", - - # Entertainment & Media - "Blu-ray Player", - "Gaming Console", - "PlayStation", - "Xbox", - "Sound System", - "Surround Sound", - "Music System", - - # Special Features - "Library", - "Reading Room", - "Study Room", - "Private Pool", - "Private Garden", - "Yard", - "Courtyard", - "Outdoor Furniture", - "BBQ Facilities", - "Picnic Area", - ] - + return ['Free WiFi', 'WiFi', 'High-Speed Internet', 'WiFi in Room', 'Flat-Screen TV', 'TV', 'Cable TV', 'Satellite TV', 'Smart TV', 'Netflix', 'Streaming Services', 'DVD Player', 'Stereo System', 'Radio', 'iPod Dock', 'Air Conditioning', 'AC', 'Heating', 'Climate Control', 'Ceiling Fan', 'Air Purifier', 'Private Bathroom', 'Ensuite Bathroom', 'Bathtub', 'Jacuzzi Bathtub', 'Hot Tub', 'Shower', 'Rain Shower', 'Walk-in Shower', 'Bidet', 'Hair Dryer', 'Hairdryer', 'Bathrobes', 'Slippers', 'Toiletries', 'Premium Toiletries', 'Towels', 'Mini Bar', 'Minibar', 'Refrigerator', 'Fridge', 'Microwave', 'Coffee Maker', 'Electric Kettle', 'Tea Making Facilities', 'Coffee Machine', 'Nespresso Machine', 'Kitchenette', 'Dining Table', 'Room Service', 'Breakfast Included', 'Breakfast', 'Complimentary Water', 'Bottled Water', 'Desk', 'Writing Desk', 'Office Desk', 'Work Desk', 'Sofa', 'Sitting Area', 'Lounge Area', 'Dining Area', 'Separate Living Area', 'Wardrobe', 'Closet', 'Dresser', 'Mirror', 'Full-Length Mirror', 'Seating Area', 'King Size Bed', 'Queen Size Bed', 'Double Bed', 'Twin Beds', 'Single Bed', 'Extra Bedding', 'Pillow Menu', 'Premium Bedding', 'Blackout Curtains', 'Soundproofing', 'Safe', 'In-Room Safe', 'Safety Deposit Box', 'Smoke Detector', 'Fire Extinguisher', 'Security System', 'Key Card Access', 'Door Lock', 'Pepper Spray', 'USB Charging Ports', 'USB Ports', 'USB Outlets', 'Power Outlets', 'Charging Station', 'Laptop Safe', 'HDMI Port', 'Phone', 'Desk Phone', 'Wake-Up Service', 'Alarm Clock', 'Digital Clock', 'Balcony', 'Private Balcony', 'Terrace', 'Patio', 'City View', 'Ocean View', 'Sea View', 'Mountain View', 'Garden View', 'Pool View', 'Park View', 'Window', 'Large Windows', 'Floor-to-Ceiling Windows', '24-Hour Front Desk', '24 Hour Front Desk', '24/7 Front Desk', 'Concierge Service', 'Butler Service', 'Housekeeping', 'Daily Housekeeping', 'Turndown Service', 'Laundry Service', 'Dry Cleaning', 'Ironing Service', 'Luggage Storage', 'Bell Service', 'Valet Parking', 'Parking', 'Free Parking', 'Airport Shuttle', 'Shuttle Service', 'Car Rental', 'Taxi Service', 'Gym Access', 'Fitness Center', 'Fitness Room', 'Spa Access', 'Spa', 'Sauna', 'Steam Room', 'Hot Tub', 'Massage Service', 'Beauty Services', 'Swimming Pool', 'Pool', 'Indoor Pool', 'Outdoor Pool', 'Infinity Pool', 'Pool Access', 'Golf Course', 'Tennis Court', 'Beach Access', 'Water Sports', 'Business Center', 'Meeting Room', 'Conference Room', 'Fax Service', 'Photocopying', 'Printing Service', 'Secretarial Services', 'Wheelchair Accessible', 'Accessible Room', 'Elevator Access', 'Ramp Access', 'Accessible Bathroom', 'Lowered Sink', 'Grab Bars', 'Hearing Accessible', 'Visual Alarm', 'Family Room', 'Kids Welcome', 'Baby Crib', 'Extra Bed', 'Crib', 'Childcare Services', 'Pets Allowed', 'Pet Friendly', 'Smoking Room', 'Non-Smoking Room', 'No Smoking', 'Interconnecting Rooms', 'Adjoining Rooms', 'Suite', 'Separate Bedroom', 'Kitchen', 'Full Kitchen', 'Dishwasher', 'Oven', 'Stove', 'Washing Machine', 'Dryer', 'Iron', 'Ironing Board', 'Clothes Rack', 'Umbrella', 'Shoe Shine Service', 'Fireplace', 'Jacuzzi', 'Steam Shower', 'Spa Bath', 'Bidet Toilet', 'Smart Home System', 'Lighting Control', 'Curtain Control', 'Automated Systems', 'Personalized Service', 'VIP Treatment', 'Butler', 'Private Entrance', 'Private Elevator', 'Panic Button', 'Blu-ray Player', 'Gaming Console', 'PlayStation', 'Xbox', 'Sound System', 'Surround Sound', 'Music System', 'Library', 'Reading Room', 'Study Room', 'Private Pool', 'Private Garden', 'Yard', 'Courtyard', 'Outdoor Furniture', 'BBQ Facilities', 'Picnic Area'] async def get_amenities_list(db: Session) -> List[str]: - """Get all unique amenities from room types and rooms, plus predefined amenities""" - # Start with predefined comprehensive list all_amenities = set(get_predefined_amenities()) - - # Get from room types room_types = db.query(RoomType.amenities).all() for rt in room_types: if rt.amenities: @@ -413,8 +74,6 @@ async def get_amenities_list(db: Session) -> List[str]: all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()]) except: all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()]) - - # Get from rooms rooms = db.query(Room.amenities).all() for r in rooms: if r.amenities: @@ -430,7 +89,4 @@ async def get_amenities_list(db: Session) -> List[str]: all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()]) except: all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()]) - - # Return unique, sorted values - return sorted(list(all_amenities)) - + return sorted(list(all_amenities)) \ No newline at end of file diff --git a/Backend/src/services/stripe_service.py b/Backend/src/services/stripe_service.py index fd94ea8e..a24cde4a 100644 --- a/Backend/src/services/stripe_service.py +++ b/Backend/src/services/stripe_service.py @@ -1,6 +1,3 @@ -""" -Stripe payment service for processing card payments -""" import logging import stripe from typing import Optional, Dict, Any @@ -10,333 +7,152 @@ from ..models.booking import Booking, BookingStatus from ..models.system_settings import SystemSettings from sqlalchemy.orm import Session from datetime import datetime - logger = logging.getLogger(__name__) - def get_stripe_secret_key(db: Session) -> Optional[str]: - """Get Stripe secret key from database or environment variable""" try: - setting = db.query(SystemSettings).filter( - SystemSettings.key == "stripe_secret_key" - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_secret_key').first() if setting and setting.value: return setting.value except Exception: pass - # Fallback to environment variable return settings.STRIPE_SECRET_KEY if settings.STRIPE_SECRET_KEY else None - def get_stripe_publishable_key(db: Session) -> Optional[str]: - """Get Stripe publishable key from database or environment variable""" try: - setting = db.query(SystemSettings).filter( - SystemSettings.key == "stripe_publishable_key" - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_publishable_key').first() if setting and setting.value: return setting.value except Exception: pass - # Fallback to environment variable return settings.STRIPE_PUBLISHABLE_KEY if settings.STRIPE_PUBLISHABLE_KEY else None - def get_stripe_webhook_secret(db: Session) -> Optional[str]: - """Get Stripe webhook secret from database or environment variable""" try: - setting = db.query(SystemSettings).filter( - SystemSettings.key == "stripe_webhook_secret" - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_webhook_secret').first() if setting and setting.value: return setting.value except Exception: pass - # Fallback to environment variable return settings.STRIPE_WEBHOOK_SECRET if settings.STRIPE_WEBHOOK_SECRET else None - class StripeService: - """Service for handling Stripe payments""" - + @staticmethod - def create_payment_intent( - amount: float, - currency: str = "usd", - metadata: Optional[Dict[str, Any]] = None, - customer_id: Optional[str] = None, - db: Optional[Session] = None - ) -> Dict[str, Any]: - """ - Create a Stripe Payment Intent - - Args: - amount: Payment amount in smallest currency unit (cents for USD) - currency: Currency code (default: usd) - metadata: Additional metadata to attach to the payment intent - customer_id: Optional Stripe customer ID - db: Optional database session to get keys from database - - Returns: - Payment intent object - """ - # Get secret key from database or environment + def create_payment_intent(amount: float, currency: str='usd', metadata: Optional[Dict[str, Any]]=None, customer_id: Optional[str]=None, db: Optional[Session]=None) -> Dict[str, Any]: secret_key = None if db: secret_key = get_stripe_secret_key(db) if not secret_key: secret_key = settings.STRIPE_SECRET_KEY - if not secret_key: - raise ValueError("Stripe secret key is not configured") - - # Set the API key for this request + raise ValueError('Stripe secret key is not configured') stripe.api_key = secret_key - - # Validate amount is reasonable (Stripe max is $999,999.99) if amount <= 0: - raise ValueError("Amount must be greater than 0") + raise ValueError('Amount must be greater than 0') if amount > 999999.99: raise ValueError(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99") - - # Convert amount to cents (smallest currency unit) - # Amount should be in dollars, so multiply by 100 to get cents amount_in_cents = int(round(amount * 100)) - - # Double-check the cents amount doesn't exceed Stripe's limit - if amount_in_cents > 99999999: # $999,999.99 in cents + if amount_in_cents > 99999999: raise ValueError(f"Amount ${amount:,.2f} (${amount_in_cents} cents) exceeds Stripe's maximum") - - intent_params = { - "amount": amount_in_cents, - "currency": currency, - "automatic_payment_methods": { - "enabled": True, - }, - "metadata": metadata or {}, - } - + intent_params = {'amount': amount_in_cents, 'currency': currency, 'automatic_payment_methods': {'enabled': True}, 'metadata': metadata or {}} if customer_id: - intent_params["customer"] = customer_id - + intent_params['customer'] = customer_id try: intent = stripe.PaymentIntent.create(**intent_params) - return { - "client_secret": intent.client_secret, - "id": intent.id, - "status": intent.status, - "amount": intent.amount, - "currency": intent.currency, - } + return {'client_secret': intent.client_secret, 'id': intent.id, 'status': intent.status, 'amount': intent.amount, 'currency': intent.currency} except stripe.StripeError as e: - raise ValueError(f"Stripe error: {str(e)}") - + raise ValueError(f'Stripe error: {str(e)}') + @staticmethod - def retrieve_payment_intent( - payment_intent_id: str, - db: Optional[Session] = None - ) -> Dict[str, Any]: - """ - Retrieve a payment intent by ID - - Args: - payment_intent_id: Stripe payment intent ID - db: Optional database session to get keys from database - - Returns: - Payment intent object - """ - # Get secret key from database or environment + def retrieve_payment_intent(payment_intent_id: str, db: Optional[Session]=None) -> Dict[str, Any]: secret_key = None if db: secret_key = get_stripe_secret_key(db) if not secret_key: secret_key = settings.STRIPE_SECRET_KEY - if not secret_key: - raise ValueError("Stripe secret key is not configured") - - # Set the API key for this request + raise ValueError('Stripe secret key is not configured') stripe.api_key = secret_key - try: intent = stripe.PaymentIntent.retrieve(payment_intent_id) - # Safely access charges - they may not exist on all payment intents charges = [] if hasattr(intent, 'charges') and intent.charges: charges_data = getattr(intent.charges, 'data', []) - charges = [ - { - "id": charge.id, - "paid": charge.paid, - "status": charge.status, - } - for charge in charges_data - ] - - return { - "id": intent.id, - "status": intent.status, - "amount": intent.amount / 100, # Convert from cents - "currency": intent.currency, - "metadata": intent.metadata, - "charges": charges, - } + charges = [{'id': charge.id, 'paid': charge.paid, 'status': charge.status} for charge in charges_data] + return {'id': intent.id, 'status': intent.status, 'amount': intent.amount / 100, 'currency': intent.currency, 'metadata': intent.metadata, 'charges': charges} except stripe.StripeError as e: - raise ValueError(f"Stripe error: {str(e)}") - + raise ValueError(f'Stripe error: {str(e)}') + @staticmethod - async def confirm_payment( - payment_intent_id: str, - db: Session, - booking_id: Optional[int] = None - ) -> Dict[str, Any]: - """ - Confirm a payment and update database records - - Args: - payment_intent_id: Stripe payment intent ID - db: Database session - booking_id: Optional booking ID for metadata lookup - - Returns: - Payment record dictionary - """ + async def confirm_payment(payment_intent_id: str, db: Session, booking_id: Optional[int]=None) -> Dict[str, Any]: try: intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db) - - # Find or get booking_id from metadata - if not booking_id and intent_data.get("metadata"): - booking_id = intent_data["metadata"].get("booking_id") + if not booking_id and intent_data.get('metadata'): + booking_id = intent_data['metadata'].get('booking_id') if booking_id: booking_id = int(booking_id) - if not booking_id: - raise ValueError("Booking ID is required") - + raise ValueError('Booking ID is required') booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: - raise ValueError("Booking not found") - - # Check payment intent status - payment_status = intent_data.get("status") - print(f"Payment intent status: {payment_status}") - - # Accept succeeded or processing status (processing means payment is being processed) - if payment_status not in ["succeeded", "processing"]: - raise ValueError(f"Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.") - - # Find existing payment or create new one - payment = db.query(Payment).filter( - Payment.booking_id == booking_id, - Payment.transaction_id == payment_intent_id, - Payment.payment_method == PaymentMethod.stripe - ).first() - - # If not found, try to find pending deposit payment (for cash bookings with deposit) - # This allows updating the payment_method from the default to stripe + raise ValueError('Booking not found') + payment_status = intent_data.get('status') + print(f'Payment intent status: {payment_status}') + if payment_status not in ['succeeded', 'processing']: + raise ValueError(f'Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.') + payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == payment_intent_id, Payment.payment_method == PaymentMethod.stripe).first() if not payment: - 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() - - amount = intent_data["amount"] - + 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() + amount = intent_data['amount'] if payment: - # Update existing payment - # Only mark as completed if payment intent succeeded - if payment_status == "succeeded": + if payment_status == 'succeeded': payment.payment_status = PaymentStatus.completed payment.payment_date = datetime.utcnow() - # If processing, keep as pending (will be updated by webhook) payment.amount = amount - payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe + payment.payment_method = PaymentMethod.stripe else: - # Create new payment record payment_type = PaymentType.full - if booking.requires_deposit and not booking.deposit_paid: + if booking.requires_deposit and (not booking.deposit_paid): payment_type = PaymentType.deposit - - # Only mark as completed if payment intent succeeded - payment_status_enum = PaymentStatus.completed if payment_status == "succeeded" else PaymentStatus.pending - payment_date = datetime.utcnow() if payment_status == "succeeded" else None - - payment = Payment( - booking_id=booking_id, - amount=amount, - payment_method=PaymentMethod.stripe, - payment_type=payment_type, - payment_status=payment_status_enum, - transaction_id=payment_intent_id, - payment_date=payment_date, - notes=f"Stripe payment - Intent: {payment_intent_id} (Status: {payment_status})", - ) + payment_status_enum = PaymentStatus.completed if payment_status == 'succeeded' else PaymentStatus.pending + payment_date = datetime.utcnow() if payment_status == 'succeeded' else None + payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.stripe, payment_type=payment_type, payment_status=payment_status_enum, transaction_id=payment_intent_id, payment_date=payment_date, notes=f'Stripe payment - Intent: {payment_intent_id} (Status: {payment_status})') db.add(payment) - - # Commit payment first to ensure it's saved db.commit() db.refresh(payment) - - # Update booking status only if payment is completed if payment.payment_status == PaymentStatus.completed: - # Refresh booking to get updated payments relationship db.refresh(booking) - - # Calculate total paid from all completed payments (now includes current payment) - # This needs to be calculated before the if/elif blocks - total_paid = sum( - float(p.amount) for p in booking.payments - if p.payment_status == PaymentStatus.completed - ) - - # Update invoice status based on payment + total_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed)) from ..models.invoice import Invoice, InvoiceStatus from ..services.invoice_service import InvoiceService - - # Find invoices for this booking and update their status invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all() for invoice in invoices: - # Update invoice amount_paid and balance_due invoice.amount_paid = total_paid invoice.balance_due = float(invoice.total_amount) - total_paid - - # Update invoice status if invoice.balance_due <= 0: invoice.status = InvoiceStatus.paid invoice.paid_date = datetime.utcnow() elif invoice.amount_paid > 0: invoice.status = InvoiceStatus.sent - booking_was_confirmed = False should_send_email = False if payment.payment_type == PaymentType.deposit: - # Mark deposit as paid and confirm booking booking.deposit_paid = True - # Restore cancelled bookings or confirm pending bookings if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: booking.status = BookingStatus.confirmed booking_was_confirmed = True should_send_email = True elif booking.status == BookingStatus.confirmed: - # Booking already confirmed, but deposit was just paid should_send_email = True elif payment.payment_type == PaymentType.full: - # Confirm booking if: - # 1. Total paid (all payments) covers the booking price, OR - # 2. This single payment covers the entire booking amount - # Also restore cancelled bookings when payment succeeds if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price): if booking.status in [BookingStatus.pending, BookingStatus.cancelled]: booking.status = BookingStatus.confirmed booking_was_confirmed = True should_send_email = True elif booking.status == BookingStatus.confirmed: - # Booking already confirmed, but full payment was just completed should_send_email = True - - # Send booking confirmation email if booking was just confirmed or payment completed if should_send_email: try: from ..utils.mailer import send_email @@ -346,208 +162,109 @@ class StripeService: from sqlalchemy.orm import selectinload import os from ..config.settings import settings - - # Get client URL from settings - 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")) - - # Get platform currency for email - currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first() - currency = currency_setting.value if currency_setting and currency_setting.value else "USD" - - # Get currency symbol - currency_symbols = { - "USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥", "CNY": "¥", - "KRW": "₩", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$", - "VND": "₫", "INR": "₹", "CHF": "CHF", "NZD": "NZ$" - } + client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first() + client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first() + currency = currency_setting.value if currency_setting and currency_setting.value else 'USD' + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '₩', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '₫', 'INR': '₹', 'CHF': 'CHF', 'NZD': 'NZ$'} currency_symbol = currency_symbols.get(currency, currency) - - # Load booking with room details for email - booking_with_room = db.query(Booking).options( - selectinload(Booking.room).selectinload(Room.room_type) - ).filter(Booking.id == booking_id).first() - + booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking_id).first() room = booking_with_room.room if booking_with_room else None - room_type_name = room.room_type.name if room and room.room_type else "Room" - - # Calculate amount paid and remaining due + room_type_name = room.room_type.name if room and room.room_type else 'Room' amount_paid = total_paid payment_type_str = payment.payment_type.value if payment.payment_type else None - - email_html = booking_confirmation_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - 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=False, # Payment completed, no deposit message needed - deposit_amount=None, - amount_paid=amount_paid, - payment_type=payment_type_str, - client_url=client_url, - currency_symbol=currency_symbol - ) + email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', 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=False, deposit_amount=None, amount_paid=amount_paid, payment_type=payment_type_str, client_url=client_url, currency_symbol=currency_symbol) if booking.user: - await send_email( - to=booking.user.email, - subject=f"Booking Confirmed - {booking.booking_number}", - html=email_html - ) - logger.info(f"Booking confirmation email sent to {booking.user.email}") + await send_email(to=booking.user.email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html) + logger.info(f'Booking confirmation email sent to {booking.user.email}') except Exception as email_error: - logger.error(f"Failed to send booking confirmation email: {str(email_error)}") - - # Send invoice email if payment is completed and invoice is now paid + logger.error(f'Failed to send booking confirmation email: {str(email_error)}') from ..utils.mailer import send_email from ..services.invoice_service import InvoiceService from ..routes.booking_routes import _generate_invoice_email_html - - # Load user for email from ..models.user import User user = db.query(User).filter(User.id == booking.user_id).first() - for invoice in invoices: if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0: try: invoice_dict = InvoiceService.invoice_to_dict(invoice) invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma) - invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice" + invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice' if user: - await send_email( - to=user.email, - subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed", - html=invoice_html - ) - logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}") + await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html) + logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}') except Exception as email_error: - logger.error(f"Failed to send invoice email: {str(email_error)}") - - # Commit booking and invoice status updates + logger.error(f'Failed to send invoice email: {str(email_error)}') db.commit() db.refresh(booking) - - # Safely get enum values + def get_enum_value(enum_obj): - """Safely extract value from enum or return as-is""" if enum_obj is None: return None if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)): return enum_obj.value return enum_obj - try: - return { - "id": payment.id, - "booking_id": payment.booking_id, - "amount": float(payment.amount) if payment.amount else 0.0, - "payment_method": get_enum_value(payment.payment_method), - "payment_type": get_enum_value(payment.payment_type), - "payment_status": get_enum_value(payment.payment_status), - "transaction_id": payment.transaction_id, - "payment_date": payment.payment_date.isoformat() if payment.payment_date else None, - } + return {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': get_enum_value(payment.payment_method), 'payment_type': get_enum_value(payment.payment_type), 'payment_status': get_enum_value(payment.payment_status), 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None} except AttributeError as ae: - print(f"AttributeError accessing payment fields: {ae}") - print(f"Payment object: {payment}") - print(f"Payment payment_method: {payment.payment_method if hasattr(payment, 'payment_method') else 'missing'}") - print(f"Payment payment_type: {payment.payment_type if hasattr(payment, 'payment_type') else 'missing'}") - print(f"Payment payment_status: {payment.payment_status if hasattr(payment, 'payment_status') else 'missing'}") + print(f'AttributeError accessing payment fields: {ae}') + print(f'Payment object: {payment}') + print(f'Payment payment_method: {(payment.payment_method if hasattr(payment, 'payment_method') else 'missing')}') + print(f'Payment payment_type: {(payment.payment_type if hasattr(payment, 'payment_type') else 'missing')}') + print(f'Payment payment_status: {(payment.payment_status if hasattr(payment, 'payment_status') else 'missing')}') raise - except ValueError as e: - # Re-raise ValueError as-is (these are expected errors) db.rollback() raise except Exception as e: import traceback error_details = traceback.format_exc() - error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}" - print(f"Error in confirm_payment: {error_msg}") - print(f"Traceback: {error_details}") + error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}' + print(f'Error in confirm_payment: {error_msg}') + print(f'Traceback: {error_details}') db.rollback() - raise ValueError(f"Error confirming payment: {error_msg}") - + raise ValueError(f'Error confirming payment: {error_msg}') + @staticmethod - async def handle_webhook( - payload: bytes, - signature: str, - db: Session - ) -> Dict[str, Any]: - """ - Handle Stripe webhook events - - Args: - payload: Raw webhook payload - signature: Stripe signature header - db: Database session - - Returns: - Webhook event data - """ + async def handle_webhook(payload: bytes, signature: str, db: Session) -> Dict[str, Any]: webhook_secret = get_stripe_webhook_secret(db) if not webhook_secret: webhook_secret = settings.STRIPE_WEBHOOK_SECRET - if not webhook_secret: - raise ValueError("Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.") - + raise ValueError('Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.') try: - event = stripe.Webhook.construct_event( - payload, signature, webhook_secret - ) + event = stripe.Webhook.construct_event(payload, signature, webhook_secret) except ValueError as e: - raise ValueError(f"Invalid payload: {str(e)}") + raise ValueError(f'Invalid payload: {str(e)}') except stripe.SignatureVerificationError as e: - raise ValueError(f"Invalid signature: {str(e)}") - - # Handle the event - if event["type"] == "payment_intent.succeeded": - payment_intent = event["data"]["object"] - payment_intent_id = payment_intent["id"] - metadata = payment_intent.get("metadata", {}) - booking_id = metadata.get("booking_id") - + raise ValueError(f'Invalid signature: {str(e)}') + if event['type'] == 'payment_intent.succeeded': + payment_intent = event['data']['object'] + payment_intent_id = payment_intent['id'] + metadata = payment_intent.get('metadata', {}) + booking_id = metadata.get('booking_id') if booking_id: - try: - await StripeService.confirm_payment( - payment_intent_id=payment_intent_id, - db=db, - booking_id=int(booking_id) - ) - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.error(f"Error processing webhook for booking {booking_id}: {str(e)}") - - elif event["type"] == "payment_intent.payment_failed": - payment_intent = event["data"]["object"] - payment_intent_id = payment_intent["id"] - metadata = payment_intent.get("metadata", {}) - booking_id = metadata.get("booking_id") - + try: + await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=int(booking_id)) + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.error(f'Error processing webhook for booking {booking_id}: {str(e)}') + elif event['type'] == 'payment_intent.payment_failed': + payment_intent = event['data']['object'] + payment_intent_id = payment_intent['id'] + metadata = payment_intent.get('metadata', {}) + booking_id = metadata.get('booking_id') if booking_id: - # Update payment status to failed - payment = db.query(Payment).filter( - Payment.transaction_id == payment_intent_id, - Payment.booking_id == int(booking_id) - ).first() - + payment = db.query(Payment).filter(Payment.transaction_id == payment_intent_id, Payment.booking_id == int(booking_id)).first() if payment: payment.payment_status = PaymentStatus.failed db.commit() - - # Auto-cancel booking when payment fails booking = db.query(Booking).filter(Booking.id == int(booking_id)).first() if booking and booking.status != BookingStatus.cancelled: booking.status = BookingStatus.cancelled db.commit() db.refresh(booking) - - # Send cancellation email (non-blocking) try: if booking.user: from ..utils.mailer import send_email @@ -555,30 +272,12 @@ class StripeService: from ..models.system_settings import SystemSettings from ..config.settings import settings import os - - # Get client URL from settings - 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")) - - email_html = booking_status_changed_email_template( - booking_number=booking.booking_number, - guest_name=booking.user.full_name if booking.user else "Guest", - status="cancelled", - client_url=client_url - ) - await send_email( - to=booking.user.email, - subject=f"Booking Cancelled - {booking.booking_number}", - html=email_html - ) + 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') + email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url) + await send_email(to=booking.user.email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html) except Exception as e: import logging logger = logging.getLogger(__name__) - logger.error(f"Failed to send cancellation email: {e}") - - return { - "status": "success", - "event_type": event["type"], - "event_id": event["id"], - } - + logger.error(f'Failed to send cancellation email: {e}') + return {'status': 'success', 'event_type': event['type'], 'event_id': event['id']} \ No newline at end of file diff --git a/Backend/src/utils/email_templates.py b/Backend/src/utils/email_templates.py index 4c9c97fb..18bda82f 100644 --- a/Backend/src/utils/email_templates.py +++ b/Backend/src/utils/email_templates.py @@ -1,14 +1,9 @@ -""" -Email templates for various notifications -""" from datetime import datetime from typing import Optional from ..config.database import SessionLocal from ..models.system_settings import SystemSettings - def _get_company_settings(): - """Get company settings from database""" try: db = SessionLocal() try: @@ -44,9 +39,7 @@ def _get_company_settings(): "company_address": None, } - def get_base_template(content: str, title: str = "Hotel Booking", client_url: str = "http://localhost:5173") -> str: - """Luxury HTML email template with premium company branding""" company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" company_tagline = company_settings.get("company_tagline") or "Excellence Redefined" @@ -55,12 +48,12 @@ def get_base_template(content: str, title: str = "Hotel Booking", client_url: st company_email = company_settings.get("company_email") company_address = company_settings.get("company_address") - # Build logo HTML if logo exists + logo_html = "" if company_logo_url: - # Convert relative URL to absolute if needed + if not company_logo_url.startswith('http'): - # Try to construct full URL + server_url = client_url.replace('://localhost:5173', '').replace('://localhost:3000', '') if not server_url.startswith('http'): server_url = f"http://{server_url}" if ':' not in server_url.split('//')[-1] else server_url @@ -68,187 +61,45 @@ def get_base_template(content: str, title: str = "Hotel Booking", client_url: st else: full_logo_url = company_logo_url - logo_html = f''' -
- {company_name} - {f'

{company_tagline}

' if company_tagline else ''} -
- ''' + logo_html = f else: - logo_html = f''' -
-

{company_name}

- {f'

{company_tagline}

' if company_tagline else ''} -
- ''' + logo_html = f - # Build footer contact info + footer_contact = "" if company_phone or company_email or company_address: - footer_contact = ''' -
- - ''' + footer_contact = if company_phone: - footer_contact += f''' - - - - ''' + footer_contact += f if company_email: - footer_contact += f''' - - - - ''' + footer_contact += f if company_address: - # Replace newlines with
for address - formatted_address = company_address.replace('\n', '
') - footer_contact += f''' - - - - ''' - footer_contact += ''' -
-

📞 {company_phone}

-
-

✉️ {company_email}

-
-

📍 {formatted_address}

-
-
- ''' - - return f""" - - - - - - {title} - - - - - - - - - - - - - -
- {logo_html} -
- - - - -
- {content} -
-
-

This is an automated email. Please do not reply.

-

© {datetime.now().year} {company_name}. All rights reserved.

- {footer_contact} -
- - - """ + formatted_address = company_address.replace('\n', '
') + footer_contact += f + footer_contact += + + return f def welcome_email_template(name: str, email: str, client_url: str) -> str: - """Welcome email template for new registrations""" company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" - content = f""" -
-
-
- -
-
-
-

Welcome, {name}!

-

We are delighted to welcome you to {company_name}.

-

Your account has been successfully created with email: {email}

- -
-

🎁 What you can do:

- -
- -
- - Access Your Account - -
- """ + content = f return get_base_template(content, f"Welcome to {company_name}", client_url) - def password_reset_email_template(reset_url: str) -> str: - """Password reset email template""" - content = f""" -
-
-
- 🔐 -
-
-
-

Password Reset Request

-

A password reset request has been received for your account.

-

Click the button below to reset your password. This link will expire in 1 hour.

- -
- - Reset Password - -
- -
-

⚠️ If you did not request this password reset, please ignore this email and your password will remain unchanged.

-
- """ + content = f company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" return get_base_template(content, f"Password Reset - {company_name}", reset_url.split('/reset-password')[0] if '/reset-password' in reset_url else "http://localhost:5173") - def password_changed_email_template(email: str) -> str: - """Password changed confirmation email template""" - content = f""" -
-
-
- -
-
-
-

Password Changed Successfully

-

The password for account {email} has been changed successfully.

- -
-

🔒 If you did not make this change, please contact our support team immediately to secure your account.

-
- """ + content = f company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" return get_base_template(content, f"Password Changed - {company_name}", "http://localhost:5173") - def booking_confirmation_email_template( booking_number: str, guest_name: str, @@ -268,125 +119,27 @@ def booking_confirmation_email_template( client_url: str = "http://localhost:5173", currency_symbol: str = "$" ) -> str: - """Booking confirmation email template""" deposit_info = "" if requires_deposit and deposit_amount and amount_paid is None: - deposit_info = f""" -
-

⚠️ Deposit Required

-

Please pay a deposit of {currency_symbol}{deposit_amount:.2f} to confirm your booking.

-

Your booking will be confirmed once the deposit is received.

-
- """ + deposit_info = f - # Payment breakdown section (shown when payment is completed) + payment_breakdown = "" if amount_paid is not None: remaining_due = total_price - amount_paid payment_type_label = "Deposit Payment" if payment_type == "deposit" else "Full Payment" - payment_breakdown = f""" -
-

Payment Information

- - - - - - - - - - - - - - """ + payment_breakdown = f if remaining_due > 0: - payment_breakdown += f""" - - - - - """ + payment_breakdown += f else: - payment_breakdown += f""" - - - - - """ - payment_breakdown += """ -
Payment Type:{payment_type_label}
Amount Paid:{currency_symbol}{amount_paid:.2f}
Total Booking Price:{currency_symbol}{total_price:.2f}
Remaining Due:{currency_symbol}{remaining_due:.2f}
Status:✅ Fully Paid
-
- """ + payment_breakdown += f + payment_breakdown += - content = f""" -
-
-
- 🏨 -
-
-
-

Booking Confirmation

-

Dear {guest_name},

-

Thank you for choosing us! We have received your reservation request and are delighted to welcome you.

- -
-

Booking Details

- - - - - - - - - - - - - - - - - - - - - - {f''' - - - - - - - - - ''' if original_price and discount_amount and discount_amount > 0 else ''} - - - - -
Booking Number:{booking_number}
Room:{room_type} - Room {room_number}
Check-in:{check_in}
Check-out:{check_out}
Guests:{num_guests} guest{'s' if num_guests > 1 else ''}
Subtotal:{currency_symbol}{original_price:.2f}
Promotion Discount{f' ({promotion_code})' if promotion_code else ''}:-{currency_symbol}{discount_amount:.2f}
Total Price:{currency_symbol}{total_price:.2f}
-
- - {payment_breakdown} - - {deposit_info} - -
- - View Booking Details - -
- """ + content = f company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" return get_base_template(content, f"Booking Confirmation - {company_name}", client_url) - def payment_confirmation_email_template( booking_number: str, guest_name: str, @@ -398,139 +151,44 @@ def payment_confirmation_email_template( client_url: str = "http://localhost:5173", currency_symbol: str = "$" ) -> str: - """Payment confirmation email template""" transaction_info = "" if transaction_id: - transaction_info = f""" - - Transaction ID: - {transaction_id} - - """ + transaction_info = f payment_type_info = "" if payment_type: payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment" - payment_type_info = f""" - - Payment Type: - {payment_type_label} - - """ + payment_type_info = f total_price_info = "" remaining_due_info = "" if total_price is not None: - total_price_info = f""" - - Total Booking Price: - {currency_symbol}{total_price:.2f} - - """ + total_price_info = f if payment_type == "deposit" and total_price > amount: remaining_due = total_price - amount - remaining_due_info = f""" - - Remaining Due: - {currency_symbol}{remaining_due:.2f} - - """ + remaining_due_info = f - content = f""" -
-
-
- 💳 -
-
-
-

Payment Received

-

Dear {guest_name},

-

We have successfully received your payment for booking {booking_number}.

- -
-

Payment Details

- - - - - - - - - - {transaction_info} - {payment_type_info} - {total_price_info} - - - - - {remaining_due_info} -
Booking Number:{booking_number}
Payment Method:{payment_method}
Amount Paid:{currency_symbol}{amount:.2f}
-
- -

✨ Your booking is now confirmed. We look forward to hosting you!

- -
- - View Booking - -
- """ + content = f company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" return get_base_template(content, f"Payment Confirmation - {company_name}", client_url) - def booking_status_changed_email_template( booking_number: str, guest_name: str, status: str, client_url: str = "http://localhost:5173" ) -> str: - """Booking status change email template""" status_colors = { - "confirmed": ("#10B981", "Confirmed", "✅", "#ecfdf5", "#d1fae5"), - "cancelled": ("#EF4444", "Cancelled", "❌", "#fef2f2", "#fee2e2"), - "checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"), - "checked_out": ("#8B5CF6", "Checked Out", "🏃", "#f5f3ff", "#e9d5ff"), + "confirmed": (" + "cancelled": (" + "checked_in": (" + "checked_out": (" } - color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("#6B7280", status.title(), "📋", "#f3f4f6", "#e5e7eb")) + color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), (" - content = f""" -
-
-
- {icon} -
-
-
-

Booking Status Updated

-

Dear {guest_name},

-

Your booking status has been updated.

- -
-

Status Information

- - - - - - - - - -
Booking Number:{booking_number}
New Status:{status_text}
-
- -
- - View Booking - -
- """ + content = f company_settings = _get_company_settings() company_name = company_settings.get("company_name") or "Hotel Booking" return get_base_template(content, f"Booking {status_text} - {company_name}", client_url) diff --git a/Backend/src/utils/mailer.py b/Backend/src/utils/mailer.py index dfdcb627..1fa74302 100644 --- a/Backend/src/utils/mailer.py +++ b/Backend/src/utils/mailer.py @@ -6,148 +6,86 @@ import logging from ..config.settings import settings from ..config.database import SessionLocal from ..models.system_settings import SystemSettings - logger = logging.getLogger(__name__) - def _get_smtp_settings_from_db(): - """ - Get SMTP settings from system_settings table. - Returns dict with settings or None if not available. - """ try: db = SessionLocal() try: smtp_settings = {} - setting_keys = [ - "smtp_host", - "smtp_port", - "smtp_user", - "smtp_password", - "smtp_from_email", - "smtp_from_name", - "smtp_use_tls", - ] - + setting_keys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_use_tls'] for key in setting_keys: - setting = db.query(SystemSettings).filter( - SystemSettings.key == key - ).first() + setting = db.query(SystemSettings).filter(SystemSettings.key == key).first() if setting and setting.value: smtp_settings[key] = setting.value - - # Only return if we have at least host, user, and password - if smtp_settings.get("smtp_host") and smtp_settings.get("smtp_user") and smtp_settings.get("smtp_password"): + if smtp_settings.get('smtp_host') and smtp_settings.get('smtp_user') and smtp_settings.get('smtp_password'): return smtp_settings return None finally: db.close() except Exception as e: - logger.debug(f"Could not fetch SMTP settings from database: {str(e)}") + logger.debug(f'Could not fetch SMTP settings from database: {str(e)}') return None - -async def send_email(to: str, subject: str, html: str = None, text: str = None): - """ - Send email using SMTP - Uses system_settings first, then falls back to config/settings.py and environment variables - """ +async def send_email(to: str, subject: str, html: str=None, text: str=None): try: - # Try to get SMTP settings from database first db_smtp_settings = _get_smtp_settings_from_db() - if db_smtp_settings: - # Use settings from database - mail_host = db_smtp_settings.get("smtp_host") - mail_user = db_smtp_settings.get("smtp_user") - mail_pass = db_smtp_settings.get("smtp_password") - mail_port = int(db_smtp_settings.get("smtp_port", "587")) - mail_use_tls = db_smtp_settings.get("smtp_use_tls", "true").lower() == "true" - from_address = db_smtp_settings.get("smtp_from_email") - from_name = db_smtp_settings.get("smtp_from_name", "Hotel Booking") - logger.info("Using SMTP settings from system_settings database") + mail_host = db_smtp_settings.get('smtp_host') + mail_user = db_smtp_settings.get('smtp_user') + mail_pass = db_smtp_settings.get('smtp_password') + mail_port = int(db_smtp_settings.get('smtp_port', '587')) + mail_use_tls = db_smtp_settings.get('smtp_use_tls', 'true').lower() == 'true' + from_address = db_smtp_settings.get('smtp_from_email') + from_name = db_smtp_settings.get('smtp_from_name', 'Hotel Booking') + logger.info('Using SMTP settings from system_settings database') else: - # Fallback to config/settings.py and env vars - mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST") - mail_user = settings.SMTP_USER or os.getenv("MAIL_USER") - mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS") - mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587")) - mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true" - mail_use_tls = mail_secure # For backward compatibility - client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173") - - # Get from address - prefer settings, then env, then generate from client_url - from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM") + mail_host = settings.SMTP_HOST or os.getenv('MAIL_HOST') + mail_user = settings.SMTP_USER or os.getenv('MAIL_USER') + mail_pass = settings.SMTP_PASSWORD or os.getenv('MAIL_PASS') + mail_port = settings.SMTP_PORT or int(os.getenv('MAIL_PORT', '587')) + mail_secure = os.getenv('MAIL_SECURE', 'false').lower() == 'true' + mail_use_tls = mail_secure + client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173') + from_address = settings.SMTP_FROM_EMAIL or os.getenv('MAIL_FROM') if not from_address: - # Generate from client_url if not set domain = client_url.replace('https://', '').replace('http://', '').split('/')[0] - from_address = f"no-reply@{domain}" - - # Use from name if available - from_name = settings.SMTP_FROM_NAME or "Hotel Booking" - logger.info("Using SMTP settings from config/environment variables") - - from_header = f"{from_name} <{from_address}>" - + from_address = f'no-reply@{domain}' + from_name = settings.SMTP_FROM_NAME or 'Hotel Booking' + logger.info('Using SMTP settings from config/environment variables') + from_header = f'{from_name} <{from_address}>' if not (mail_host and mail_user and mail_pass): - error_msg = "SMTP mailer not configured. Set SMTP_HOST, SMTP_USER and SMTP_PASSWORD in .env file." + error_msg = 'SMTP mailer not configured. Set SMTP_HOST, SMTP_USER and SMTP_PASSWORD in .env file.' logger.error(error_msg) raise ValueError(error_msg) - - # Create message - message = MIMEMultipart("alternative") - message["From"] = from_header - message["To"] = to - message["Subject"] = subject - + message = MIMEMultipart('alternative') + message['From'] = from_header + message['To'] = to + message['Subject'] = subject if text: - message.attach(MIMEText(text, "plain")) + message.attach(MIMEText(text, 'plain')) if html: - message.attach(MIMEText(html, "html")) - - # If no content provided, add a default text - if not text and not html: - message.attach(MIMEText("", "plain")) - - # Determine TLS/SSL settings - # For port 465: use SSL/TLS (use_tls=True, start_tls=False) - # For port 587: use STARTTLS (use_tls=False, start_tls=True) - # For port 25: plain (usually not used for authenticated sending) + message.attach(MIMEText(html, 'html')) + if not text and (not html): + message.attach(MIMEText('', 'plain')) if mail_port == 465 or mail_use_tls: - # SSL/TLS connection (port 465) use_tls = True start_tls = False elif mail_port == 587: - # STARTTLS connection (port 587) use_tls = False start_tls = True else: - # Plain connection (port 25 or other) use_tls = False start_tls = False - - logger.info(f"Attempting to send email to {to} via {mail_host}:{mail_port} (use_tls: {use_tls}, start_tls: {start_tls})") - - # Send email using SMTP client - smtp_client = aiosmtplib.SMTP( - hostname=mail_host, - port=mail_port, - use_tls=use_tls, - start_tls=start_tls, - username=mail_user, - password=mail_pass, - ) - + logger.info(f'Attempting to send email to {to} via {mail_host}:{mail_port} (use_tls: {use_tls}, start_tls: {start_tls})') + smtp_client = aiosmtplib.SMTP(hostname=mail_host, port=mail_port, use_tls=use_tls, start_tls=start_tls, username=mail_user, password=mail_pass) try: await smtp_client.connect() - # Authentication happens automatically if username/password are provided in constructor await smtp_client.send_message(message) - logger.info(f"Email sent successfully to {to}") + logger.info(f'Email sent successfully to {to}') finally: await smtp_client.quit() - except Exception as e: - error_msg = f"Failed to send email to {to}: {type(e).__name__}: {str(e)}" + error_msg = f'Failed to send email to {to}: {type(e).__name__}: {str(e)}' logger.error(error_msg, exc_info=True) - raise - + raise \ No newline at end of file diff --git a/Backend/src/utils/vnpay_service.py b/Backend/src/utils/vnpay_service.py index 2d3669f1..d46cae1b 100644 --- a/Backend/src/utils/vnpay_service.py +++ b/Backend/src/utils/vnpay_service.py @@ -1,22 +1,11 @@ -""" -VNPay integration removed -This file is intentionally left as a stub to indicate the VNPay -payment gateway has been removed from the project. -""" - - def create_payment_url(*args, **kwargs): - raise NotImplementedError("VNPay integration has been removed") - + raise NotImplementedError('VNPay integration has been removed') def verify_return(*args, **kwargs): - raise NotImplementedError("VNPay integration has been removed") - + raise NotImplementedError('VNPay integration has been removed') def sort_object(obj): return {} - def create_signature(*args, **kwargs): - return "" - + return '' \ No newline at end of file diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index cd843de1..a7973178 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -15,22 +15,21 @@ import OfflineIndicator from './components/common/OfflineIndicator'; import CookieConsentBanner from './components/common/CookieConsentBanner'; import AnalyticsLoader from './components/common/AnalyticsLoader'; import Loading from './components/common/Loading'; +import ScrollToTop from './components/common/ScrollToTop'; -// Store import useAuthStore from './store/useAuthStore'; import useFavoritesStore from './store/useFavoritesStore'; -// Layout Components import { LayoutMain } from './components/layout'; import AdminLayout from './pages/AdminLayout'; -// Auth Components import { ProtectedRoute, - AdminRoute + AdminRoute, + StaffRoute, + CustomerRoute } from './components/auth'; -// Lazy load pages for code splitting const HomePage = lazy(() => import('./pages/HomePage')); const DashboardPage = lazy(() => import('./pages/customer/DashboardPage')); const RoomListPage = lazy(() => import('./pages/customer/RoomListPage')); @@ -56,18 +55,21 @@ const RegisterPage = lazy(() => import('./pages/auth/RegisterPage')); const ForgotPasswordPage = lazy(() => import('./pages/auth/ForgotPasswordPage')); const ResetPasswordPage = lazy(() => import('./pages/auth/ResetPasswordPage')); -// Lazy load admin pages const AdminDashboardPage = lazy(() => import('./pages/admin/DashboardPage')); const InvoiceManagementPage = lazy(() => import('./pages/admin/InvoiceManagementPage')); const PaymentManagementPage = lazy(() => import('./pages/admin/PaymentManagementPage')); const UserManagementPage = lazy(() => import('./pages/admin/UserManagementPage')); +const BookingManagementPage = lazy(() => import('./pages/admin/BookingManagementPage')); const PageContentDashboardPage = lazy(() => import('./pages/admin/PageContentDashboard')); const AnalyticsDashboardPage = lazy(() => import('./pages/admin/AnalyticsDashboardPage')); const BusinessDashboardPage = lazy(() => import('./pages/admin/BusinessDashboardPage')); const SettingsPage = lazy(() => import('./pages/admin/SettingsPage')); const ReceptionDashboardPage = lazy(() => import('./pages/admin/ReceptionDashboardPage')); -// Demo component for pages not yet created +const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage')); +const ChatManagementPage = lazy(() => import('./pages/staff/ChatManagementPage')); +const StaffLayout = lazy(() => import('./pages/StaffLayout')); + const DemoPage: React.FC<{ title: string }> = ({ title }) => (

@@ -80,7 +82,7 @@ const DemoPage: React.FC<{ title: string }> = ({ title }) => ( ); function App() { - // Use Zustand store + const { isAuthenticated, userInfo, @@ -94,20 +96,20 @@ function App() { loadGuestFavorites, } = useFavoritesStore(); - // Initialize auth state when app loads + useEffect(() => { initializeAuth(); }, [initializeAuth]); - // Load favorites when authenticated or load guest favorites + useEffect(() => { if (isAuthenticated) { - // Sync guest favorites first, then fetch + syncGuestFavorites().then(() => { fetchFavorites(); }); } else { - // Load guest favorites from localStorage + loadGuestFavorites(); } }, [ @@ -117,7 +119,7 @@ function App() { loadGuestFavorites, ]); - // Handle logout + const handleLogout = async () => { await logout(); }; @@ -133,9 +135,10 @@ function App() { v7_relativeSplatPath: true, }} > + }> - {/* Public Routes with Main Layout */} + {} } + element={ + + + + } /> } /> - {/* Protected Routes - Requires login */} + {} + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> + - + } /> - {/* Auth Routes (no layout) */} + {} } @@ -285,7 +292,7 @@ function App() { element={} /> - {/* Admin Routes - Only admin can access */} + {} - {/* 404 Route */} + {} + + + + } + > + } + /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + {} } diff --git a/Frontend/src/components/admin/IconPicker.tsx b/Frontend/src/components/admin/IconPicker.tsx index 88d720cd..5feffca3 100644 --- a/Frontend/src/components/admin/IconPicker.tsx +++ b/Frontend/src/components/admin/IconPicker.tsx @@ -2,7 +2,6 @@ import React, { useState, useMemo } from 'react'; import * as LucideIcons from 'lucide-react'; import { Search, X } from 'lucide-react'; -// Popular icons for hotel/luxury content const popularIcons = [ 'Sparkles', 'Star', 'Award', 'Shield', 'Heart', 'Crown', 'Gem', 'Zap', 'Wifi', 'Coffee', 'Utensils', 'Bed', 'Home', 'MapPin', @@ -45,7 +44,7 @@ const IconPicker: React.FC = ({ value, onChange, label = 'Icon' const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - // Get all available Lucide icons + const allIcons = useMemo(() => { const icons: string[] = []; const excludedNames = new Set([ @@ -61,17 +60,17 @@ const IconPicker: React.FC = ({ value, onChange, label = 'Icon' ]); for (const iconName in LucideIcons) { - // Skip non-icon exports + if ( excludedNames.has(iconName) || iconName.startsWith('_') || - iconName[0] !== iconName[0].toUpperCase() // Lucide icons start with uppercase + iconName[0] !== iconName[0].toUpperCase() ) { continue; } const iconComponent = (LucideIcons as any)[iconName]; - // Check if it's a React component (function) + if (typeof iconComponent === 'function') { icons.push(iconName); } @@ -81,10 +80,10 @@ const IconPicker: React.FC = ({ value, onChange, label = 'Icon' return sorted; }, []); - // Filter icons based on search + const filteredIcons = useMemo(() => { if (!searchQuery.trim()) { - // Show popular icons first, then others + const popular = popularIcons.filter(icon => allIcons.includes(icon)); const others = allIcons.filter(icon => !popularIcons.includes(icon)); return [...popular, ...others]; diff --git a/Frontend/src/components/auth/AdminRoute.tsx b/Frontend/src/components/auth/AdminRoute.tsx index b842b5f5..8cc3b20a 100644 --- a/Frontend/src/components/auth/AdminRoute.tsx +++ b/Frontend/src/components/auth/AdminRoute.tsx @@ -6,20 +6,13 @@ interface AdminRouteProps { children: React.ReactNode; } -/** - * AdminRoute - Protects routes that are only for Admin - * - * Checks: - * 1. Is user logged in → if not, redirect to /login - * 2. Does user have admin role → if not, redirect to / - */ const AdminRoute: React.FC = ({ children }) => { const location = useLocation(); const { isAuthenticated, userInfo, isLoading } = useAuthStore(); - // Loading auth state → show loading + if (isLoading) { return (
= ({ ); } - // Not logged in → redirect to /login + if (!isAuthenticated) { return ( = ({ ); } - // Logged in but not admin → redirect to / + const isAdmin = userInfo?.role === 'admin'; if (!isAdmin) { return ; diff --git a/Frontend/src/components/auth/CustomerRoute.tsx b/Frontend/src/components/auth/CustomerRoute.tsx new file mode 100644 index 00000000..c84ba443 --- /dev/null +++ b/Frontend/src/components/auth/CustomerRoute.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import useAuthStore from '../../store/useAuthStore'; + +interface CustomerRouteProps { + children: React.ReactNode; +} + +const CustomerRoute: React.FC = ({ + children +}) => { + const location = useLocation(); + const { isAuthenticated, userInfo, isLoading } = useAuthStore(); + + + if (isLoading) { + return ( +
+
+
+

+ Loading... +

+
+
+ ); + } + + + if (!isAuthenticated) { + return ( + + ); + } + + + const isCustomer = userInfo?.role !== 'admin' && userInfo?.role !== 'staff'; + if (!isCustomer) { + if (userInfo?.role === 'admin') { + return ; + } else if (userInfo?.role === 'staff') { + return ; + } + return ; + } + + return <>{children}; +}; + +export default CustomerRoute; + diff --git a/Frontend/src/components/auth/ProtectedRoute.tsx b/Frontend/src/components/auth/ProtectedRoute.tsx index 6307ccaa..2538cadd 100644 --- a/Frontend/src/components/auth/ProtectedRoute.tsx +++ b/Frontend/src/components/auth/ProtectedRoute.tsx @@ -6,19 +6,13 @@ interface ProtectedRouteProps { children: React.ReactNode; } -/** - * ProtectedRoute - Protects routes that require authentication - * - * If user is not logged in, redirect to /login - * and save current location to redirect back after login - */ const ProtectedRoute: React.FC = ({ children }) => { const location = useLocation(); const { isAuthenticated, isLoading } = useAuthStore(); - // Loading auth state → show loading + if (isLoading) { return (
= ({ ); } - // Not logged in → redirect to /login + if (!isAuthenticated) { return ( = ({ + children +}) => { + const location = useLocation(); + const { isAuthenticated, userInfo, isLoading } = useAuthStore(); + + + if (isLoading) { + return ( +
+
+
+

+ Authenticating... +

+
+
+ ); + } + + + if (!isAuthenticated) { + return ( + + ); + } + + + const isStaff = userInfo?.role === 'staff'; + if (!isStaff) { + return ; + } + + return <>{children}; +}; + +export default StaffRoute; + diff --git a/Frontend/src/components/auth/index.ts b/Frontend/src/components/auth/index.ts index 5b799cbe..f8103870 100644 --- a/Frontend/src/components/auth/index.ts +++ b/Frontend/src/components/auth/index.ts @@ -1,2 +1,4 @@ export { default as ProtectedRoute } from './ProtectedRoute'; export { default as AdminRoute } from './AdminRoute'; +export { default as StaffRoute } from './StaffRoute'; +export { default as CustomerRoute } from './CustomerRoute'; diff --git a/Frontend/src/components/chat/ChatWidget.tsx b/Frontend/src/components/chat/ChatWidget.tsx new file mode 100644 index 00000000..75e2d288 --- /dev/null +++ b/Frontend/src/components/chat/ChatWidget.tsx @@ -0,0 +1,516 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { MessageCircle, X, Send, Minimize2, Maximize2 } from 'lucide-react'; +import { chatService, type Chat, type ChatMessage } from '../../services/api'; +import useAuthStore from '../../store/useAuthStore'; +import { toast } from 'react-toastify'; + +interface ChatWidgetProps { + onClose?: () => void; +} + +const ChatWidget: React.FC = ({ onClose }) => { + const [isOpen, setIsOpen] = useState(false); + const [isMinimized, setIsMinimized] = useState(false); + const [chat, setChat] = useState(null); + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [loading, setLoading] = useState(false); + const [ws, setWs] = useState(null); + const [visitorInfo, setVisitorInfo] = useState({ + name: '', + email: '', + phone: '' + }); + const [showVisitorForm, setShowVisitorForm] = useState(false); + const [formErrors, setFormErrors] = useState>({}); + const messagesEndRef = useRef(null); + const { isAuthenticated, userInfo } = useAuthStore(); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const validateVisitorForm = (): boolean => { + const errors: Record = {}; + + if (!visitorInfo.name.trim()) { + errors.name = 'Name is required'; + } + + if (!visitorInfo.email.trim()) { + errors.email = 'Email is required'; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(visitorInfo.email)) { + errors.email = 'Please enter a valid email address'; + } + + if (!visitorInfo.phone.trim()) { + errors.phone = 'Phone is required'; + } + + setFormErrors(errors); + return Object.keys(errors).length === 0; + }; + + const createChat = async () => { + if (!isAuthenticated) { + if (!validateVisitorForm()) { + return; + } + } + + try { + setLoading(true); + const response = await chatService.createChat( + isAuthenticated + ? undefined + : { + visitor_name: visitorInfo.name, + visitor_email: visitorInfo.email, + visitor_phone: visitorInfo.phone + } + ); + setChat(response.data); + setShowVisitorForm(false); + + const messagesResponse = await chatService.getMessages(response.data.id); + setMessages(messagesResponse.data); + + connectWebSocket(response.data.id); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to start chat'); + } finally { + setLoading(false); + } + }; + + const connectWebSocket = (chatId: number) => { + if (ws) { + ws.close(); + } + + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + const normalizedBase = baseUrl.replace(/\/$/, ''); + const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws'; + const wsBase = normalizedBase.replace(/^https?/, wsProtocol); + const wsUrl = `${wsBase}/api/chat/ws/${chatId}?user_type=visitor`; + + const websocket = new WebSocket(wsUrl); + + websocket.onopen = () => { + console.log('WebSocket connected for chat', chatId); + }; + + websocket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'new_message') { + setMessages(prev => { + const exists = prev.find(m => m.id === data.data.id); + if (exists) return prev; + return [...prev, data.data]; + }); + } else if (data.type === 'chat_accepted') { + if (chat) { + const updatedChat = { + ...chat, + status: 'active' as const, + staff_name: data.data.staff_name, + staff_id: data.data.staff_id + }; + setChat(updatedChat); + toast.success(`Chat accepted by ${data.data.staff_name}`); + } + } else if (data.type === 'chat_closed') { + toast.info('Chat has been closed'); + if (chat) { + setChat({ ...chat, status: 'closed' }); + } + } + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + websocket.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + websocket.onclose = (event) => { + console.log('WebSocket disconnected', event.code, event.reason); + if (event.code !== 1000 && chat) { + console.log('Attempting to reconnect WebSocket...'); + setTimeout(() => { + if (chat) { + connectWebSocket(chat.id); + } + }, 3000); + } + }; + + setWs(websocket); + }; + + useEffect(() => { + if (!chat || !isOpen || isMinimized) return; + + const pollInterval = setInterval(async () => { + try { + const messagesResponse = await chatService.getMessages(chat.id); + if (messagesResponse.success) { + setMessages(messagesResponse.data); + } + + if (chat.status === 'pending') { + try { + const chatResponse = await chatService.getChat(chat.id); + if (chatResponse.success) { + const updatedChat = chatResponse.data; + if (updatedChat.status !== chat.status || + updatedChat.staff_name !== chat.staff_name || + updatedChat.staff_id !== chat.staff_id) { + setChat(updatedChat); + if (updatedChat.status === 'active' && updatedChat.staff_name && chat.status === 'pending') { + toast.success(`Chat accepted by ${updatedChat.staff_name}`); + } + } + } + } catch (error) { + console.debug('Could not poll chat status:', error); + } + } + } catch (error) { + console.error('Error polling chat updates:', error); + } + }, 5000); + + return () => clearInterval(pollInterval); + }, [chat, isOpen, isMinimized]); + + const handleOpen = () => { + setIsOpen(true); + setIsMinimized(false); + if (isAuthenticated && !chat && !loading) { + createChat(); + } else if (!isAuthenticated && !chat) { + setShowVisitorForm(true); + } + }; + + const handleClose = () => { + if (ws) { + ws.close(); + setWs(null); + } + setIsOpen(false); + if (onClose) onClose(); + }; + + const handleEndChat = async () => { + if (!chat) return; + + if (window.confirm('Are you sure you want to end this chat?')) { + try { + await chatService.closeChat(chat.id); + toast.success('Chat ended'); + if (ws) { + ws.close(); + setWs(null); + } + setChat({ ...chat, status: 'closed' }); + } catch (error: any) { + toast.error(error.response?.data?.detail || 'Failed to end chat'); + } + } + }; + + const handleSend = async () => { + if (!newMessage.trim() || !chat) return; + + const messageText = newMessage.trim(); + setNewMessage(''); + + const tempMessage: ChatMessage = { + id: Date.now(), + chat_id: chat.id, + sender_type: 'visitor', + sender_name: isAuthenticated ? userInfo?.full_name : 'Guest', + message: messageText, + is_read: false, + created_at: new Date().toISOString() + }; + setMessages(prev => [...prev, tempMessage]); + + try { + if (ws && ws.readyState === WebSocket.OPEN) { + + ws.send(JSON.stringify({ + type: 'message', + message: messageText + })); + } else { + await chatService.sendMessage(chat.id, messageText); + } + } catch (error: any) { + toast.error('Failed to send message'); + setMessages(prev => prev.filter(m => m.id !== tempMessage.id)); + } + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + if (!isOpen) { + return ( + + ); + } + + return ( +
+ {} +
+
+ +
+

+ {chat?.status === 'active' && chat?.staff_name + ? `Chat with ${chat.staff_name}` + : chat?.status === 'closed' + ? 'Chat Ended' + : 'Support Chat'} +

+ {chat?.status === 'pending' && ( +

Waiting for staff...

+ )} + {chat?.status === 'active' && ( +

Online

+ )} + {chat?.status === 'closed' && ( +

This chat has ended

+ )} +
+
+
+ {chat && chat.status !== 'closed' && ( + + )} + + +
+
+ + {!isMinimized && ( + <> + {} + {showVisitorForm && !chat && ( +
+
+

+ Please provide your information to start chatting +

+
+
+ + { + setVisitorInfo({ ...visitorInfo, name: e.target.value }); + if (formErrors.name) setFormErrors({ ...formErrors, name: '' }); + }} + className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + formErrors.name ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="Your full name" + /> + {formErrors.name && ( +

{formErrors.name}

+ )} +
+
+ + { + setVisitorInfo({ ...visitorInfo, email: e.target.value }); + if (formErrors.email) setFormErrors({ ...formErrors, email: '' }); + }} + className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + formErrors.email ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="your.email@example.com" + /> + {formErrors.email && ( +

{formErrors.email}

+ )} +
+
+ + { + setVisitorInfo({ ...visitorInfo, phone: e.target.value }); + if (formErrors.phone) setFormErrors({ ...formErrors, phone: '' }); + }} + className={`w-full px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + formErrors.phone ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="+1 (555) 123-4567" + /> + {formErrors.phone && ( +

{formErrors.phone}

+ )} +
+ +
+
+
+ )} + + {} + {!showVisitorForm && ( +
+ {loading && !chat ? ( +
+ Starting chat... +
+ ) : messages.length === 0 ? ( +
+ No messages yet. Start the conversation! +
+ ) : ( + messages.map((message) => ( +
+
+ {message.sender_type === 'staff' && ( +
+ {message.sender_name || 'Staff'} +
+ )} +

+ {message.message} +

+

+ {new Date(message.created_at).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} +

+
+
+ )) + )} +
+
+ )} + + {} + {!showVisitorForm && chat && chat.status !== 'closed' && ( +
+
+ setNewMessage(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Type your message..." + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+ {chat?.status === 'pending' && ( +

+ Waiting for a staff member to accept your chat. You can still send messages. +

+ )} +
+ )} + + )} +
+ ); +}; + +export default ChatWidget; + diff --git a/Frontend/src/components/chat/StaffChatNotification.tsx b/Frontend/src/components/chat/StaffChatNotification.tsx new file mode 100644 index 00000000..bcd353b8 --- /dev/null +++ b/Frontend/src/components/chat/StaffChatNotification.tsx @@ -0,0 +1,166 @@ +import React, { useEffect, useState } from 'react'; +import { MessageCircle, Bell } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import useAuthStore from '../../store/useAuthStore'; +import { chatService, type Chat } from '../../services/api'; +import { useChatNotifications } from '../../contexts/ChatNotificationContext'; + +const StaffChatNotification: React.FC = () => { + const [notificationWs, setNotificationWs] = useState(null); + const [pendingChats, setPendingChats] = useState([]); + const [isConnecting, setIsConnecting] = useState(false); + const reconnectTimeoutRef = React.useRef(null); + const navigate = useNavigate(); + const { isAuthenticated, userInfo, token } = useAuthStore(); + const { unreadCount, refreshCount } = useChatNotifications(); + + useEffect(() => { + if (!isAuthenticated || (userInfo?.role !== 'staff' && userInfo?.role !== 'admin')) { + if (notificationWs) { + notificationWs.close(); + setNotificationWs(null); + } + return; + } + + const authToken = token || localStorage.getItem('token'); + if (!authToken) return; + + if (notificationWs && (notificationWs.readyState === WebSocket.CONNECTING || notificationWs.readyState === WebSocket.OPEN)) { + return; + } + + if (isConnecting) { + return; + } + + const connectWebSocket = () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + + setIsConnecting(true); + const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + const normalizedBase = baseUrl.replace(/\/$/, ''); + const wsProtocol = normalizedBase.startsWith('https') ? 'wss' : 'ws'; + const wsBase = normalizedBase.replace(/^https?/, wsProtocol); + const wsUrl = `${wsBase}/api/chat/ws/staff/notifications?token=${encodeURIComponent(authToken)}`; + + console.log('Connecting to WebSocket:', wsUrl.replace(authToken, 'TOKEN_HIDDEN')); + + try { + const websocket = new WebSocket(wsUrl); + + websocket.onopen = () => { + console.log('Staff notification WebSocket connected'); + setIsConnecting(false); + }; + + websocket.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.type === 'ping' || data.type === 'pong' || data.type === 'connected') { + return; + } + + if (data.type === 'new_chat') { + const chatData = data.data; + setPendingChats(prev => { + const exists = prev.find(c => c.id === chatData.id); + if (exists) return prev; + return [...prev, chatData]; + }); + refreshCount(); + + toast.info(`New chat from ${chatData.visitor_name}`, { + onClick: () => { + navigate('/staff/chats'); + }, + autoClose: 10000 + }); + } else if (data.type === 'new_message_notification') { + const chatData = data.data.chat; + const messageData = data.data.message; + refreshCount(); + + toast.info(`New message from ${chatData.visitor_name}: ${messageData.message.substring(0, 50)}${messageData.message.length > 50 ? '...' : ''}`, { + onClick: () => { + navigate('/staff/chats'); + }, + autoClose: 10000 + }); + } + } catch (error) { + console.error('Error parsing notification:', error); + } + }; + + websocket.onerror = (error) => { + console.error('Notification WebSocket error:', error); + setIsConnecting(false); + }; + + websocket.onclose = (event) => { + console.log('Notification WebSocket disconnected', event.code, event.reason); + setIsConnecting(false); + setNotificationWs(null); + + if (event.code !== 1000 && isAuthenticated && (userInfo?.role === 'staff' || userInfo?.role === 'admin')) { + reconnectTimeoutRef.current = setTimeout(() => { + connectWebSocket(); + }, 5000); + } + }; + + setNotificationWs(websocket); + } catch (error) { + console.error('Error creating WebSocket:', error); + setIsConnecting(false); + reconnectTimeoutRef.current = setTimeout(() => { + connectWebSocket(); + }, 5000); + } + }; + + connectWebSocket(); + + return () => { + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (notificationWs) { + notificationWs.close(1000, 'Component unmounting'); + setNotificationWs(null); + } + setIsConnecting(false); + }; + }, [isAuthenticated, userInfo?.role]); + + if (!isAuthenticated || (userInfo?.role !== 'staff' && userInfo?.role !== 'admin')) { + return null; + } + + const totalUnread = unreadCount; + + return ( + + ); +}; + +export default StaffChatNotification; + diff --git a/Frontend/src/components/common/AnalyticsLoader.tsx b/Frontend/src/components/common/AnalyticsLoader.tsx index 2762b76b..646d3fa3 100644 --- a/Frontend/src/components/common/AnalyticsLoader.tsx +++ b/Frontend/src/components/common/AnalyticsLoader.tsx @@ -20,7 +20,7 @@ const AnalyticsLoader: React.FC = () => { const gaLoadedRef = useRef(false); const fbLoadedRef = useRef(false); - // Load public privacy config once + useEffect(() => { let mounted = true; const loadConfig = async () => { @@ -29,7 +29,7 @@ const AnalyticsLoader: React.FC = () => { if (!mounted) return; setConfig(cfg); } catch { - // Fail silently in production; analytics are non-critical + } }; void loadConfig(); @@ -38,7 +38,7 @@ const AnalyticsLoader: React.FC = () => { }; }, []); - // Load Google Analytics when allowed + useEffect(() => { if (!config || !consent) return; const measurementId = config.integrations.ga_measurement_id; @@ -46,12 +46,10 @@ const AnalyticsLoader: React.FC = () => { config.policy.analytics_enabled && consent.categories.analytics; if (!measurementId || !analyticsAllowed || gaLoadedRef.current) return; - // Inject GA script + const script = document.createElement('script'); script.async = true; - script.src = `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent( - measurementId - )}`; + script.src = `https://www.googletagmanager.com/gtag/js?id=${measurementId}`; document.head.appendChild(script); window.dataLayer = window.dataLayer || []; @@ -65,11 +63,11 @@ const AnalyticsLoader: React.FC = () => { gaLoadedRef.current = true; return () => { - // We don't remove GA script on unmount; typical SPA behaviour is to keep it. + }; }, [config, consent]); - // Track GA page views on route change + useEffect(() => { if (!gaLoadedRef.current || !config?.integrations.ga_measurement_id) return; if (typeof window.gtag !== 'function') return; @@ -78,7 +76,7 @@ const AnalyticsLoader: React.FC = () => { }); }, [location, config]); - // Load Meta Pixel when allowed + useEffect(() => { if (!config || !consent) return; const pixelId = config.integrations.fb_pixel_id; @@ -86,22 +84,24 @@ const AnalyticsLoader: React.FC = () => { config.policy.marketing_enabled && consent.categories.marketing; if (!pixelId || !marketingAllowed || fbLoadedRef.current) return; - // Meta Pixel base code - !(function (f: any, b, e, v, n?, t?, s?) { + + (function (f: any, b: Document, e: string) { if (f.fbq) return; - n = f.fbq = function () { + const n: any = f.fbq = function () { (n.callMethod ? n.callMethod : n.queue.push).apply(n, arguments); }; if (!f._fbq) f._fbq = n; - (n as any).push = n; - (n as any).loaded = true; - (n as any).version = '2.0'; - (n as any).queue = []; - t = b.createElement(e); + n.push = n; + n.loaded = true; + n.version = '2.0'; + n.queue = []; + const t = b.createElement(e) as HTMLScriptElement; t.async = true; t.src = 'https://connect.facebook.net/en_US/fbevents.js'; - s = b.getElementsByTagName(e)[0]; - s.parentNode?.insertBefore(t, s); + const s = b.getElementsByTagName(e)[0]; + if (s && s.parentNode) { + s.parentNode.insertBefore(t, s); + } })(window, document, 'script'); window.fbq('init', pixelId); @@ -114,4 +114,3 @@ const AnalyticsLoader: React.FC = () => { export default AnalyticsLoader; - diff --git a/Frontend/src/components/common/ConfirmationDialog.tsx b/Frontend/src/components/common/ConfirmationDialog.tsx index 714cb796..480e99ab 100644 --- a/Frontend/src/components/common/ConfirmationDialog.tsx +++ b/Frontend/src/components/common/ConfirmationDialog.tsx @@ -26,22 +26,20 @@ const ConfirmationDialog: React.FC = ({ }) => { if (!isOpen) return null; - const variantStyles = { - danger: { - icon: 'text-red-600', - button: 'bg-red-600 hover:bg-red-700 focus:ring-red-500', - }, - warning: { - icon: 'text-yellow-600', - button: 'bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500', - }, - info: { - icon: 'text-blue-600', - button: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500', - }, - }; - - const styles = variantStyles[variant]; + + + + + + + + + + + + + + return (
= ({ role="dialog" aria-modal="true" > - {/* Backdrop */} + {}

- {/* Cash Payment */} + {} - {/* Stripe Payment */} + {}