This commit is contained in:
Iliyan Angelov
2025-11-16 20:05:08 +02:00
parent 98ccd5b6ff
commit 48353cde9c
118 changed files with 9488 additions and 1336 deletions

View File

@@ -1,35 +1,34 @@
# Environment
NODE_ENV=development
# Hotel Booking API - Environment Variables
# Copy this file to .env and fill in your actual values
# Server
PORT=3000
HOST=localhost
# ============================================
# Email/SMTP Configuration
# ============================================
# SMTP Server Settings
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASSWORD=your-app-specific-password
# Database
# Email Sender Information
SMTP_FROM_EMAIL=noreply@yourdomain.com
SMTP_FROM_NAME=Hotel Booking
# Alternative: Legacy environment variable names (for backward compatibility)
# MAIL_HOST=smtp.gmail.com
# MAIL_PORT=587
# MAIL_USER=your-email@gmail.com
# MAIL_PASS=your-app-specific-password
# MAIL_FROM=noreply@yourdomain.com
# MAIL_SECURE=false
# ============================================
# Other Required Variables
# ============================================
CLIENT_URL=http://localhost:5173
DB_USER=root
DB_PASS=your_database_password
DB_NAME=hotel_db
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_NAME=hotel_booking_dev
# JWT
JWT_SECRET=your_super_secret_jwt_key_change_this_in_production
JWT_EXPIRES_IN=1h
JWT_REFRESH_SECRET=your_super_secret_refresh_key_change_this_in_production
JWT_REFRESH_EXPIRES_IN=7d
# Client URL
CLIENT_URL=http://localhost:5173
# Upload
MAX_FILE_SIZE=5242880
ALLOWED_FILE_TYPES=image/jpeg,image/png,image/jpg,image/webp
# Pagination
DEFAULT_PAGE_SIZE=10
MAX_PAGE_SIZE=100
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
JWT_SECRET=your-super-secret-jwt-key-change-in-production

Binary file not shown.

View File

@@ -5,12 +5,17 @@ from alembic import context
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
@@ -20,16 +25,8 @@ config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Get database URL from environment
database_url = os.getenv("DATABASE_URL")
if not database_url:
db_user = os.getenv("DB_USER", "root")
db_pass = os.getenv("DB_PASS", "")
db_name = os.getenv("DB_NAME", "hotel_db")
db_host = os.getenv("DB_HOST", "localhost")
db_port = os.getenv("DB_PORT", "3306")
database_url = f"mysql+pymysql://{db_user}:{db_pass}@{db_host}:{db_port}/{db_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

View File

@@ -0,0 +1,285 @@
"""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_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)
op.create_index(op.f('ix_audit_logs_request_id'), 'audit_logs', ['request_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_id'), 'audit_logs', ['resource_id'], unique=False)
op.create_index(op.f('ix_audit_logs_resource_type'), 'audit_logs', ['resource_type'], unique=False)
op.create_index(op.f('ix_audit_logs_user_id'), 'audit_logs', ['user_id'], unique=False)
op.drop_index('name', table_name='SequelizeMeta')
op.drop_table('SequelizeMeta')
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')
op.drop_index('bookings_booking_number', table_name='bookings')
op.drop_index('bookings_check_in_date', table_name='bookings')
op.drop_index('bookings_check_out_date', table_name='bookings')
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')
op.create_index(op.f('ix_bookings_booking_number'), 'bookings', ['booking_number'], unique=True)
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')
op.drop_index('checkin_checkout_booking_id', table_name='checkin_checkout')
op.create_index(op.f('ix_checkin_checkout_id'), 'checkin_checkout', ['id'], unique=False)
op.create_unique_constraint(None, 'checkin_checkout', ['booking_id'])
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')
op.drop_index('favorites_user_id', table_name='favorites')
op.drop_index('unique_user_room_favorite', table_name='favorites')
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.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')
op.drop_index('token', table_name='password_reset_tokens')
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.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')
op.drop_index('payments_payment_status', table_name='payments')
op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False)
op.create_foreign_key(None, 'payments', 'bookings', ['booking_id'], ['id'])
op.create_foreign_key(None, 'payments', 'payments', ['related_payment_id'], ['id'])
op.drop_index('code', table_name='promotions')
op.drop_index('promotions_code', table_name='promotions')
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')
op.drop_index('token', table_name='refresh_tokens')
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')
op.drop_index('reviews_status', table_name='reviews')
op.drop_index('reviews_user_id', table_name='reviews')
op.create_index(op.f('ix_reviews_id'), 'reviews', ['id'], unique=False)
op.create_foreign_key(None, 'reviews', 'rooms', ['room_id'], ['id'])
op.create_foreign_key(None, 'reviews', 'users', ['user_id'], ['id'])
op.drop_index('name', table_name='roles')
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')
op.drop_index('rooms_room_type_id', table_name='rooms')
op.drop_index('rooms_status', table_name='rooms')
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')
op.drop_index('service_usages_service_id', table_name='service_usages')
op.create_index(op.f('ix_service_usages_id'), 'service_usages', ['id'], unique=False)
op.create_foreign_key(None, 'service_usages', 'bookings', ['booking_id'], ['id'])
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')
op.drop_index('users_role_id', table_name='users')
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')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.create_index('users_role_id', 'users', ['role_id'], unique=False)
op.create_index('users_email', 'users', ['email'], unique=False)
op.create_index('email', 'users', ['email'], unique=False)
op.drop_index(op.f('ix_services_id'), table_name='services')
op.create_index('services_category', 'services', ['category'], unique=False)
op.drop_constraint(None, 'service_usages', type_='foreignkey')
op.drop_constraint(None, 'service_usages', type_='foreignkey')
op.create_foreign_key('service_usages_ibfk_2', 'service_usages', 'services', ['service_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('service_usages_ibfk_1', 'service_usages', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_service_usages_id'), table_name='service_usages')
op.create_index('service_usages_service_id', 'service_usages', ['service_id'], unique=False)
op.create_index('service_usages_booking_id', 'service_usages', ['booking_id'], unique=False)
op.drop_constraint(None, 'rooms', type_='foreignkey')
op.create_foreign_key('rooms_ibfk_1', 'rooms', 'room_types', ['room_type_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_rooms_room_number'), table_name='rooms')
op.drop_index(op.f('ix_rooms_id'), table_name='rooms')
op.create_index('rooms_status', 'rooms', ['status'], unique=False)
op.create_index('rooms_room_type_id', 'rooms', ['room_type_id'], unique=False)
op.create_index('rooms_featured', 'rooms', ['featured'], unique=False)
op.create_index('room_number', 'rooms', ['room_number'], unique=False)
op.drop_index(op.f('ix_room_types_id'), table_name='room_types')
op.drop_index(op.f('ix_roles_name'), table_name='roles')
op.drop_index(op.f('ix_roles_id'), table_name='roles')
op.create_index('name', 'roles', ['name'], unique=False)
op.drop_constraint(None, 'reviews', type_='foreignkey')
op.drop_constraint(None, 'reviews', type_='foreignkey')
op.create_foreign_key('reviews_ibfk_1', 'reviews', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.create_foreign_key('reviews_ibfk_2', 'reviews', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_reviews_id'), table_name='reviews')
op.create_index('reviews_user_id', 'reviews', ['user_id'], unique=False)
op.create_index('reviews_status', 'reviews', ['status'], unique=False)
op.create_index('reviews_room_id', 'reviews', ['room_id'], unique=False)
op.drop_constraint(None, 'refresh_tokens', type_='foreignkey')
op.create_foreign_key('refresh_tokens_ibfk_1', 'refresh_tokens', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_refresh_tokens_token'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens')
op.create_index('token', 'refresh_tokens', ['token'], unique=False)
op.create_index('refresh_tokens_user_id', 'refresh_tokens', ['user_id'], unique=False)
op.create_index('refresh_tokens_token', 'refresh_tokens', ['token'], unique=False)
op.drop_index(op.f('ix_promotions_id'), table_name='promotions')
op.drop_index(op.f('ix_promotions_code'), table_name='promotions')
op.create_index('promotions_is_active', 'promotions', ['is_active'], unique=False)
op.create_index('promotions_code', 'promotions', ['code'], unique=False)
op.create_index('code', 'promotions', ['code'], unique=False)
op.drop_constraint(None, 'payments', type_='foreignkey')
op.drop_constraint(None, 'payments', type_='foreignkey')
op.create_foreign_key('payments_ibfk_1', 'payments', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('payments_related_payment_id_foreign_idx', 'payments', 'payments', ['related_payment_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
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.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')
op.drop_index(op.f('ix_password_reset_tokens_id'), table_name='password_reset_tokens')
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.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')
op.create_foreign_key('favorites_ibfk_2', 'favorites', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='CASCADE')
op.drop_index(op.f('ix_favorites_id'), table_name='favorites')
op.create_index('unique_user_room_favorite', 'favorites', ['user_id', 'room_id'], unique=False)
op.create_index('favorites_user_id', 'favorites', ['user_id'], unique=False)
op.create_index('favorites_room_id', 'favorites', ['room_id'], unique=False)
op.drop_constraint(None, 'checkin_checkout', type_='foreignkey')
op.drop_constraint(None, 'checkin_checkout', type_='foreignkey')
op.drop_constraint(None, 'checkin_checkout', type_='foreignkey')
op.create_foreign_key('checkin_checkout_ibfk_3', 'checkin_checkout', 'users', ['checkout_by'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
op.create_foreign_key('checkin_checkout_ibfk_2', 'checkin_checkout', 'users', ['checkin_by'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
op.create_foreign_key('checkin_checkout_ibfk_1', 'checkin_checkout', 'bookings', ['booking_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_constraint(None, 'checkin_checkout', type_='unique')
op.drop_index(op.f('ix_checkin_checkout_id'), table_name='checkin_checkout')
op.create_index('checkin_checkout_booking_id', 'checkin_checkout', ['booking_id'], unique=False)
op.drop_constraint(None, 'bookings', type_='foreignkey')
op.drop_constraint(None, 'bookings', type_='foreignkey')
op.create_foreign_key('bookings_ibfk_1', 'bookings', 'users', ['user_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.create_foreign_key('bookings_ibfk_2', 'bookings', 'rooms', ['room_id'], ['id'], onupdate='CASCADE', ondelete='RESTRICT')
op.drop_index(op.f('ix_bookings_id'), table_name='bookings')
op.drop_index(op.f('ix_bookings_booking_number'), table_name='bookings')
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)
op.create_index('bookings_check_out_date', 'bookings', ['check_out_date'], unique=False)
op.create_index('bookings_check_in_date', 'bookings', ['check_in_date'], unique=False)
op.create_index('bookings_booking_number', 'bookings', ['booking_number'], unique=False)
op.create_index('booking_number', 'bookings', ['booking_number'], unique=False)
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_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')
op.drop_index(op.f('ix_audit_logs_resource_id'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_request_id'), table_name='audit_logs')
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 ###

View File

@@ -17,3 +17,8 @@ aiosmtplib==3.0.1
jinja2==3.1.2
alembic==1.12.1
# Enterprise features (optional but recommended)
# redis==5.0.1 # Uncomment if using Redis caching
# prometheus-client==0.19.0 # Uncomment for Prometheus metrics
# sentry-sdk==1.38.0 # Uncomment for Sentry error tracking

View File

@@ -0,0 +1,91 @@
#!/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
from src.config.database import SessionLocal
from src.models.user import User
from src.config.logging_config import setup_logging
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)
return hashed.decode('utf-8')
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)
print(f"✅ Password reset for {email}")
print(f" New password: {new_password}")
print(f" Hash length: {len(user.password)} characters")
print()
return True
def main():
"""Reset passwords for all test users"""
db = SessionLocal()
try:
print("="*80)
print("RESETTING TEST USER PASSWORDS")
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)
print(f"\n❌ Error: {e}")
db.rollback()
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -3,21 +3,46 @@
Main entry point for the FastAPI server
"""
import uvicorn
import os
from dotenv import load_dotenv
from src.config.settings import settings
from src.config.logging_config import setup_logging, get_logger
load_dotenv()
# Setup logging
setup_logging()
logger = get_logger(__name__)
if __name__ == "__main__":
port = int(os.getenv("PORT", 8000))
host = os.getenv("HOST", "0.0.0.0")
reload = os.getenv("NODE_ENV") == "development"
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=host,
port=port,
reload=reload,
log_level="info"
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
)

View File

@@ -1,38 +1,63 @@
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
import os
from dotenv import load_dotenv
from sqlalchemy.pool import QueuePool
from .settings import settings
from .logging_config import get_logger
load_dotenv()
logger = get_logger(__name__)
# Database configuration
DB_USER = os.getenv("DB_USER", "root")
DB_PASS = os.getenv("DB_PASS", "")
DB_NAME = os.getenv("DB_NAME", "hotel_db")
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = os.getenv("DB_PORT", "3306")
DATABASE_URL = f"mysql+pymysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
# Database configuration using settings
DATABASE_URL = settings.database_url
# Enhanced engine configuration for enterprise use
engine = create_engine(
DATABASE_URL,
pool_pre_ping=True,
pool_recycle=300,
pool_size=5,
max_overflow=10,
echo=os.getenv("NODE_ENV") == "development"
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")
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Set connection-level settings"""
logger.debug("New database connection established")
@event.listens_for(engine, "checkout")
def receive_checkout(dbapi_conn, connection_record, connection_proxy):
"""Log connection checkout"""
logger.debug("Connection checked out from pool")
@event.listens_for(engine, "checkin")
def receive_checkin(dbapi_conn, connection_record):
"""Log connection checkin"""
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
except Exception:
db.rollback()
raise
finally:
db.close()

View File

@@ -0,0 +1,96 @@
"""
Enterprise-grade structured logging configuration
"""
import logging
import sys
from logging.handlers import RotatingFileHandler
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
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
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'
)
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)
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)

View File

@@ -0,0 +1,119 @@
"""
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")
@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}"
@property
def is_production(self) -> bool:
"""Check if running in production"""
return self.ENVIRONMENT.lower() == "production"
@property
def is_development(self) -> bool:
"""Check if running in 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()

View File

@@ -1,17 +1,30 @@
from fastapi import FastAPI, Request, HTTPException
from fastapi import FastAPI, Request, HTTPException, Depends, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.exc import IntegrityError, OperationalError
from jose.exceptions import JWTError
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
import os
from pathlib import Path
from datetime import datetime
import sys
from .config.database import engine, Base
# 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 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,
@@ -19,38 +32,65 @@ from .middleware.error_handler import (
http_exception_handler,
general_exception_handler
)
# Create database tables
Base.metadata.create_all(bind=engine)
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)")
Base.metadata.create_all(bind=engine)
else:
# Ensure new cookie-related 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
logger.info("Ensuring cookie-related tables exist")
CookiePolicy.__table__.create(bind=engine, checkfirst=True)
CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True)
except Exception as e:
logger.error(f"Failed to ensure cookie tables exist: {e}")
from .routes import auth_routes
from .routes import privacy_routes
# Initialize FastAPI app
app = FastAPI(
title="Hotel Booking API",
description="Hotel booking backend API",
version="1.0.0"
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.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
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
if settings.RATE_LIMIT_ENABLED:
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
# Allow multiple origins for development
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
allowed_origins = [
client_url,
"http://localhost:5173", # Vite default
"http://localhost:3000", # Alternative port
"http://localhost:5174", # Vite alternative
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"http://127.0.0.1:5174",
]
# In development, allow all localhost origins using regex
if os.getenv("ENVIRONMENT", "development") == "development":
if settings.is_development:
# For development, use regex to allow any localhost port
app.add_middleware(
CORSMiddleware,
@@ -59,18 +99,20 @@ if os.getenv("ENVIRONMENT", "development") == "development":
allow_methods=["*"],
allow_headers=["*"],
)
logger.info("CORS configured for development (allowing localhost)")
else:
# Production: use specific origins
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
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)
uploads_dir = Path(__file__).parent.parent / "uploads"
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")
@@ -81,25 +123,82 @@ app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(JWTError, jwt_error_handler)
app.add_exception_handler(Exception, general_exception_handler)
# Health check
@app.get("/health")
async def health_check():
# Enhanced Health check with database connectivity
@app.get("/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)):
"""
Enhanced health check endpoint with database connectivity test
"""
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
try:
from sqlalchemy import text
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
)
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
)
return health_status
# Metrics endpoint (basic)
@app.get("/metrics", tags=["monitoring"])
async def metrics():
"""
Basic metrics endpoint (can be extended with Prometheus or similar)
"""
return {
"status": "success",
"message": "Server is running",
"timestamp": __import__("datetime").datetime.utcnow().isoformat()
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"environment": settings.ENVIRONMENT,
"timestamp": datetime.utcnow().isoformat()
}
# API Routes
# 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)
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, banner_routes,
favorite_routes, service_routes, promotion_routes, report_routes,
review_routes, user_routes
review_routes, user_routes, audit_routes, admin_privacy_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")
@@ -110,12 +209,66 @@ 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")
# Note: FastAPI automatically handles 404s for unmatched routes
# This handler is kept for custom 404 responses but may not be needed
# Versioned routes (v1)
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)
app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX)
logger.info("All routes registered successfully")
# Startup event
@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}")
# Shutdown event
@app.on_event("shutdown")
async def shutdown_event():
"""Run on application shutdown"""
logger.info(f"{settings.APP_NAME} shutting down gracefully")
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("PORT", 3000))
uvicorn.run("main:app", host="0.0.0.0", port=port, reload=True)
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
)

View File

@@ -6,6 +6,7 @@ from typing import Optional
import os
from ..config.database import get_db
from ..config.settings import settings
from ..models.user import User
security = HTTPBearer()
@@ -26,7 +27,8 @@ def get_current_user(
)
try:
payload = jwt.decode(token, os.getenv("JWT_SECRET"), algorithms=["HS256"])
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

View File

@@ -0,0 +1,89 @@
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"
def _parse_consent_cookie(raw_value: str | None) -> CookieConsent:
if not raw_value:
return CookieConsent() # Defaults: only necessary = True
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}")
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:
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}")
return response
def is_analytics_allowed(request: Request) -> bool:
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)
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)
if not consent:
return False
return consent.categories.preferences

View File

@@ -3,7 +3,6 @@ from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError
from jose.exceptions import JWTError
import os
import traceback
@@ -96,10 +95,23 @@ async def general_exception_handler(request: Request, exc: Exception):
"""
Handle all other exceptions
"""
# Log error
print(f"Error: {exc}")
if os.getenv("NODE_ENV") == "development":
traceback.print_exc()
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"):
@@ -116,12 +128,17 @@ async def general_exception_handler(request: Request, exc: Exception):
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
if settings.is_development:
response_content["stack"] = traceback.format_exc()
return JSONResponse(
status_code=status_code,
content={
"status": "error",
"message": message,
**({"stack": traceback.format_exc()} if os.getenv("NODE_ENV") == "development" else {})
}
content=response_content
)

View File

@@ -0,0 +1,65 @@
"""
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.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
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
}
)
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

View File

@@ -0,0 +1,57 @@
"""
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
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
if settings.is_production:
security_headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
# Apply headers
for header, value in security_headers.items():
response.headers[header] = value
return response

View File

@@ -0,0 +1,41 @@
"""
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
)
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."
}
)

View File

@@ -13,6 +13,9 @@ from .checkin_checkout import CheckInCheckOut
from .banner import Banner
from .review import Review
from .favorite import Favorite
from .audit_log import AuditLog
from .cookie_policy import CookiePolicy
from .cookie_integration_config import CookieIntegrationConfig
__all__ = [
"Role",
@@ -30,5 +33,8 @@ __all__ = [
"Banner",
"Review",
"Favorite",
"AuditLog",
"CookiePolicy",
"CookieIntegrationConfig",
]

View File

@@ -0,0 +1,28 @@
"""
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"
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"
resource_id = Column(Integer, nullable=True, index=True)
ip_address = Column(String(45), nullable=True) # IPv6 compatible
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
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])

View File

@@ -0,0 +1,30 @@
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"
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
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")

View File

@@ -0,0 +1,31 @@
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")

View File

@@ -0,0 +1,120 @@
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 ..services.privacy_admin_service import privacy_admin_service
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).
"""
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
)
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).
"""
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
)
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).
"""
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
)
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).
"""
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,
)

View File

@@ -0,0 +1,239 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
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.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)"""
try:
query = db.query(AuditLog)
# Apply filters
if 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}%")
)
query = query.filter(search_filter)
# Date range filter
if start_date:
try:
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 = 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
if log.user:
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,
},
},
}
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)"""
try:
query = db.query(AuditLog)
# Date range filter
if start_date:
try:
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 = 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],
},
}
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)"""
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,
}
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}
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -163,7 +163,12 @@ async def get_profile(
"""Get current user profile"""
try:
user = await auth_service.get_profile(db, current_user.id)
return user
return {
"status": "success",
"data": {
"user": user
}
}
except ValueError as e:
if "User not found" in str(e):
raise HTTPException(
@@ -176,6 +181,46 @@ async def get_profile(
)
@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"""
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")
)
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():
status_code = status.HTTP_404_NOT_FOUND
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)}"
)
@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(
request: ForgotPasswordRequest,

View File

@@ -1,9 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request, UploadFile, File
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from typing import Optional
from datetime import datetime
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
@@ -215,6 +218,12 @@ async def delete_banner(
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
# Delete image file if it exists and is a local upload
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
if file_path.exists():
file_path.unlink()
db.delete(banner)
db.commit()
@@ -227,3 +236,51 @@ async def delete_banner(
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)"""
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"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "banners"
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
ext = Path(image.filename).suffix
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()
await f.write(content)
# Return the image URL
image_url = f"/uploads/banners/{filename}"
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"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))

View File

@@ -4,14 +4,21 @@ from sqlalchemy import and_, or_
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
from ..models.user import User
from ..models.booking import Booking, BookingStatus
from ..models.room import Room
from ..models.room_type import RoomType
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
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"])
@@ -255,6 +262,33 @@ async def create_booking(
# Fetch with relations
booking = db.query(Booking).filter(Booking.id == booking.id).first()
# Send booking confirmation email (non-blocking)
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
room = db.query(Room).filter(Room.id == room_id).first()
room_type_name = room.room_type.name if room and room.room_type else "Room"
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=current_user.full_name,
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=check_in.strftime("%B %d, %Y"),
check_out=check_out.strftime("%B %d, %Y"),
num_guests=guest_count,
total_price=float(total_price),
requires_deposit=requires_deposit,
deposit_amount=deposit_amount if requires_deposit else None,
client_url=client_url
)
await send_email(
to=current_user.email,
subject=f"Booking Confirmation - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send booking confirmation email: {e}")
return {
"success": True,
"data": {"booking": booking},
@@ -354,6 +388,23 @@ async def cancel_booking(
booking.status = BookingStatus.cancelled
db.commit()
# Send cancellation email (non-blocking)
try:
client_url = 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:
print(f"Failed to send cancellation email: {e}")
return {
"success": True,
"data": {"booking": booking}
@@ -378,6 +429,7 @@ async def update_booking(
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
old_status = booking.status
status_value = booking_data.get("status")
if status_value:
try:
@@ -388,6 +440,24 @@ async def update_booking(
db.commit()
db.refresh(booking)
# Send status change email if status changed (non-blocking)
if status_value and old_status != booking.status:
try:
client_url = 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=booking.status.value,
client_url=client_url
)
await send_email(
to=booking.user.email if booking.user else None,
subject=f"Booking Status Updated - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send status change email: {e}")
return {
"status": "success",
"message": "Booking updated successfully",

View File

@@ -2,12 +2,16 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
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
from ..models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking
from ..models.booking import Booking, BookingStatus
from ..utils.mailer import send_email
from ..utils.email_templates import payment_confirmation_email_template
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -85,6 +89,63 @@ async def get_payments(
raise HTTPException(status_code=500, detail=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"""
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
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
payments = db.query(Payment).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,
}
if payment.booking:
payment_dict["booking"] = {
"id": payment.booking.id,
"booking_number": payment.booking.booking_number,
}
result.append(payment_dict)
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,
@@ -169,11 +230,32 @@ async def create_payment(
# If marked as paid, update status
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:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
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,
client_url=client_url
)
await send_email(
to=booking.user.email,
subject=f"Payment Confirmed - {booking.booking_number}",
html=email_html
)
except Exception as e:
print(f"Failed to send payment confirmation email: {e}")
return {
"status": "success",
"message": "Payment created successfully",
@@ -209,6 +291,7 @@ async def update_payment_status(
if status_data.get("transaction_id"):
payment.transaction_id = status_data["transaction_id"]
old_status = payment.payment_status
if status_data.get("mark_as_paid"):
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
@@ -216,6 +299,37 @@ async def update_payment_status(
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:
# Refresh booking relationship
payment = db.query(Payment).filter(Payment.id == id).first()
if payment.booking and payment.booking.user:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
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
)
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
if payment.payment_type == PaymentType.deposit and payment.booking:
payment.booking.deposit_paid = True
# Optionally auto-confirm booking if deposit is paid
if payment.booking.status == BookingStatus.pending:
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",

View File

@@ -0,0 +1,111 @@
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 ..services.privacy_admin_service import privacy_admin_service
logger = get_logger(__name__)
router = APIRouter(prefix="/privacy", tags=["privacy"])
@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.
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,
)
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)
"""
config = privacy_admin_service.get_public_privacy_config(db)
return PublicPrivacyConfigResponse(data=config)

View File

@@ -10,6 +10,8 @@ from ..models.user import User
from ..models.booking import Booking, BookingStatus
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"])
@@ -140,6 +142,33 @@ async def get_reports(
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)
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,
@@ -152,6 +181,7 @@ async def get_reports(
"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:
@@ -221,6 +251,171 @@ async def get_dashboard_stats(
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"""
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)
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()
upcoming_bookings = []
for booking in upcoming_bookings_query:
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"total_price": float(booking.total_price) if booking.total_price else 0.0,
}
if booking.room:
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"room_type": {
"name": booking.room.room_type.name if booking.room.room_type else None
}
}
upcoming_bookings.append(booking_dict)
# Recent 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_activity = []
for booking in recent_bookings_query:
activity_type = None
if booking.status == BookingStatus.checked_out:
activity_type = "Check-out"
elif booking.status == BookingStatus.checked_in:
activity_type = "Check-in"
elif booking.status == BookingStatus.confirmed:
activity_type = "Booking Confirmed"
elif booking.status == BookingStatus.pending:
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,
}
if booking.room:
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()
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
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),
}
}
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),

View File

@@ -0,0 +1,68 @@
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.",
)
class CookiePolicySettingsResponse(BaseModel):
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."
)
class CookieIntegrationSettingsResponse(BaseModel):
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

View File

@@ -0,0 +1,70 @@
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).",
)
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.",
)
class CookieConsentResponse(BaseModel):
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

View File

@@ -0,0 +1,82 @@
"""
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
"""
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
)
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
}
)
except Exception as e:
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()

View File

@@ -5,19 +5,29 @@ import secrets
import hashlib
from sqlalchemy.orm import Session
from typing import Optional
import logging
from ..models.user import User
from ..models.refresh_token import RefreshToken
from ..models.password_reset_token import PasswordResetToken
from ..models.role import Role
from ..utils.mailer import send_email
from ..utils.email_templates import (
welcome_email_template,
password_reset_email_template,
password_changed_email_template
)
from ..config.settings import settings
import os
logger = logging.getLogger(__name__)
class AuthService:
def __init__(self):
self.jwt_secret = os.getenv("JWT_SECRET")
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET")
# 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")
@@ -70,6 +80,7 @@ class AuthService:
"name": user.full_name,
"email": user.email,
"phone": user.phone,
"avatar": user.avatar,
"role": user.role.name if user.role else "customer",
"createdAt": user.created_at.isoformat() if user.created_at else None,
"updatedAt": user.updated_at.isoformat() if user.updated_at else None,
@@ -115,33 +126,16 @@ class AuthService:
# Send welcome email (non-blocking)
try:
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
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)
await send_email(
to=user.email,
subject="Welcome to Hotel Booking",
html=f"""
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #4F46E5;">Welcome {user.full_name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{user.email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
</ul>
</div>
<p>
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
</a>
</p>
</div>
"""
html=email_html
)
logger.info(f"Welcome email sent successfully to {user.email}")
except Exception as e:
print(f"Failed to send welcome email: {e}")
logger.error(f"Failed to send welcome email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
return {
"user": self.format_user_response(user),
@@ -170,14 +164,42 @@ class AuthService:
expiry_days = 7 if remember_me else 1
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
# Save refresh token
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
expires_at=expires_at
)
db.add(refresh_token)
db.commit()
# 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
# Save new refresh token
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
expires_at=expires_at
)
db.add(refresh_token)
db.commit()
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"]
).delete()
db.flush()
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
expires_at=expires_at
)
db.add(refresh_token)
db.commit()
except Exception as retry_error:
db.rollback()
logger.error(f"Retry failed for refresh token: {str(retry_error)}", exc_info=True)
raise ValueError("Failed to create session. Please try again.")
return {
"user": self.format_user_response(user),
@@ -235,6 +257,53 @@ class AuthService:
return self.format_user_response(user)
async def update_profile(
self,
db: Session,
user_id: int,
full_name: Optional[str] = None,
email: Optional[str] = None,
phone_number: Optional[str] = None,
password: Optional[str] = None,
current_password: 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
).first()
if existing_user:
raise ValueError("Email already registered")
user.email = email
if phone_number is not None:
user.phone = phone_number
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)
@@ -270,22 +339,41 @@ class AuthService:
db.commit()
# Build reset URL
client_url = os.getenv("CLIENT_URL", "http://localhost:5173")
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()
await send_email(
to=user.email,
subject="Reset password - Hotel Booking",
html=f"""
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password (expires in 1 hour):</p>
<p><a href="{reset_url}">{reset_url}</a></p>
"""
html=email_html,
text=plain_text
)
logger.info(f"Password reset email sent successfully to {user.email} with reset URL: {reset_url}")
except Exception as e:
print(f"Failed to send reset email: {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,
@@ -332,13 +420,16 @@ class AuthService:
# 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)
await send_email(
to=user.email,
subject="Password Changed",
html=f"<p>The password for account {user.email} has been changed successfully.</p>"
html=email_html
)
logger.info(f"Password changed confirmation email sent successfully to {user.email}")
except Exception as e:
print(f"Failed to send confirmation email: {e}")
logger.error(f"Failed to send password changed confirmation email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
return {
"success": True,

View File

@@ -0,0 +1,98 @@
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,
)
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()
db.refresh(policy)
return policy
@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,
)
@staticmethod
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
policy.preferences_enabled = settings.preferences_enabled
if updated_by:
policy.updated_by_id = updated_by.id
db.add(policy)
db.commit()
db.refresh(policy)
return policy
# Integrations
@staticmethod
def get_or_create_integrations(db: Session) -> CookieIntegrationConfig:
config = db.query(CookieIntegrationConfig).first()
if config:
return config
config = CookieIntegrationConfig()
db.add(config)
db.commit()
db.refresh(config)
return config
@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,
)
@staticmethod
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
if updated_by:
cfg.updated_by_id = updated_by.id
db.add(cfg)
db.commit()
db.refresh(cfg)
return cfg
@staticmethod
def get_public_privacy_config(db: Session) -> PublicPrivacyConfig:
policy = PrivacyAdminService.get_policy_settings(db)
integrations = PrivacyAdminService.get_integration_settings(db)
return PublicPrivacyConfig(policy=policy, integrations=integrations)
privacy_admin_service = PrivacyAdminService()

View File

@@ -0,0 +1,261 @@
"""
Email templates for various notifications
"""
from datetime import datetime
from typing import Optional
def get_base_template(content: str, title: str = "Hotel Booking") -> str:
"""Base HTML email template"""
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table role="presentation" style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 20px 0; text-align: center; background-color: #4F46E5;">
<h1 style="color: #ffffff; margin: 0;">Hotel Booking</h1>
</td>
</tr>
<tr>
<td style="padding: 40px 20px; background-color: #ffffff;">
<table role="presentation" style="width: 100%; max-width: 600px; margin: 0 auto;">
<tr>
<td>
{content}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 20px; text-align: center; background-color: #f4f4f4; color: #666666; font-size: 12px;">
<p style="margin: 0;">This is an automated email. Please do not reply.</p>
<p style="margin: 5px 0 0 0;"{datetime.now().year} Hotel Booking. All rights reserved.</p>
</td>
</tr>
</table>
</body>
</html>
"""
def welcome_email_template(name: str, email: str, client_url: str) -> str:
"""Welcome email template for new registrations"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Welcome {name}!</h2>
<p>Thank you for registering an account at <strong>Hotel Booking</strong>.</p>
<p>Your account has been successfully created with email: <strong>{email}</strong></p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>You can:</strong></p>
<ul style="margin-top: 10px;">
<li>Search and book hotel rooms</li>
<li>Manage your bookings</li>
<li>Update your personal information</li>
</ul>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/login" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Login Now
</a>
</p>
"""
return get_base_template(content, "Welcome to Hotel Booking")
def password_reset_email_template(reset_url: str) -> str:
"""Password reset email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Reset Request</h2>
<p>You (or someone) has requested to reset your password.</p>
<p>Click the link below to reset your password. This link will expire in 1 hour:</p>
<p style="text-align: center; margin: 30px 0;">
<a href="{reset_url}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
Reset Password
</a>
</p>
<p style="color: #666666; font-size: 14px;">If you did not request this, please ignore this email.</p>
"""
return get_base_template(content, "Password Reset")
def password_changed_email_template(email: str) -> str:
"""Password changed confirmation email template"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Password Changed Successfully</h2>
<p>The password for account <strong>{email}</strong> has been changed successfully.</p>
<p>If you did not make this change, please contact our support team immediately.</p>
"""
return get_base_template(content, "Password Changed")
def booking_confirmation_email_template(
booking_number: str,
guest_name: str,
room_number: str,
room_type: str,
check_in: str,
check_out: str,
num_guests: int,
total_price: float,
requires_deposit: bool,
deposit_amount: Optional[float] = None,
client_url: str = "http://localhost:5173"
) -> str:
"""Booking confirmation email template"""
deposit_info = ""
if requires_deposit and deposit_amount:
deposit_info = f"""
<div style="background-color: #FEF3C7; border-left: 4px solid #F59E0B; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; font-weight: bold; color: #92400E;">⚠️ Deposit Required</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Please pay a deposit of <strong>€{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 5px 0 0 0; color: #78350F;">Your booking will be confirmed once the deposit is received.</p>
</div>
"""
content = f"""
<h2 style="color: #4F46E5; margin-top: 0;">Booking Confirmation</h2>
<p>Dear {guest_name},</p>
<p>Thank you for your booking! We have received your reservation request.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #1F2937;">Booking Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Room:</td>
<td style="padding: 8px 0; color: #1F2937;">{room_type} - Room {room_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-in:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_in}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Check-out:</td>
<td style="padding: 8px 0; color: #1F2937;">{check_out}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Guests:</td>
<td style="padding: 8px 0; color: #1F2937;">{num_guests}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Total Price:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">€{total_price:.2f}</td>
</tr>
</table>
</div>
{deposit_info}
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
View Booking Details
</a>
</p>
"""
return get_base_template(content, "Booking Confirmation")
def payment_confirmation_email_template(
booking_number: str,
guest_name: str,
amount: float,
payment_method: str,
transaction_id: Optional[str] = None,
client_url: str = "http://localhost:5173"
) -> str:
"""Payment confirmation email template"""
transaction_info = ""
if transaction_id:
transaction_info = f"""
<tr>
<td style="padding: 8px 0; color: #6B7280;">Transaction ID:</td>
<td style="padding: 8px 0; color: #1F2937; font-family: monospace;">{transaction_id}</td>
</tr>
"""
content = f"""
<h2 style="color: #10B981; margin-top: 0;">Payment Received</h2>
<p>Dear {guest_name},</p>
<p>We have successfully received your payment for booking <strong>{booking_number}</strong>.</p>
<div style="background-color: #ECFDF5; border-left: 4px solid #10B981; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0; color: #065F46;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Amount:</td>
<td style="padding: 8px 0; font-weight: bold; color: #065F46; font-size: 18px;">€{amount:.2f}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">Payment Method:</td>
<td style="padding: 8px 0; color: #1F2937;">{payment_method}</td>
</tr>
{transaction_info}
</table>
</div>
<p>Your booking is now confirmed. We look forward to hosting you!</p>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: #10B981; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
View Booking
</a>
</p>
"""
return get_base_template(content, "Payment Confirmation")
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"),
"cancelled": ("#EF4444", "Cancelled"),
"checked_in": ("#3B82F6", "Checked In"),
"checked_out": ("#8B5CF6", "Checked Out"),
}
color, status_text = status_colors.get(status.lower(), ("#6B7280", status.title()))
content = f"""
<h2 style="color: {color}; margin-top: 0;">Booking Status Updated</h2>
<p>Dear {guest_name},</p>
<p>Your booking status has been updated.</p>
<div style="background-color: #F3F4F6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; color: #6B7280; width: 40%;">Booking Number:</td>
<td style="padding: 8px 0; font-weight: bold; color: #1F2937;">{booking_number}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #6B7280;">New Status:</td>
<td style="padding: 8px 0; font-weight: bold; color: {color}; font-size: 18px;">{status_text}</td>
</tr>
</table>
</div>
<p style="text-align: center; margin-top: 30px;">
<a href="{client_url}/bookings/{booking_number}" style="background-color: {color}; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block;">
View Booking
</a>
</p>
"""
return get_base_template(content, f"Booking {status_text}")

View File

@@ -2,47 +2,96 @@ import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
import os
import logging
from ..config.settings import settings
logger = logging.getLogger(__name__)
async def send_email(to: str, subject: str, html: str = None, text: str = None):
"""
Send email using SMTP
Requires MAIL_HOST, MAIL_USER and MAIL_PASS to be set in env.
Uses settings from config/settings.py with fallback to environment variables
"""
# Require SMTP credentials to be present
mail_host = os.getenv("MAIL_HOST")
mail_user = os.getenv("MAIL_USER")
mail_pass = os.getenv("MAIL_PASS")
try:
# Get SMTP settings from settings.py, fallback to 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"
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")
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"
from_header = f"{from_name} <{from_address}>"
if not (mail_host and mail_user and mail_pass):
raise ValueError(
"SMTP mailer not configured. Set MAIL_HOST, MAIL_USER and MAIL_PASS in env."
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."
logger.error(error_msg)
raise ValueError(error_msg)
# Create message
message = MIMEMultipart("alternative")
message["From"] = from_header
message["To"] = to
message["Subject"] = subject
if text:
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 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 465: use SSL/TLS (use_tls=True, start_tls=False)
# For port 25: plain (usually not used for authenticated sending)
if mail_port == 465 or mail_secure:
# 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,
)
mail_port = int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
client_url = os.getenv("CLIENT_URL", "example.com")
from_address = os.getenv("MAIL_FROM", f"no-reply@{client_url.replace('https://', '').replace('http://', '')}")
# Create message
message = MIMEMultipart("alternative")
message["From"] = from_address
message["To"] = to
message["Subject"] = subject
if text:
message.attach(MIMEText(text, "plain"))
if html:
message.attach(MIMEText(html, "html"))
# Send email
await aiosmtplib.send(
message,
hostname=mail_host,
port=mail_port,
use_tls=not mail_secure and mail_port == 587,
start_tls=not mail_secure and mail_port == 587,
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}")
finally:
await smtp_client.quit()
except Exception as e:
error_msg = f"Failed to send email to {to}: {type(e).__name__}: {str(e)}"
logger.error(error_msg, exc_info=True)
raise