This commit is contained in:
Iliyan Angelov
2025-11-21 01:20:51 +02:00
parent a38ab4fa82
commit 6f85b8cf17
242 changed files with 7154 additions and 14492 deletions

View File

@@ -1,2 +0,0 @@
# Hotel Booking Server Package

View File

@@ -4,54 +4,25 @@ from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import QueuePool
from .settings import settings
from .logging_config import get_logger
logger = get_logger(__name__)
# Database configuration using settings
DATABASE_URL = settings.database_url
engine = create_engine(DATABASE_URL, poolclass=QueuePool, pool_pre_ping=True, pool_recycle=3600, pool_size=10, max_overflow=20, echo=settings.is_development, future=True, connect_args={'charset': 'utf8mb4', 'connect_timeout': 10})
# Enhanced engine configuration for enterprise use
engine = create_engine(
DATABASE_URL,
poolclass=QueuePool,
pool_pre_ping=True, # Verify connections before using
pool_recycle=3600, # Recycle connections after 1 hour
pool_size=10, # Number of connections to maintain
max_overflow=20, # Additional connections beyond pool_size
echo=settings.is_development, # Log SQL queries in development
future=True, # Use SQLAlchemy 2.0 style
connect_args={
"charset": "utf8mb4",
"connect_timeout": 10
}
)
# Event listeners for connection pool monitoring
@event.listens_for(engine, "connect")
@event.listens_for(engine, 'connect')
def set_sqlite_pragma(dbapi_conn, connection_record):
"""Set connection-level settings"""
logger.debug("New database connection established")
logger.debug('New database connection established')
@event.listens_for(engine, "checkout")
@event.listens_for(engine, 'checkout')
def receive_checkout(dbapi_conn, connection_record, connection_proxy):
"""Log connection checkout"""
logger.debug("Connection checked out from pool")
logger.debug('Connection checked out from pool')
@event.listens_for(engine, "checkin")
@event.listens_for(engine, 'checkin')
def receive_checkin(dbapi_conn, connection_record):
"""Log connection checkin"""
logger.debug("Connection returned to pool")
logger.debug('Connection returned to pool')
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Dependency to get DB session
def get_db():
"""
Dependency for getting database session.
Automatically handles session lifecycle.
"""
db = SessionLocal()
try:
yield db
@@ -59,5 +30,4 @@ def get_db():
db.rollback()
raise
finally:
db.close()
db.close()

View File

@@ -1,6 +1,3 @@
"""
Enterprise-grade structured logging configuration
"""
import logging
import sys
from logging.handlers import RotatingFileHandler
@@ -8,89 +5,32 @@ from pathlib import Path
from typing import Optional
from .settings import settings
def setup_logging(
log_level: Optional[str] = None,
log_file: Optional[str] = None,
enable_file_logging: bool = True
) -> logging.Logger:
"""
Setup structured logging with file and console handlers
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_file: Path to log file
enable_file_logging: Whether to enable file logging
Returns:
Configured root logger
"""
# Get configuration from settings
def setup_logging(log_level: Optional[str]=None, log_file: Optional[str]=None, enable_file_logging: bool=True) -> logging.Logger:
level = log_level or settings.LOG_LEVEL
log_file_path = log_file or settings.LOG_FILE
# Convert string level to logging constant
numeric_level = getattr(logging, level.upper(), logging.INFO)
# Create logs directory if it doesn't exist
if enable_file_logging and log_file_path:
log_path = Path(log_file_path)
log_path.parent.mkdir(parents=True, exist_ok=True)
# Create formatter with structured format
detailed_formatter = logging.Formatter(
fmt='%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
simple_formatter = logging.Formatter(
fmt='%(asctime)s | %(levelname)-8s | %(message)s',
datefmt='%H:%M:%S'
)
# Configure root logger
detailed_formatter = logging.Formatter(fmt='%(asctime)s | %(levelname)-8s | %(name)s | %(funcName)s:%(lineno)d | %(message)s', datefmt='%Y-%m-%d %H:%M:%S')
simple_formatter = logging.Formatter(fmt='%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%H:%M:%S')
root_logger = logging.getLogger()
root_logger.setLevel(numeric_level)
# Remove existing handlers
root_logger.handlers.clear()
# Console handler (always enabled)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(numeric_level)
console_handler.setFormatter(simple_formatter if settings.is_development else detailed_formatter)
root_logger.addHandler(console_handler)
# File handler (rotating) - Disabled in development to avoid file watcher issues
if enable_file_logging and log_file_path and not settings.is_development:
file_handler = RotatingFileHandler(
log_file_path,
maxBytes=settings.LOG_MAX_BYTES,
backupCount=settings.LOG_BACKUP_COUNT,
encoding='utf-8'
)
if enable_file_logging and log_file_path and (not settings.is_development):
file_handler = RotatingFileHandler(log_file_path, maxBytes=settings.LOG_MAX_BYTES, backupCount=settings.LOG_BACKUP_COUNT, encoding='utf-8')
file_handler.setLevel(numeric_level)
file_handler.setFormatter(detailed_formatter)
root_logger.addHandler(file_handler)
# Set levels for third-party loggers
logging.getLogger("uvicorn").setLevel(logging.INFO)
logging.getLogger("uvicorn.access").setLevel(logging.INFO if settings.is_development else logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
logging.getLogger("slowapi").setLevel(logging.WARNING)
logging.getLogger('uvicorn').setLevel(logging.INFO)
logging.getLogger('uvicorn.access').setLevel(logging.INFO if settings.is_development else logging.WARNING)
logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING)
logging.getLogger('slowapi').setLevel(logging.WARNING)
return root_logger
def get_logger(name: str) -> logging.Logger:
"""
Get a logger instance with the given name
Args:
name: Logger name (typically __name__)
Returns:
Logger instance
"""
return logging.getLogger(name)
return logging.getLogger(name)

View File

@@ -1,129 +1,72 @@
"""
Enterprise-grade configuration management using Pydantic Settings
"""
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from typing import List
import os
class Settings(BaseSettings):
"""Application settings with environment variable support"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore"
)
# Application
APP_NAME: str = Field(default="Hotel Booking API", description="Application name")
APP_VERSION: str = Field(default="1.0.0", description="Application version")
ENVIRONMENT: str = Field(default="development", description="Environment: development, staging, production")
DEBUG: bool = Field(default=False, description="Debug mode")
API_V1_PREFIX: str = Field(default="/api/v1", description="API v1 prefix")
# Server
HOST: str = Field(default="0.0.0.0", description="Server host")
PORT: int = Field(default=8000, description="Server port")
# Database
DB_USER: str = Field(default="root", description="Database user")
DB_PASS: str = Field(default="", description="Database password")
DB_NAME: str = Field(default="hotel_db", description="Database name")
DB_HOST: str = Field(default="localhost", description="Database host")
DB_PORT: str = Field(default="3306", description="Database port")
# Security
JWT_SECRET: str = Field(default="dev-secret-key-change-in-production-12345", description="JWT secret key")
JWT_ALGORITHM: str = Field(default="HS256", description="JWT algorithm")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description="JWT access token expiration in minutes")
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description="JWT refresh token expiration in days")
# CORS
CLIENT_URL: str = Field(default="http://localhost:5173", description="Frontend client URL")
CORS_ORIGINS: List[str] = Field(
default_factory=lambda: [
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173"
],
description="Allowed CORS origins"
)
# Rate Limiting
RATE_LIMIT_ENABLED: bool = Field(default=True, description="Enable rate limiting")
RATE_LIMIT_PER_MINUTE: int = Field(default=60, description="Requests per minute per IP")
# Logging
LOG_LEVEL: str = Field(default="INFO", description="Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL")
LOG_FILE: str = Field(default="logs/app.log", description="Log file path")
LOG_MAX_BYTES: int = Field(default=10485760, description="Max log file size (10MB)")
LOG_BACKUP_COUNT: int = Field(default=5, description="Number of backup log files")
# Email
SMTP_HOST: str = Field(default="smtp.gmail.com", description="SMTP host")
SMTP_PORT: int = Field(default=587, description="SMTP port")
SMTP_USER: str = Field(default="", description="SMTP username")
SMTP_PASSWORD: str = Field(default="", description="SMTP password")
SMTP_FROM_EMAIL: str = Field(default="", description="From email address")
SMTP_FROM_NAME: str = Field(default="Hotel Booking", description="From name")
# File Upload
UPLOAD_DIR: str = Field(default="uploads", description="Upload directory")
MAX_UPLOAD_SIZE: int = Field(default=5242880, description="Max upload size in bytes (5MB)")
ALLOWED_EXTENSIONS: List[str] = Field(
default_factory=lambda: ["jpg", "jpeg", "png", "gif", "webp"],
description="Allowed file extensions"
)
# Redis (for caching)
REDIS_ENABLED: bool = Field(default=False, description="Enable Redis caching")
REDIS_HOST: str = Field(default="localhost", description="Redis host")
REDIS_PORT: int = Field(default=6379, description="Redis port")
REDIS_DB: int = Field(default=0, description="Redis database number")
REDIS_PASSWORD: str = Field(default="", description="Redis password")
# Request Timeout
REQUEST_TIMEOUT: int = Field(default=30, description="Request timeout in seconds")
# Health Check
HEALTH_CHECK_INTERVAL: int = Field(default=30, description="Health check interval in seconds")
# Stripe Payment Gateway
STRIPE_SECRET_KEY: str = Field(default="", description="Stripe secret key")
STRIPE_PUBLISHABLE_KEY: str = Field(default="", description="Stripe publishable key")
STRIPE_WEBHOOK_SECRET: str = Field(default="", description="Stripe webhook secret")
# PayPal Payment Gateway
PAYPAL_CLIENT_ID: str = Field(default="", description="PayPal client ID")
PAYPAL_CLIENT_SECRET: str = Field(default="", description="PayPal client secret")
PAYPAL_MODE: str = Field(default="sandbox", description="PayPal mode: sandbox or live")
model_config = SettingsConfigDict(env_file='.env', env_file_encoding='utf-8', case_sensitive=False, extra='ignore')
APP_NAME: str = Field(default='Hotel Booking API', description='Application name')
APP_VERSION: str = Field(default='1.0.0', description='Application version')
ENVIRONMENT: str = Field(default='development', description='Environment: development, staging, production')
DEBUG: bool = Field(default=False, description='Debug mode')
API_V1_PREFIX: str = Field(default='/api/v1', description='API v1 prefix')
HOST: str = Field(default='0.0.0.0', description='Server host')
PORT: int = Field(default=8000, description='Server port')
DB_USER: str = Field(default='root', description='Database user')
DB_PASS: str = Field(default='', description='Database password')
DB_NAME: str = Field(default='hotel_db', description='Database name')
DB_HOST: str = Field(default='localhost', description='Database host')
DB_PORT: str = Field(default='3306', description='Database port')
JWT_SECRET: str = Field(default='dev-secret-key-change-in-production-12345', description='JWT secret key')
JWT_ALGORITHM: str = Field(default='HS256', description='JWT algorithm')
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes')
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description='JWT refresh token expiration in days')
CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL')
CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins')
RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting')
RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP')
LOG_LEVEL: str = Field(default='INFO', description='Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL')
LOG_FILE: str = Field(default='logs/app.log', description='Log file path')
LOG_MAX_BYTES: int = Field(default=10485760, description='Max log file size (10MB)')
LOG_BACKUP_COUNT: int = Field(default=5, description='Number of backup log files')
SMTP_HOST: str = Field(default='smtp.gmail.com', description='SMTP host')
SMTP_PORT: int = Field(default=587, description='SMTP port')
SMTP_USER: str = Field(default='', description='SMTP username')
SMTP_PASSWORD: str = Field(default='', description='SMTP password')
SMTP_FROM_EMAIL: str = Field(default='', description='From email address')
SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name')
UPLOAD_DIR: str = Field(default='uploads', description='Upload directory')
MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)')
ALLOWED_EXTENSIONS: List[str] = Field(default_factory=lambda: ['jpg', 'jpeg', 'png', 'gif', 'webp'], description='Allowed file extensions')
REDIS_ENABLED: bool = Field(default=False, description='Enable Redis caching')
REDIS_HOST: str = Field(default='localhost', description='Redis host')
REDIS_PORT: int = Field(default=6379, description='Redis port')
REDIS_DB: int = Field(default=0, description='Redis database number')
REDIS_PASSWORD: str = Field(default='', description='Redis password')
REQUEST_TIMEOUT: int = Field(default=30, description='Request timeout in seconds')
HEALTH_CHECK_INTERVAL: int = Field(default=30, description='Health check interval in seconds')
STRIPE_SECRET_KEY: str = Field(default='', description='Stripe secret key')
STRIPE_PUBLISHABLE_KEY: str = Field(default='', description='Stripe publishable key')
STRIPE_WEBHOOK_SECRET: str = Field(default='', description='Stripe webhook secret')
PAYPAL_CLIENT_ID: str = Field(default='', description='PayPal client ID')
PAYPAL_CLIENT_SECRET: str = Field(default='', description='PayPal client secret')
PAYPAL_MODE: str = Field(default='sandbox', description='PayPal mode: sandbox or live')
@property
def database_url(self) -> str:
"""Construct database URL"""
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
return f'mysql+pymysql://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}'
@property
def is_production(self) -> bool:
"""Check if running in production"""
return self.ENVIRONMENT.lower() == "production"
return self.ENVIRONMENT.lower() == 'production'
@property
def is_development(self) -> bool:
"""Check if running in development"""
return self.ENVIRONMENT.lower() == "development"
return self.ENVIRONMENT.lower() == 'development'
@property
def redis_url(self) -> str:
"""Construct Redis URL"""
if self.REDIS_PASSWORD:
return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
# Global settings instance
settings = Settings()
return f'redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}'
return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}'
settings = Settings()

View File

@@ -11,222 +11,111 @@ from slowapi.errors import RateLimitExceeded
from pathlib import Path
from datetime import datetime
import sys
# Import configuration and logging FIRST
from .config.settings import settings
from .config.logging_config import setup_logging, get_logger
from .config.database import engine, Base, get_db
from . import models # noqa: F401 - ensure models are imported so tables are created
from . import models
from sqlalchemy.orm import Session
# Setup logging before anything else
logger = setup_logging()
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode")
# Import middleware
from .middleware.error_handler import (
validation_exception_handler,
integrity_error_handler,
jwt_error_handler,
http_exception_handler,
general_exception_handler
)
logger.info(f'Starting {settings.APP_NAME} v{settings.APP_VERSION} in {settings.ENVIRONMENT} mode')
from .middleware.error_handler import validation_exception_handler, integrity_error_handler, jwt_error_handler, http_exception_handler, general_exception_handler
from .middleware.request_id import RequestIDMiddleware
from .middleware.security import SecurityHeadersMiddleware
from .middleware.timeout import TimeoutMiddleware
from .middleware.cookie_consent import CookieConsentMiddleware
# Create database tables (for development, migrations should be used in production)
if settings.is_development:
logger.info("Creating database tables (development mode)")
logger.info('Creating database tables (development mode)')
Base.metadata.create_all(bind=engine)
else:
# Ensure new tables exist even if full migrations haven't been run yet.
try:
from .models.cookie_policy import CookiePolicy
from .models.cookie_integration_config import CookieIntegrationConfig
from .models.page_content import PageContent
logger.info("Ensuring required tables exist")
logger.info('Ensuring required tables exist')
CookiePolicy.__table__.create(bind=engine, checkfirst=True)
CookieIntegrationConfig.__table__.create(bind=engine, checkfirst=True)
PageContent.__table__.create(bind=engine, checkfirst=True)
except Exception as e:
logger.error(f"Failed to ensure required tables exist: {e}")
logger.error(f'Failed to ensure required tables exist: {e}')
from .routes import auth_routes
from .routes import privacy_routes
# Initialize FastAPI app
app = FastAPI(
title=settings.APP_NAME,
description="Enterprise-grade Hotel Booking API",
version=settings.APP_VERSION,
docs_url="/api/docs" if not settings.is_production else None,
redoc_url="/api/redoc" if not settings.is_production else None,
openapi_url="/api/openapi.json" if not settings.is_production else None
)
# Add middleware in order (order matters!)
# 1. Request ID middleware (first to add request ID)
app = FastAPI(title=settings.APP_NAME, description='Enterprise-grade Hotel Booking API', version=settings.APP_VERSION, docs_url='/api/docs' if not settings.is_production else None, redoc_url='/api/redoc' if not settings.is_production else None, openapi_url='/api/openapi.json' if not settings.is_production else None)
app.add_middleware(RequestIDMiddleware)
# 2. Cookie consent middleware (makes consent available on request.state)
app.add_middleware(CookieConsentMiddleware)
# 3. Timeout middleware
if settings.REQUEST_TIMEOUT > 0:
app.add_middleware(TimeoutMiddleware)
# 4. Security headers middleware
app.add_middleware(SecurityHeadersMiddleware)
# Rate limiting
if settings.RATE_LIMIT_ENABLED:
limiter = Limiter(
key_func=get_remote_address,
default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"]
)
limiter = Limiter(key_func=get_remote_address, default_limits=[f'{settings.RATE_LIMIT_PER_MINUTE}/minute'])
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
logger.info(f"Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute")
# CORS configuration
logger.info(f'Rate limiting enabled: {settings.RATE_LIMIT_PER_MINUTE} requests/minute')
if settings.is_development:
# For development, use regex to allow any localhost port
app.add_middleware(
CORSMiddleware,
allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
logger.info("CORS configured for development (allowing localhost)")
app.add_middleware(CORSMiddleware, allow_origin_regex='http://(localhost|127\\.0\\.0\\.1)(:\\d+)?', allow_credentials=True, allow_methods=['*'], allow_headers=['*'])
logger.info('CORS configured for development (allowing localhost)')
else:
# Production: use specific origins
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["*"],
)
logger.info(f"CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins")
# Serve static files (uploads)
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*'])
logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins')
uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR
uploads_dir.mkdir(exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
# Exception handlers
app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads')
app.add_exception_handler(HTTPException, http_exception_handler)
app.add_exception_handler(RequestValidationError, validation_exception_handler)
app.add_exception_handler(IntegrityError, integrity_error_handler)
app.add_exception_handler(JWTError, jwt_error_handler)
app.add_exception_handler(Exception, general_exception_handler)
# Enhanced Health check with database connectivity
@app.get("/health", tags=["health"])
@app.get("/api/health", tags=["health"])
async def health_check(db: Session = Depends(get_db)):
"""
Enhanced health check endpoint with database connectivity test
Available at both /health and /api/health for consistency
"""
health_status = {
"status": "healthy",
"timestamp": datetime.utcnow().isoformat(),
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"environment": settings.ENVIRONMENT,
"checks": {
"api": "ok",
"database": "unknown"
}
}
# Check database connectivity
@app.get('/health', tags=['health'])
@app.get('/api/health', tags=['health'])
async def health_check(db: Session=Depends(get_db)):
health_status = {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat(), 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'checks': {'api': 'ok', 'database': 'unknown'}}
try:
from sqlalchemy import text
db.execute(text("SELECT 1"))
health_status["checks"]["database"] = "ok"
db.execute(text('SELECT 1'))
health_status['checks']['database'] = 'ok'
except OperationalError as e:
health_status["status"] = "unhealthy"
health_status["checks"]["database"] = "error"
health_status["error"] = str(e)
logger.error(f"Database health check failed: {str(e)}")
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=health_status
)
health_status['status'] = 'unhealthy'
health_status['checks']['database'] = 'error'
health_status['error'] = str(e)
logger.error(f'Database health check failed: {str(e)}')
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status)
except Exception as e:
health_status["status"] = "unhealthy"
health_status["checks"]["database"] = "error"
health_status["error"] = str(e)
logger.error(f"Health check failed: {str(e)}")
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content=health_status
)
health_status['status'] = 'unhealthy'
health_status['checks']['database'] = 'error'
health_status['error'] = str(e)
logger.error(f'Health check failed: {str(e)}')
return JSONResponse(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, content=health_status)
return health_status
# Metrics endpoint (basic)
@app.get("/metrics", tags=["monitoring"])
@app.get('/metrics', tags=['monitoring'])
async def metrics():
"""
Basic metrics endpoint (can be extended with Prometheus or similar)
"""
return {
"status": "success",
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"environment": settings.ENVIRONMENT,
"timestamp": datetime.utcnow().isoformat()
}
# API Routes with versioning
# Legacy routes (maintain backward compatibility)
app.include_router(auth_routes.router, prefix="/api")
app.include_router(privacy_routes.router, prefix="/api")
# Versioned API routes (v1)
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
app.include_router(auth_routes.router, prefix='/api')
app.include_router(privacy_routes.router, prefix='/api')
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(privacy_routes.router, prefix=settings.API_V1_PREFIX)
# Import and include other routes
from .routes import (
room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes,
review_routes, user_routes, audit_routes, admin_privacy_routes,
system_settings_routes, contact_routes, page_content_routes,
home_routes, about_routes, contact_content_routes, footer_routes
)
# Legacy routes (maintain backward compatibility)
app.include_router(room_routes.router, prefix="/api")
app.include_router(booking_routes.router, prefix="/api")
app.include_router(payment_routes.router, prefix="/api")
app.include_router(invoice_routes.router, prefix="/api")
app.include_router(banner_routes.router, prefix="/api")
app.include_router(favorite_routes.router, prefix="/api")
app.include_router(service_routes.router, prefix="/api")
app.include_router(service_booking_routes.router, prefix="/api")
app.include_router(promotion_routes.router, prefix="/api")
app.include_router(report_routes.router, prefix="/api")
app.include_router(review_routes.router, prefix="/api")
app.include_router(user_routes.router, prefix="/api")
app.include_router(audit_routes.router, prefix="/api")
app.include_router(admin_privacy_routes.router, prefix="/api")
app.include_router(system_settings_routes.router, prefix="/api")
app.include_router(contact_routes.router, prefix="/api")
app.include_router(home_routes.router, prefix="/api")
app.include_router(about_routes.router, prefix="/api")
app.include_router(contact_content_routes.router, prefix="/api")
app.include_router(footer_routes.router, prefix="/api")
# Versioned routes (v1)
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes
app.include_router(room_routes.router, prefix='/api')
app.include_router(booking_routes.router, prefix='/api')
app.include_router(payment_routes.router, prefix='/api')
app.include_router(invoice_routes.router, prefix='/api')
app.include_router(banner_routes.router, prefix='/api')
app.include_router(favorite_routes.router, prefix='/api')
app.include_router(service_routes.router, prefix='/api')
app.include_router(service_booking_routes.router, prefix='/api')
app.include_router(promotion_routes.router, prefix='/api')
app.include_router(report_routes.router, prefix='/api')
app.include_router(review_routes.router, prefix='/api')
app.include_router(user_routes.router, prefix='/api')
app.include_router(audit_routes.router, prefix='/api')
app.include_router(admin_privacy_routes.router, prefix='/api')
app.include_router(system_settings_routes.router, prefix='/api')
app.include_router(contact_routes.router, prefix='/api')
app.include_router(home_routes.router, prefix='/api')
app.include_router(about_routes.router, prefix='/api')
app.include_router(contact_content_routes.router, prefix='/api')
app.include_router(footer_routes.router, prefix='/api')
app.include_router(chat_routes.router, prefix='/api')
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX)
@@ -247,52 +136,24 @@ app.include_router(home_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(about_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(contact_content_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(footer_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix="/api")
app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix='/api')
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
logger.info('All routes registered successfully')
logger.info("All routes registered successfully")
# Startup event
@app.on_event("startup")
@app.on_event('startup')
async def startup_event():
"""Run on application startup"""
logger.info(f"{settings.APP_NAME} started successfully")
logger.info(f"Environment: {settings.ENVIRONMENT}")
logger.info(f"Debug mode: {settings.DEBUG}")
logger.info(f"API version: {settings.API_V1_PREFIX}")
logger.info(f'{settings.APP_NAME} started successfully')
logger.info(f'Environment: {settings.ENVIRONMENT}')
logger.info(f'Debug mode: {settings.DEBUG}')
logger.info(f'API version: {settings.API_V1_PREFIX}')
# Shutdown event
@app.on_event("shutdown")
@app.on_event('shutdown')
async def shutdown_event():
"""Run on application shutdown"""
logger.info(f"{settings.APP_NAME} shutting down gracefully")
if __name__ == "__main__":
logger.info(f'{settings.APP_NAME} shutting down gracefully')
if __name__ == '__main__':
import uvicorn
from pathlib import Path
# Only watch the src directory to avoid watching logs, uploads, etc.
base_dir = Path(__file__).parent.parent
src_dir = str(base_dir / "src")
uvicorn.run(
"src.main:app",
host=settings.HOST,
port=settings.PORT,
reload=settings.is_development,
log_level=settings.LOG_LEVEL.lower(),
reload_dirs=[src_dir] if settings.is_development else None,
reload_excludes=[
"*.log",
"*.pyc",
"*.pyo",
"*.pyd",
"__pycache__",
"**/__pycache__/**",
"*.db",
"*.sqlite",
"*.sqlite3"
],
reload_delay=0.5 # Increase delay to reduce false positives
)
src_dir = str(base_dir / 'src')
uvicorn.run('src.main:app', host=settings.HOST, port=settings.PORT, reload=settings.is_development, log_level=settings.LOG_LEVEL.lower(), reload_dirs=[src_dir] if settings.is_development else None, reload_excludes=['*.log', '*.pyc', '*.pyo', '*.pyd', '__pycache__', '**/__pycache__/**', '*.db', '*.sqlite', '*.sqlite3'], reload_delay=0.5)

View File

@@ -4,71 +4,56 @@ from jose import JWTError, jwt
from sqlalchemy.orm import Session
from typing import Optional
import os
from ..config.database import get_db
from ..config.settings import settings
from ..models.user import User
from ..models.role import Role
security = HTTPBearer()
def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""
Verify JWT token and return current user
"""
def get_current_user(credentials: HTTPAuthorizationCredentials=Depends(security), db: Session=Depends(get_db)) -> User:
token = credentials.credentials
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
credentials_exception = HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Could not validate credentials', headers={'WWW-Authenticate': 'Bearer'})
try:
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
user_id: int = payload.get("userId")
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345')
payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
user_id: int = payload.get('userId')
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
return user
def authorize_roles(*allowed_roles: str):
"""
Check if user has required role
"""
def role_checker(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
# Query the role from database instead of using hardcoded IDs
def role_checker(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)) -> User:
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User role not found"
)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='User role not found')
user_role_name = role.name
if user_role_name not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to access this resource"
)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='You do not have permission to access this resource')
return current_user
return role_checker
def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials]=Depends(HTTPBearer(auto_error=False)), db: Session=Depends(get_db)) -> Optional[User]:
if not credentials:
return None
token = credentials.credentials
try:
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345')
payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
user_id: int = payload.get('userId')
if user_id is None:
return None
except JWTError:
return None
user = db.query(User).filter(User.id == user_id).first()
return user
def verify_token(token: str) -> dict:
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345')
payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
return payload

View File

@@ -1,89 +1,52 @@
import json
from typing import Callable, Awaitable
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from ..schemas.privacy import CookieConsent, CookieCategoryPreferences
from ..config.settings import settings
from ..config.logging_config import get_logger
logger = get_logger(__name__)
COOKIE_CONSENT_COOKIE_NAME = "cookieConsent"
COOKIE_CONSENT_COOKIE_NAME = 'cookieConsent'
def _parse_consent_cookie(raw_value: str | None) -> CookieConsent:
if not raw_value:
return CookieConsent() # Defaults: only necessary = True
return CookieConsent()
try:
data = json.loads(raw_value)
# Pydantic will validate and coerce as needed
return CookieConsent(**data)
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"Failed to parse cookie consent cookie: {exc}")
except Exception as exc:
logger.warning(f'Failed to parse cookie consent cookie: {exc}')
return CookieConsent()
class CookieConsentMiddleware(BaseHTTPMiddleware):
"""
Middleware that parses the cookie consent cookie (if present) and attaches it
to `request.state.cookie_consent` for downstream handlers.
"""
async def dispatch(
self, request: Request, call_next: Callable[[Request], Awaitable[Response]]
) -> Response:
async def dispatch(self, request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME)
consent = _parse_consent_cookie(raw_cookie)
# Ensure 'necessary' is always true regardless of stored value
consent.categories.necessary = True
request.state.cookie_consent = consent
response = await call_next(request)
# If there's no cookie yet, set a minimal default consent cookie
# so that the banner can be rendered based on server-side knowledge.
if COOKIE_CONSENT_COOKIE_NAME not in request.cookies:
try:
response.set_cookie(
key=COOKIE_CONSENT_COOKIE_NAME,
value=consent.model_dump_json(),
httponly=True,
secure=settings.is_production,
samesite="lax",
max_age=365 * 24 * 60 * 60, # 1 year
path="/",
)
except Exception as exc: # pragma: no cover - defensive
logger.warning(f"Failed to set default cookie consent cookie: {exc}")
response.set_cookie(key=COOKIE_CONSENT_COOKIE_NAME, value=consent.model_dump_json(), httponly=True, secure=settings.is_production, samesite='lax', max_age=365 * 24 * 60 * 60, path='/')
except Exception as exc:
logger.warning(f'Failed to set default cookie consent cookie: {exc}')
return response
def is_analytics_allowed(request: Request) -> bool:
consent: CookieConsent | None = getattr(request.state, "cookie_consent", None)
consent: CookieConsent | None = getattr(request.state, 'cookie_consent', None)
if not consent:
return False
return consent.categories.analytics
def is_marketing_allowed(request: Request) -> bool:
consent: CookieConsent | None = getattr(request.state, "cookie_consent", None)
consent: CookieConsent | None = getattr(request.state, 'cookie_consent', None)
if not consent:
return False
return consent.categories.marketing
def is_preferences_allowed(request: Request) -> bool:
consent: CookieConsent | None = getattr(request.state, "cookie_consent", None)
consent: CookieConsent | None = getattr(request.state, 'cookie_consent', None)
if not consent:
return False
return consent.categories.preferences
return consent.categories.preferences

View File

@@ -5,140 +5,47 @@ from sqlalchemy.exc import IntegrityError
from jose.exceptions import JWTError
import traceback
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""
Handle validation errors
"""
errors = []
for error in exc.errors():
field = ".".join(str(loc) for loc in error["loc"] if loc != "body")
errors.append({
"field": field,
"message": error["msg"]
})
# Get the first error message for the main message
first_error = errors[0]["message"] if errors else "Validation error"
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"status": "error",
"message": first_error,
"errors": errors
}
)
field = '.'.join((str(loc) for loc in error['loc'] if loc != 'body'))
errors.append({'field': field, 'message': error['msg']})
first_error = errors[0]['message'] if errors else 'Validation error'
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': first_error, 'errors': errors})
async def integrity_error_handler(request: Request, exc: IntegrityError):
"""
Handle database integrity errors (unique constraints, etc.)
"""
error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc)
# Check for duplicate entry
if "Duplicate entry" in error_msg or "UNIQUE constraint" in error_msg:
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"status": "error",
"message": "Duplicate entry",
"errors": [{"message": "This record already exists"}]
}
)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"status": "error",
"message": "Database integrity error"
}
)
if 'Duplicate entry' in error_msg or 'UNIQUE constraint' in error_msg:
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Duplicate entry', 'errors': [{'message': 'This record already exists'}]})
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Database integrity error'})
async def jwt_error_handler(request: Request, exc: JWTError):
"""
Handle JWT errors
"""
return JSONResponse(
status_code=status.HTTP_401_UNAUTHORIZED,
content={
"status": "error",
"message": "Invalid token"
}
)
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={'status': 'error', 'message': 'Invalid token'})
async def http_exception_handler(request: Request, exc: HTTPException):
"""
Handle HTTPException errors
"""
# If detail is already a dict with status/message, return it directly
if isinstance(exc.detail, dict):
return JSONResponse(
status_code=exc.status_code,
content=exc.detail
)
# Otherwise format as standard error response
return JSONResponse(
status_code=exc.status_code,
content={
"status": "error",
"message": str(exc.detail) if exc.detail else "An error occurred"
}
)
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(status_code=exc.status_code, content={'status': 'error', 'message': str(exc.detail) if exc.detail else 'An error occurred'})
async def general_exception_handler(request: Request, exc: Exception):
"""
Handle all other exceptions
"""
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
request_id = getattr(request.state, "request_id", None)
# Log error with context
logger.error(
f"Unhandled exception: {type(exc).__name__}: {str(exc)}",
extra={
"request_id": request_id,
"path": request.url.path,
"method": request.method,
"exception_type": type(exc).__name__
},
exc_info=True
)
# Handle HTTPException with dict detail
if isinstance(exc, Exception) and hasattr(exc, "status_code"):
request_id = getattr(request.state, 'request_id', None)
logger.error(f'Unhandled exception: {type(exc).__name__}: {str(exc)}', extra={'request_id': request_id, 'path': request.url.path, 'method': request.method, 'exception_type': type(exc).__name__}, exc_info=True)
if isinstance(exc, Exception) and hasattr(exc, 'status_code'):
status_code = exc.status_code
if hasattr(exc, "detail"):
if hasattr(exc, 'detail'):
detail = exc.detail
if isinstance(detail, dict):
# If detail is already a dict with status/message, return it directly
return JSONResponse(status_code=status_code, content=detail)
message = str(detail) if detail else "An error occurred"
message = str(detail) if detail else 'An error occurred'
else:
message = str(exc) if str(exc) else "Internal server error"
message = str(exc) if str(exc) else 'Internal server error'
else:
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
message = str(exc) if str(exc) else "Internal server error"
response_content = {
"status": "error",
"message": message
}
# Add stack trace in development
message = str(exc) if str(exc) else 'Internal server error'
response_content = {'status': 'error', 'message': message}
if settings.is_development:
response_content["stack"] = traceback.format_exc()
return JSONResponse(
status_code=status_code,
content=response_content
)
response_content['stack'] = traceback.format_exc()
return JSONResponse(status_code=status_code, content=response_content)

View File

@@ -1,65 +1,21 @@
"""
Request ID middleware for tracking requests across services
"""
import uuid
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from ..config.logging_config import get_logger
logger = get_logger(__name__)
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Add unique request ID to each request for tracing"""
async def dispatch(self, request: Request, call_next):
# Generate or get request ID
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
# Add request ID to request state
request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4())
request.state.request_id = request_id
# Log request
logger.info(
f"Request started: {request.method} {request.url.path}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"client_ip": request.client.host if request.client else None
}
)
# Process request
logger.info(f'Request started: {request.method} {request.url.path}', extra={'request_id': request_id, 'method': request.method, 'path': request.url.path, 'client_ip': request.client.host if request.client else None})
try:
response = await call_next(request)
# Add request ID to response headers
response.headers["X-Request-ID"] = request_id
# Log response
logger.info(
f"Request completed: {request.method} {request.url.path} - {response.status_code}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code
}
)
response.headers['X-Request-ID'] = request_id
logger.info(f'Request completed: {request.method} {request.url.path} - {response.status_code}', extra={'request_id': request_id, 'method': request.method, 'path': request.url.path, 'status_code': response.status_code})
return response
except Exception as e:
logger.error(
f"Request failed: {request.method} {request.url.path} - {str(e)}",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"error": str(e)
},
exc_info=True
)
raise
logger.error(f'Request failed: {request.method} {request.url.path} - {str(e)}', extra={'request_id': request_id, 'method': request.method, 'path': request.url.path, 'error': str(e)}, exc_info=True)
raise

View File

@@ -1,57 +1,20 @@
"""
Security middleware for adding security headers
"""
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
"""Add security headers to all responses"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Security headers
security_headers = {
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"X-XSS-Protection": "1; mode=block",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Permissions-Policy": "geolocation=(), microphone=(), camera=()",
}
# Allow resources (like banner images) to be loaded cross-origin by the frontend.
# This helps avoid Firefox's OpaqueResponseBlocking when the frontend runs
# on a different origin (e.g. Vite dev server on :5173) and loads images
# from the API origin (e.g. :8000).
#
# In production you may want a stricter policy (e.g. "same-site") depending
# on your deployment topology.
security_headers.setdefault("Cross-Origin-Resource-Policy", "cross-origin")
# Add Content-Security-Policy
security_headers = {'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'X-XSS-Protection': '1; mode=block', 'Referrer-Policy': 'strict-origin-when-cross-origin', 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()'}
security_headers.setdefault('Cross-Origin-Resource-Policy', 'cross-origin')
if settings.is_production:
security_headers["Content-Security-Policy"] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self' data:; "
"connect-src 'self'"
)
# Add Strict-Transport-Security in production with HTTPS
security_headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'"
if settings.is_production:
security_headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
# Apply headers
security_headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
for header, value in security_headers.items():
response.headers[header] = value
return response
return response

View File

@@ -1,41 +1,16 @@
"""
Request timeout middleware
"""
import asyncio
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
class TimeoutMiddleware(BaseHTTPMiddleware):
"""Add timeout to requests"""
async def dispatch(self, request: Request, call_next):
try:
# Use asyncio.wait_for to add timeout
response = await asyncio.wait_for(
call_next(request),
timeout=settings.REQUEST_TIMEOUT
)
response = await asyncio.wait_for(call_next(request), timeout=settings.REQUEST_TIMEOUT)
return response
except asyncio.TimeoutError:
logger.warning(
f"Request timeout: {request.method} {request.url.path}",
extra={
"request_id": getattr(request.state, "request_id", None),
"method": request.method,
"path": request.url.path,
"timeout": settings.REQUEST_TIMEOUT
}
)
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail={
"status": "error",
"message": "Request timeout. Please try again."
}
)
logger.warning(f'Request timeout: {request.method} {request.url.path}', extra={'request_id': getattr(request.state, 'request_id', None), 'method': request.method, 'path': request.url.path, 'timeout': settings.REQUEST_TIMEOUT})
raise HTTPException(status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail={'status': 'error', 'message': 'Request timeout. Please try again.'})

View File

@@ -20,36 +20,5 @@ from .cookie_integration_config import CookieIntegrationConfig
from .system_settings import SystemSettings
from .invoice import Invoice, InvoiceItem
from .page_content import PageContent, PageType
__all__ = [
"Role",
"User",
"RefreshToken",
"PasswordResetToken",
"RoomType",
"Room",
"Booking",
"Payment",
"Service",
"ServiceUsage",
"ServiceBooking",
"ServiceBookingItem",
"ServicePayment",
"ServiceBookingStatus",
"ServicePaymentStatus",
"ServicePaymentMethod",
"Promotion",
"CheckInCheckOut",
"Banner",
"Review",
"Favorite",
"AuditLog",
"CookiePolicy",
"CookieIntegrationConfig",
"SystemSettings",
"Invoice",
"InvoiceItem",
"PageContent",
"PageType",
]
from .chat import Chat, ChatMessage, ChatStatus
__all__ = ['Role', 'User', 'RefreshToken', 'PasswordResetToken', 'RoomType', 'Room', 'Booking', 'Payment', 'Service', 'ServiceUsage', 'ServiceBooking', 'ServiceBookingItem', 'ServicePayment', 'ServiceBookingStatus', 'ServicePaymentStatus', 'ServicePaymentMethod', 'Promotion', 'CheckInCheckOut', 'Banner', 'Review', 'Favorite', 'AuditLog', 'CookiePolicy', 'CookieIntegrationConfig', 'SystemSettings', 'Invoice', 'InvoiceItem', 'PageContent', 'PageType', 'Chat', 'ChatMessage', 'ChatStatus']

Binary file not shown.

View File

@@ -1,28 +1,20 @@
"""
Audit log model for tracking important actions
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
__tablename__ = 'audit_logs'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
action = Column(String(100), nullable=False, index=True) # e.g., "user.created", "booking.cancelled"
resource_type = Column(String(50), nullable=False, index=True) # e.g., "user", "booking"
user_id = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
action = Column(String(100), nullable=False, index=True)
resource_type = Column(String(50), nullable=False, index=True)
resource_id = Column(Integer, nullable=True, index=True)
ip_address = Column(String(45), nullable=True) # IPv6 compatible
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(255), nullable=True)
request_id = Column(String(36), nullable=True, index=True) # UUID
details = Column(JSON, nullable=True) # Additional context
status = Column(String(20), nullable=False, default="success") # success, failed, error
request_id = Column(String(36), nullable=True, index=True)
details = Column(JSON, nullable=True)
status = Column(String(20), nullable=False, default='success')
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
user = relationship("User", foreign_keys=[user_id])
user = relationship('User', foreign_keys=[user_id])

View File

@@ -3,16 +3,14 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class Banner(Base):
__tablename__ = "banners"
__tablename__ = 'banners'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
title = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
image_url = Column(String(255), nullable=False)
link_url = Column(String(255), nullable=True)
position = Column(String(50), nullable=False, default="home")
position = Column(String(50), nullable=False, default='home')
display_order = Column(Integer, nullable=False, default=0)
is_active = Column(Boolean, nullable=False, default=True)
start_date = Column(DateTime, nullable=True)
@@ -27,5 +25,4 @@ class Banner(Base):
return False
if not self.start_date or not self.end_date:
return self.is_active
return self.start_date <= now <= self.end_date
return self.start_date <= now <= self.end_date

View File

@@ -4,41 +4,35 @@ from datetime import datetime
import enum
from ..config.database import Base
class BookingStatus(str, enum.Enum):
pending = "pending"
confirmed = "confirmed"
checked_in = "checked_in"
checked_out = "checked_out"
cancelled = "cancelled"
pending = 'pending'
confirmed = 'confirmed'
checked_in = 'checked_in'
checked_out = 'checked_out'
cancelled = 'cancelled'
class Booking(Base):
__tablename__ = "bookings"
__tablename__ = 'bookings'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_number = Column(String(50), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False)
check_in_date = Column(DateTime, nullable=False)
check_out_date = Column(DateTime, nullable=False)
num_guests = Column(Integer, nullable=False, default=1)
total_price = Column(Numeric(10, 2), nullable=False)
original_price = Column(Numeric(10, 2), nullable=True) # Price before discount
discount_amount = Column(Numeric(10, 2), nullable=True, default=0) # Discount amount applied
promotion_code = Column(String(50), nullable=True) # Promotion code used
original_price = Column(Numeric(10, 2), nullable=True)
discount_amount = Column(Numeric(10, 2), nullable=True, default=0)
promotion_code = Column(String(50), nullable=True)
status = Column(Enum(BookingStatus), nullable=False, default=BookingStatus.pending)
deposit_paid = Column(Boolean, nullable=False, default=False)
requires_deposit = Column(Boolean, nullable=False, default=False)
special_requests = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="bookings")
room = relationship("Room", back_populates="bookings")
payments = relationship("Payment", back_populates="booking", cascade="all, delete-orphan")
invoices = relationship("Invoice", back_populates="booking", cascade="all, delete-orphan")
service_usages = relationship("ServiceUsage", back_populates="booking", cascade="all, delete-orphan")
checkin_checkout = relationship("CheckInCheckOut", back_populates="booking", uselist=False)
user = relationship('User', back_populates='bookings')
room = relationship('Room', back_populates='bookings')
payments = relationship('Payment', back_populates='booking', cascade='all, delete-orphan')
invoices = relationship('Invoice', back_populates='booking', cascade='all, delete-orphan')
service_usages = relationship('ServiceUsage', back_populates='booking', cascade='all, delete-orphan')
checkin_checkout = relationship('CheckInCheckOut', back_populates='booking', uselist=False)

View File

@@ -0,0 +1,37 @@
from sqlalchemy import Column, Integer, String, Text, ForeignKey, DateTime, Enum, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..config.database import Base
class ChatStatus(str, enum.Enum):
pending = 'pending'
active = 'active'
closed = 'closed'
class Chat(Base):
__tablename__ = 'chats'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
visitor_id = Column(Integer, ForeignKey('users.id'), nullable=True)
visitor_name = Column(String(100), nullable=True)
visitor_email = Column(String(100), nullable=True)
staff_id = Column(Integer, ForeignKey('users.id'), nullable=True)
status = Column(Enum(ChatStatus), nullable=False, default=ChatStatus.pending)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
closed_at = Column(DateTime, nullable=True)
visitor = relationship('User', foreign_keys=[visitor_id], back_populates='visitor_chats')
staff = relationship('User', foreign_keys=[staff_id], back_populates='staff_chats')
messages = relationship('ChatMessage', back_populates='chat', cascade='all, delete-orphan', order_by='ChatMessage.created_at')
class ChatMessage(Base):
__tablename__ = 'chat_messages'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
chat_id = Column(Integer, ForeignKey('chats.id'), nullable=False)
sender_id = Column(Integer, ForeignKey('users.id'), nullable=True)
sender_type = Column(String(20), nullable=False)
message = Column(Text, nullable=False)
is_read = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
chat = relationship('Chat', back_populates='messages')
sender = relationship('User', foreign_keys=[sender_id])

View File

@@ -3,25 +3,20 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class CheckInCheckOut(Base):
__tablename__ = "checkin_checkout"
__tablename__ = 'checkin_checkout'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False, unique=True)
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, unique=True)
checkin_time = Column(DateTime, nullable=True)
checkout_time = Column(DateTime, nullable=True)
checkin_by = Column(Integer, ForeignKey("users.id"), nullable=True)
checkout_by = Column(Integer, ForeignKey("users.id"), nullable=True)
checkin_by = Column(Integer, ForeignKey('users.id'), nullable=True)
checkout_by = Column(Integer, ForeignKey('users.id'), nullable=True)
room_condition_checkin = Column(Text, nullable=True)
room_condition_checkout = Column(Text, nullable=True)
additional_charges = Column(Numeric(10, 2), nullable=False, default=0.0)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
booking = relationship("Booking", back_populates="checkin_checkout")
checked_in_by = relationship("User", foreign_keys=[checkin_by], back_populates="checkins_processed")
checked_out_by = relationship("User", foreign_keys=[checkout_by], back_populates="checkouts_processed")
booking = relationship('Booking', back_populates='checkin_checkout')
checked_in_by = relationship('User', foreign_keys=[checkin_by], back_populates='checkins_processed')
checked_out_by = relationship('User', foreign_keys=[checkout_by], back_populates='checkouts_processed')

View File

@@ -1,30 +1,14 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, String
from sqlalchemy.orm import relationship
from ..config.database import Base
class CookieIntegrationConfig(Base):
"""
Stores IDs for well-known integrations (e.g., Google Analytics, Meta Pixel).
Does NOT allow arbitrary script injection from the dashboard.
"""
__tablename__ = "cookie_integration_configs"
__tablename__ = 'cookie_integration_configs'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
ga_measurement_id = Column(String(64), nullable=True) # e.g. G-XXXXXXXXXX
fb_pixel_id = Column(String(64), nullable=True) # e.g. 1234567890
ga_measurement_id = Column(String(64), nullable=True)
fb_pixel_id = Column(String(64), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(
DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by = relationship("User", lazy="joined")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
updated_by = relationship('User', lazy='joined')

View File

@@ -1,31 +1,15 @@
from datetime import datetime
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer
from sqlalchemy.orm import relationship
from ..config.database import Base
class CookiePolicy(Base):
"""
Global cookie policy controlled by administrators.
This does NOT store per-user consent; it controls which cookie categories
are available to be requested from users (e.g., disable analytics entirely).
"""
__tablename__ = "cookie_policies"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
analytics_enabled = Column(Boolean, default=True, nullable=False)
marketing_enabled = Column(Boolean, default=True, nullable=False)
preferences_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by = relationship("User", lazy="joined")
__tablename__ = 'cookie_policies'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
analytics_enabled = Column(Boolean, default=True, nullable=False)
marketing_enabled = Column(Boolean, default=True, nullable=False)
preferences_enabled = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
updated_by = relationship('User', lazy='joined')

View File

@@ -3,17 +3,12 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class Favorite(Base):
__tablename__ = "favorites"
__tablename__ = 'favorites'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="favorites")
room = relationship("Room", back_populates="favorites")
user = relationship('User', back_populates='favorites')
room = relationship('Room', back_populates='favorites')

View File

@@ -4,98 +4,68 @@ from datetime import datetime
import enum
from ..config.database import Base
class InvoiceStatus(str, enum.Enum):
draft = "draft"
sent = "sent"
paid = "paid"
overdue = "overdue"
cancelled = "cancelled"
draft = 'draft'
sent = 'sent'
paid = 'paid'
overdue = 'overdue'
cancelled = 'cancelled'
class Invoice(Base):
__tablename__ = "invoices"
__tablename__ = 'invoices'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
invoice_number = Column(String(50), unique=True, nullable=False, index=True)
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Invoice details
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
issue_date = Column(DateTime, default=datetime.utcnow, nullable=False)
due_date = Column(DateTime, nullable=False)
paid_date = Column(DateTime, nullable=True)
# Amounts
subtotal = Column(Numeric(10, 2), nullable=False, default=0.00)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00) # Tax percentage
tax_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
subtotal = Column(Numeric(10, 2), nullable=False, default=0.0)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.0)
tax_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
total_amount = Column(Numeric(10, 2), nullable=False)
amount_paid = Column(Numeric(10, 2), nullable=False, default=0.00)
amount_paid = Column(Numeric(10, 2), nullable=False, default=0.0)
balance_due = Column(Numeric(10, 2), nullable=False)
# Status
status = Column(Enum(InvoiceStatus), nullable=False, default=InvoiceStatus.draft)
is_proforma = Column(Boolean, nullable=False, default=False) # True for proforma invoices
# Company/Organization information (for admin to manage)
is_proforma = Column(Boolean, nullable=False, default=False)
company_name = Column(String(200), nullable=True)
company_address = Column(Text, nullable=True)
company_phone = Column(String(50), nullable=True)
company_email = Column(String(100), nullable=True)
company_tax_id = Column(String(100), nullable=True)
company_logo_url = Column(String(500), nullable=True)
# Customer information (snapshot at invoice creation)
customer_name = Column(String(200), nullable=False)
customer_email = Column(String(100), nullable=False)
customer_address = Column(Text, nullable=True)
customer_phone = Column(String(50), nullable=True)
customer_tax_id = Column(String(100), nullable=True)
# Additional information
notes = Column(Text, nullable=True)
terms_and_conditions = Column(Text, nullable=True)
payment_instructions = Column(Text, nullable=True)
# Metadata
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
booking = relationship("Booking", back_populates="invoices")
user = relationship("User", foreign_keys=[user_id], backref="invoices")
created_by = relationship("User", foreign_keys=[created_by_id])
updated_by = relationship("User", foreign_keys=[updated_by_id])
items = relationship("InvoiceItem", back_populates="invoice", cascade="all, delete-orphan")
booking = relationship('Booking', back_populates='invoices')
user = relationship('User', foreign_keys=[user_id], backref='invoices')
created_by = relationship('User', foreign_keys=[created_by_id])
updated_by = relationship('User', foreign_keys=[updated_by_id])
items = relationship('InvoiceItem', back_populates='invoice', cascade='all, delete-orphan')
class InvoiceItem(Base):
__tablename__ = "invoice_items"
__tablename__ = 'invoice_items'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
invoice_id = Column(Integer, ForeignKey("invoices.id"), nullable=False)
# Item details
invoice_id = Column(Integer, ForeignKey('invoices.id'), nullable=False)
description = Column(String(500), nullable=False)
quantity = Column(Numeric(10, 2), nullable=False, default=1.00)
quantity = Column(Numeric(10, 2), nullable=False, default=1.0)
unit_price = Column(Numeric(10, 2), nullable=False)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.00)
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.00)
tax_rate = Column(Numeric(5, 2), nullable=False, default=0.0)
discount_amount = Column(Numeric(10, 2), nullable=False, default=0.0)
line_total = Column(Numeric(10, 2), nullable=False)
# Optional reference to booking items
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=True)
service_id = Column(Integer, ForeignKey("services.id"), nullable=True)
# Metadata
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True)
service_id = Column(Integer, ForeignKey('services.id'), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
invoice = relationship("Invoice", back_populates="items")
room = relationship("Room")
service = relationship("Service")
invoice = relationship('Invoice', back_populates='items')
room = relationship('Room')
service = relationship('Service')

View File

@@ -2,31 +2,23 @@ from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, Enum as
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..config.database import Base
class PageType(str, enum.Enum):
HOME = "home"
CONTACT = "contact"
ABOUT = "about"
FOOTER = "footer"
SEO = "seo"
HOME = 'home'
CONTACT = 'contact'
ABOUT = 'about'
FOOTER = 'footer'
SEO = 'seo'
class PageContent(Base):
__tablename__ = "page_contents"
__tablename__ = 'page_contents'
id = Column(Integer, primary_key=True, index=True)
page_type = Column(SQLEnum(PageType), nullable=False, unique=True, index=True)
# General content fields
title = Column(String(500), nullable=True)
subtitle = Column(String(1000), nullable=True)
description = Column(Text, nullable=True)
content = Column(Text, nullable=True) # Rich text content
# SEO fields
content = Column(Text, nullable=True)
meta_title = Column(String(500), nullable=True)
meta_description = Column(Text, nullable=True)
meta_keywords = Column(String(1000), nullable=True)
@@ -34,67 +26,57 @@ class PageContent(Base):
og_description = Column(Text, nullable=True)
og_image = Column(String(1000), nullable=True)
canonical_url = Column(String(1000), nullable=True)
# Contact/Footer specific fields (stored as JSON strings)
contact_info = Column(Text, nullable=True) # JSON: phone, email, address
map_url = Column(String(1000), nullable=True) # Google Maps embed URL
social_links = Column(Text, nullable=True) # JSON: facebook, twitter, instagram, etc.
footer_links = Column(Text, nullable=True) # JSON: quick links, support links
badges = Column(Text, nullable=True) # JSON: array of badges with text and icon
copyright_text = Column(Text, nullable=True) # Copyright text with {YEAR} placeholder for automatic year
# Home page specific
contact_info = Column(Text, nullable=True)
map_url = Column(String(1000), nullable=True)
social_links = Column(Text, nullable=True)
footer_links = Column(Text, nullable=True)
badges = Column(Text, nullable=True)
copyright_text = Column(Text, nullable=True)
hero_title = Column(String(500), nullable=True)
hero_subtitle = Column(String(1000), nullable=True)
hero_image = Column(String(1000), nullable=True)
# About page specific
story_content = Column(Text, nullable=True)
values = Column(Text, nullable=True) # JSON array of values
features = Column(Text, nullable=True) # JSON array of features
about_hero_image = Column(Text, nullable=True) # Hero image for about page
mission = Column(Text, nullable=True) # Mission statement
vision = Column(Text, nullable=True) # Vision statement
team = Column(Text, nullable=True) # JSON array of team members with name, role, image, bio, social_links
timeline = Column(Text, nullable=True) # JSON array of timeline events with year, title, description, image
achievements = Column(Text, nullable=True) # JSON array of achievements with icon, title, description, year, image
# Home page luxury sections
values = Column(Text, nullable=True)
features = Column(Text, nullable=True)
about_hero_image = Column(Text, nullable=True)
mission = Column(Text, nullable=True)
vision = Column(Text, nullable=True)
team = Column(Text, nullable=True)
timeline = Column(Text, nullable=True)
achievements = Column(Text, nullable=True)
luxury_section_title = Column(Text, nullable=True)
luxury_section_subtitle = Column(Text, nullable=True)
luxury_section_image = Column(Text, nullable=True)
luxury_features = Column(Text, nullable=True) # JSON array of features with icon, title, description
luxury_features = Column(Text, nullable=True)
luxury_gallery_section_title = Column(Text, nullable=True)
luxury_gallery_section_subtitle = Column(Text, nullable=True)
luxury_gallery = Column(Text, nullable=True) # JSON array of image URLs
luxury_gallery = Column(Text, nullable=True)
luxury_testimonials_section_title = Column(Text, nullable=True)
luxury_testimonials_section_subtitle = Column(Text, nullable=True)
luxury_testimonials = Column(Text, nullable=True) # JSON array of testimonials
luxury_testimonials = Column(Text, nullable=True)
amenities_section_title = Column(String(500), nullable=True)
amenities_section_subtitle = Column(String(1000), nullable=True)
amenities = Column(Text, nullable=True) # JSON array of amenities with icon, title, description, image
amenities = Column(Text, nullable=True)
testimonials_section_title = Column(String(500), nullable=True)
testimonials_section_subtitle = Column(String(1000), nullable=True)
testimonials = Column(Text, nullable=True) # JSON array of testimonials with name, role, image, rating, comment
testimonials = Column(Text, nullable=True)
gallery_section_title = Column(String(500), nullable=True)
gallery_section_subtitle = Column(String(1000), nullable=True)
gallery_images = Column(Text, nullable=True) # JSON array of image URLs
gallery_images = Column(Text, nullable=True)
about_preview_title = Column(String(500), nullable=True)
about_preview_subtitle = Column(String(1000), nullable=True)
about_preview_content = Column(Text, nullable=True)
about_preview_image = Column(String(1000), nullable=True)
stats = Column(Text, nullable=True) # JSON array of stats with number, label, icon
# Additional luxury sections
stats = Column(Text, nullable=True)
luxury_services_section_title = Column(Text, nullable=True)
luxury_services_section_subtitle = Column(Text, nullable=True)
luxury_services = Column(Text, nullable=True) # JSON array of services with icon, title, description, image
luxury_services = Column(Text, nullable=True)
luxury_experiences_section_title = Column(Text, nullable=True)
luxury_experiences_section_subtitle = Column(Text, nullable=True)
luxury_experiences = Column(Text, nullable=True) # JSON array of experiences with icon, title, description, image
luxury_experiences = Column(Text, nullable=True)
awards_section_title = Column(Text, nullable=True)
awards_section_subtitle = Column(Text, nullable=True)
awards = Column(Text, nullable=True) # JSON array of awards with icon, title, description, image, year
awards = Column(Text, nullable=True)
cta_title = Column(Text, nullable=True)
cta_subtitle = Column(Text, nullable=True)
cta_button_text = Column(Text, nullable=True)
@@ -102,12 +84,7 @@ class PageContent(Base):
cta_image = Column(Text, nullable=True)
partners_section_title = Column(Text, nullable=True)
partners_section_subtitle = Column(Text, nullable=True)
partners = Column(Text, nullable=True) # JSON array of partners with name, logo, link
# Status
partners = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
# Timestamps
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)

View File

@@ -3,18 +3,13 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
__tablename__ = 'password_reset_tokens'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
token = Column(String(255), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User")
user = relationship('User')

View File

@@ -4,48 +4,40 @@ from datetime import datetime
import enum
from ..config.database import Base
class PaymentMethod(str, enum.Enum):
cash = "cash"
credit_card = "credit_card"
debit_card = "debit_card"
bank_transfer = "bank_transfer"
e_wallet = "e_wallet"
stripe = "stripe"
paypal = "paypal"
cash = 'cash'
credit_card = 'credit_card'
debit_card = 'debit_card'
bank_transfer = 'bank_transfer'
e_wallet = 'e_wallet'
stripe = 'stripe'
paypal = 'paypal'
class PaymentType(str, enum.Enum):
full = "full"
deposit = "deposit"
remaining = "remaining"
full = 'full'
deposit = 'deposit'
remaining = 'remaining'
class PaymentStatus(str, enum.Enum):
pending = "pending"
completed = "completed"
failed = "failed"
refunded = "refunded"
pending = 'pending'
completed = 'completed'
failed = 'failed'
refunded = 'refunded'
class Payment(Base):
__tablename__ = "payments"
__tablename__ = 'payments'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False)
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
payment_method = Column(Enum(PaymentMethod), nullable=False)
payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full)
deposit_percentage = Column(Integer, nullable=True)
related_payment_id = Column(Integer, ForeignKey("payments.id"), nullable=True)
related_payment_id = Column(Integer, ForeignKey('payments.id'), nullable=True)
payment_status = Column(Enum(PaymentStatus), nullable=False, default=PaymentStatus.pending)
transaction_id = Column(String(100), nullable=True)
payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
booking = relationship("Booking", back_populates="payments")
related_payment = relationship("Payment", remote_side=[id], backref="related_payments")
booking = relationship('Booking', back_populates='payments')
related_payment = relationship('Payment', remote_side=[id], backref='related_payments')

View File

@@ -4,15 +4,12 @@ from datetime import datetime
import enum
from ..config.database import Base
class DiscountType(str, enum.Enum):
percentage = "percentage"
fixed_amount = "fixed_amount"
percentage = 'percentage'
fixed_amount = 'fixed_amount'
class Promotion(Base):
__tablename__ = "promotions"
__tablename__ = 'promotions'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
code = Column(String(50), unique=True, nullable=False, index=True)
name = Column(String(100), nullable=False)
@@ -43,18 +40,13 @@ class Promotion(Base):
def calculate_discount(self, booking_amount):
if not self.is_valid():
return 0.0
if self.min_booking_amount and booking_amount < float(self.min_booking_amount):
return 0.0
discount = 0.0
if self.discount_type == DiscountType.percentage:
discount = float(booking_amount) * float(self.discount_value) / 100.0
elif self.discount_type == DiscountType.fixed_amount:
discount = float(self.discount_value)
if self.max_discount_amount and discount > float(self.max_discount_amount):
discount = float(self.max_discount_amount)
return discount
return discount

View File

@@ -3,16 +3,11 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
__tablename__ = 'refresh_tokens'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
token = Column(String(500), unique=True, nullable=False, index=True)
expires_at = Column(DateTime, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="refresh_tokens")
user = relationship('User', back_populates='refresh_tokens')

View File

@@ -4,26 +4,20 @@ from datetime import datetime
import enum
from ..config.database import Base
class ReviewStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
pending = 'pending'
approved = 'approved'
rejected = 'rejected'
class Review(Base):
__tablename__ = "reviews"
__tablename__ = 'reviews'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
room_id = Column(Integer, ForeignKey("rooms.id"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False)
rating = Column(Integer, nullable=False)
comment = Column(Text, nullable=False)
status = Column(Enum(ReviewStatus), nullable=False, default=ReviewStatus.pending)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="reviews")
room = relationship("Room", back_populates="reviews")
user = relationship('User', back_populates='reviews')
room = relationship('Room', back_populates='reviews')

View File

@@ -3,16 +3,11 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class Role(Base):
__tablename__ = "roles"
__tablename__ = 'roles'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(50), unique=True, nullable=False, index=True)
description = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
users = relationship("User", back_populates="role")
users = relationship('User', back_populates='role')

View File

@@ -4,36 +4,30 @@ from datetime import datetime
import enum
from ..config.database import Base
class RoomStatus(str, enum.Enum):
available = "available"
occupied = "occupied"
maintenance = "maintenance"
cleaning = "cleaning"
available = 'available'
occupied = 'occupied'
maintenance = 'maintenance'
cleaning = 'cleaning'
class Room(Base):
__tablename__ = "rooms"
__tablename__ = 'rooms'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
room_type_id = Column(Integer, ForeignKey("room_types.id"), nullable=False)
room_type_id = Column(Integer, ForeignKey('room_types.id'), nullable=False)
room_number = Column(String(20), unique=True, nullable=False, index=True)
floor = Column(Integer, nullable=False)
status = Column(Enum(RoomStatus), nullable=False, default=RoomStatus.available)
price = Column(Numeric(10, 2), nullable=False)
featured = Column(Boolean, nullable=False, default=False)
capacity = Column(Integer, nullable=True) # Room-specific capacity, overrides room_type capacity
room_size = Column(String(50), nullable=True) # e.g., "1 Room", "2 Rooms", "50 sqm"
view = Column(String(100), nullable=True) # e.g., "City View", "Ocean View", etc.
capacity = Column(Integer, nullable=True)
room_size = Column(String(50), nullable=True)
view = Column(String(100), nullable=True)
images = Column(JSON, nullable=True)
amenities = Column(JSON, nullable=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
room_type = relationship("RoomType", back_populates="rooms")
bookings = relationship("Booking", back_populates="room")
reviews = relationship("Review", back_populates="room")
favorites = relationship("Favorite", back_populates="room", cascade="all, delete-orphan")
room_type = relationship('RoomType', back_populates='rooms')
bookings = relationship('Booking', back_populates='room')
reviews = relationship('Review', back_populates='room')
favorites = relationship('Favorite', back_populates='room', cascade='all, delete-orphan')

View File

@@ -3,10 +3,8 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class RoomType(Base):
__tablename__ = "room_types"
__tablename__ = 'room_types'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
@@ -15,7 +13,4 @@ class RoomType(Base):
amenities = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
rooms = relationship("Room", back_populates="room_type")
rooms = relationship('Room', back_populates='room_type')

View File

@@ -3,10 +3,8 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class Service(Base):
__tablename__ = "services"
__tablename__ = 'services'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
@@ -15,7 +13,4 @@ class Service(Base):
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
service_usages = relationship("ServiceUsage", back_populates="service")
service_usages = relationship('ServiceUsage', back_populates='service')

View File

@@ -4,66 +4,53 @@ from datetime import datetime
import enum
from ..config.database import Base
class ServiceBookingStatus(str, enum.Enum):
pending = "pending"
confirmed = "confirmed"
completed = "completed"
cancelled = "cancelled"
pending = 'pending'
confirmed = 'confirmed'
completed = 'completed'
cancelled = 'cancelled'
class ServiceBooking(Base):
__tablename__ = "service_bookings"
__tablename__ = 'service_bookings'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_number = Column(String(50), unique=True, nullable=False, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False)
total_amount = Column(Numeric(10, 2), nullable=False)
status = Column(Enum(ServiceBookingStatus), nullable=False, default=ServiceBookingStatus.pending)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="service_bookings")
service_items = relationship("ServiceBookingItem", back_populates="service_booking", cascade="all, delete-orphan")
payments = relationship("ServicePayment", back_populates="service_booking", cascade="all, delete-orphan")
user = relationship('User', back_populates='service_bookings')
service_items = relationship('ServiceBookingItem', back_populates='service_booking', cascade='all, delete-orphan')
payments = relationship('ServicePayment', back_populates='service_booking', cascade='all, delete-orphan')
class ServiceBookingItem(Base):
__tablename__ = "service_booking_items"
__tablename__ = 'service_booking_items'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
service_booking_id = Column(Integer, ForeignKey('service_bookings.id'), nullable=False)
service_id = Column(Integer, ForeignKey('services.id'), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
unit_price = Column(Numeric(10, 2), nullable=False)
total_price = Column(Numeric(10, 2), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
# Relationships
service_booking = relationship("ServiceBooking", back_populates="service_items")
service = relationship("Service")
service_booking = relationship('ServiceBooking', back_populates='service_items')
service = relationship('Service')
class ServicePaymentStatus(str, enum.Enum):
pending = "pending"
completed = "completed"
failed = "failed"
refunded = "refunded"
pending = 'pending'
completed = 'completed'
failed = 'failed'
refunded = 'refunded'
class ServicePaymentMethod(str, enum.Enum):
cash = "cash"
stripe = "stripe"
bank_transfer = "bank_transfer"
cash = 'cash'
stripe = 'stripe'
bank_transfer = 'bank_transfer'
class ServicePayment(Base):
__tablename__ = "service_payments"
__tablename__ = 'service_payments'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
service_booking_id = Column(Integer, ForeignKey("service_bookings.id"), nullable=False)
service_booking_id = Column(Integer, ForeignKey('service_bookings.id'), nullable=False)
amount = Column(Numeric(10, 2), nullable=False)
payment_method = Column(Enum(ServicePaymentMethod), nullable=False)
payment_status = Column(Enum(ServicePaymentStatus), nullable=False, default=ServicePaymentStatus.pending)
@@ -72,7 +59,4 @@ class ServicePayment(Base):
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
service_booking = relationship("ServiceBooking", back_populates="payments")
service_booking = relationship('ServiceBooking', back_populates='payments')

View File

@@ -3,13 +3,11 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class ServiceUsage(Base):
__tablename__ = "service_usages"
__tablename__ = 'service_usages'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_id = Column(Integer, ForeignKey("bookings.id"), nullable=False)
service_id = Column(Integer, ForeignKey("services.id"), nullable=False)
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False)
service_id = Column(Integer, ForeignKey('services.id'), nullable=False)
quantity = Column(Integer, nullable=False, default=1)
unit_price = Column(Numeric(10, 2), nullable=False)
total_price = Column(Numeric(10, 2), nullable=False)
@@ -17,8 +15,5 @@ class ServiceUsage(Base):
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
booking = relationship("Booking", back_populates="service_usages")
service = relationship("Service", back_populates="service_usages")
booking = relationship('Booking', back_populates='service_usages')
service = relationship('Service', back_populates='service_usages')

View File

@@ -3,19 +3,12 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class SystemSettings(Base):
"""
System-wide settings controlled by administrators.
Stores key-value pairs for platform configuration like currency, etc.
"""
__tablename__ = "system_settings"
__tablename__ = 'system_settings'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(Text, nullable=False)
description = Column(Text, nullable=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
updated_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
updated_by = relationship("User", lazy="joined")
updated_by_id = Column(Integer, ForeignKey('users.id'), nullable=True)
updated_by = relationship('User', lazy='joined')

View File

@@ -3,33 +3,30 @@ from sqlalchemy.orm import relationship
from datetime import datetime
from ..config.database import Base
class User(Base):
__tablename__ = "users"
__tablename__ = 'users'
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
role_id = Column(Integer, ForeignKey("roles.id"), nullable=False)
role_id = Column(Integer, ForeignKey('roles.id'), nullable=False)
email = Column(String(100), unique=True, nullable=False, index=True)
password = Column(String(255), nullable=False)
full_name = Column(String(100), nullable=False)
phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True)
avatar = Column(String(255), nullable=True)
currency = Column(String(3), nullable=False, default='VND') # ISO 4217 currency code
currency = Column(String(3), nullable=False, default='VND')
is_active = Column(Boolean, nullable=False, default=True)
mfa_enabled = Column(Boolean, nullable=False, default=False)
mfa_secret = Column(String(255), nullable=True) # TOTP secret key (encrypted in production)
mfa_backup_codes = Column(Text, nullable=True) # JSON array of backup codes (hashed)
mfa_secret = Column(String(255), nullable=True)
mfa_backup_codes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
role = relationship("Role", back_populates="users")
bookings = relationship("Booking", back_populates="user")
refresh_tokens = relationship("RefreshToken", back_populates="user", cascade="all, delete-orphan")
checkins_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkin_by", back_populates="checked_in_by")
checkouts_processed = relationship("CheckInCheckOut", foreign_keys="CheckInCheckOut.checkout_by", back_populates="checked_out_by")
reviews = relationship("Review", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
service_bookings = relationship("ServiceBooking", back_populates="user")
role = relationship('Role', back_populates='users')
bookings = relationship('Booking', back_populates='user')
refresh_tokens = relationship('RefreshToken', back_populates='user', cascade='all, delete-orphan')
checkins_processed = relationship('CheckInCheckOut', foreign_keys='CheckInCheckOut.checkin_by', back_populates='checked_in_by')
checkouts_processed = relationship('CheckInCheckOut', foreign_keys='CheckInCheckOut.checkout_by', back_populates='checked_out_by')
reviews = relationship('Review', back_populates='user')
favorites = relationship('Favorite', back_populates='user', cascade='all, delete-orphan')
service_bookings = relationship('ServiceBooking', back_populates='user')
visitor_chats = relationship('Chat', foreign_keys='Chat.visitor_id', back_populates='visitor')
staff_chats = relationship('Chat', foreign_keys='Chat.staff_id', back_populates='staff')

View File

@@ -1,2 +0,0 @@
# Routes package

View File

@@ -1,75 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/about", tags=["about"])
router = APIRouter(prefix='/about', tags=['about'])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"story_content": content.story_content,
"values": json.loads(content.values) if content.values else None,
"features": json.loads(content.features) if content.features else None,
"about_hero_image": content.about_hero_image,
"mission": content.mission,
"vision": content.vision,
"team": json.loads(content.team) if content.team else None,
"timeline": json.loads(content.timeline) if content.timeline else None,
"achievements": json.loads(content.achievements) if content.achievements else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None}
@router.get("/")
async def get_about_content(
db: Session = Depends(get_db)
):
"""Get about page content"""
@router.get('/')
async def get_about_content(db: Session=Depends(get_db)):
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.ABOUT).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
return {'status': 'success', 'data': {'page_content': None}}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
return {'status': 'success', 'data': {'page_content': content_dict}}
except Exception as e:
logger.error(f"Error fetching about content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching about content: {str(e)}"
)
logger.error(f'Error fetching about content: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching about content: {str(e)}')

View File

@@ -1,120 +1,36 @@
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from ..config.database import get_db
from ..middleware.auth import authorize_roles
from ..models.user import User
from ..schemas.admin_privacy import (
CookieIntegrationSettings,
CookieIntegrationSettingsResponse,
CookiePolicySettings,
CookiePolicySettingsResponse,
)
from ..schemas.admin_privacy import CookieIntegrationSettings, CookieIntegrationSettingsResponse, CookiePolicySettings, CookiePolicySettingsResponse
from ..services.privacy_admin_service import privacy_admin_service
router = APIRouter(prefix='/admin/privacy', tags=['admin-privacy'])
router = APIRouter(prefix="/admin/privacy", tags=["admin-privacy"])
@router.get(
"/cookie-policy",
response_model=CookiePolicySettingsResponse,
status_code=status.HTTP_200_OK,
)
def get_cookie_policy(
db: Session = Depends(get_db),
_: User = Depends(authorize_roles("admin")),
) -> CookiePolicySettingsResponse:
"""
Get global cookie policy configuration (admin only).
"""
@router.get('/cookie-policy', response_model=CookiePolicySettingsResponse, status_code=status.HTTP_200_OK)
def get_cookie_policy(db: Session=Depends(get_db), _: User=Depends(authorize_roles('admin'))) -> CookiePolicySettingsResponse:
settings = privacy_admin_service.get_policy_settings(db)
policy = privacy_admin_service.get_or_create_policy(db)
updated_by_name = (
policy.updated_by.full_name if getattr(policy, "updated_by", None) else None
)
updated_by_name = policy.updated_by.full_name if getattr(policy, 'updated_by', None) else None
return CookiePolicySettingsResponse(data=settings, updated_at=policy.updated_at, updated_by=updated_by_name)
return CookiePolicySettingsResponse(
data=settings,
updated_at=policy.updated_at,
updated_by=updated_by_name,
)
@router.put(
"/cookie-policy",
response_model=CookiePolicySettingsResponse,
status_code=status.HTTP_200_OK,
)
def update_cookie_policy(
payload: CookiePolicySettings,
db: Session = Depends(get_db),
current_user: User = Depends(authorize_roles("admin")),
) -> CookiePolicySettingsResponse:
"""
Update global cookie policy configuration (admin only).
"""
@router.put('/cookie-policy', response_model=CookiePolicySettingsResponse, status_code=status.HTTP_200_OK)
def update_cookie_policy(payload: CookiePolicySettings, db: Session=Depends(get_db), current_user: User=Depends(authorize_roles('admin'))) -> CookiePolicySettingsResponse:
policy = privacy_admin_service.update_policy(db, payload, current_user)
settings = privacy_admin_service.get_policy_settings(db)
updated_by_name = (
policy.updated_by.full_name if getattr(policy, "updated_by", None) else None
)
updated_by_name = policy.updated_by.full_name if getattr(policy, 'updated_by', None) else None
return CookiePolicySettingsResponse(data=settings, updated_at=policy.updated_at, updated_by=updated_by_name)
return CookiePolicySettingsResponse(
data=settings,
updated_at=policy.updated_at,
updated_by=updated_by_name,
)
@router.get(
"/integrations",
response_model=CookieIntegrationSettingsResponse,
status_code=status.HTTP_200_OK,
)
def get_cookie_integrations(
db: Session = Depends(get_db),
_: User = Depends(authorize_roles("admin")),
) -> CookieIntegrationSettingsResponse:
"""
Get IDs for third-party integrations (admin only).
"""
@router.get('/integrations', response_model=CookieIntegrationSettingsResponse, status_code=status.HTTP_200_OK)
def get_cookie_integrations(db: Session=Depends(get_db), _: User=Depends(authorize_roles('admin'))) -> CookieIntegrationSettingsResponse:
settings = privacy_admin_service.get_integration_settings(db)
cfg = privacy_admin_service.get_or_create_integrations(db)
updated_by_name = (
cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None
)
updated_by_name = cfg.updated_by.full_name if getattr(cfg, 'updated_by', None) else None
return CookieIntegrationSettingsResponse(data=settings, updated_at=cfg.updated_at, updated_by=updated_by_name)
return CookieIntegrationSettingsResponse(
data=settings,
updated_at=cfg.updated_at,
updated_by=updated_by_name,
)
@router.put(
"/integrations",
response_model=CookieIntegrationSettingsResponse,
status_code=status.HTTP_200_OK,
)
def update_cookie_integrations(
payload: CookieIntegrationSettings,
db: Session = Depends(get_db),
current_user: User = Depends(authorize_roles("admin")),
) -> CookieIntegrationSettingsResponse:
"""
Update IDs for third-party integrations (admin only).
"""
@router.put('/integrations', response_model=CookieIntegrationSettingsResponse, status_code=status.HTTP_200_OK)
def update_cookie_integrations(payload: CookieIntegrationSettings, db: Session=Depends(get_db), current_user: User=Depends(authorize_roles('admin'))) -> CookieIntegrationSettingsResponse:
cfg = privacy_admin_service.update_integrations(db, payload, current_user)
settings = privacy_admin_service.get_integration_settings(db)
updated_by_name = (
cfg.updated_by.full_name if getattr(cfg, "updated_by", None) else None
)
return CookieIntegrationSettingsResponse(
data=settings,
updated_at=cfg.updated_at,
updated_by=updated_by_name,
)
updated_by_name = cfg.updated_by.full_name if getattr(cfg, 'updated_by', None) else None
return CookieIntegrationSettingsResponse(data=settings, updated_at=cfg.updated_at, updated_by=updated_by_name)

View File

@@ -3,237 +3,91 @@ from sqlalchemy.orm import Session
from sqlalchemy import desc, or_, func
from typing import Optional
from datetime import datetime
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.audit_log import AuditLog
router = APIRouter(prefix='/audit-logs', tags=['audit-logs'])
router = APIRouter(prefix="/audit-logs", tags=["audit-logs"])
@router.get("/")
async def get_audit_logs(
action: Optional[str] = Query(None, description="Filter by action"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
status_filter: Optional[str] = Query(None, alias="status", description="Filter by status"),
search: Optional[str] = Query(None, description="Search in action, resource_type, or details"),
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
page: int = Query(1, ge=1, description="Page number"),
limit: int = Query(20, ge=1, le=100, description="Items per page"),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit logs (Admin only)"""
@router.get('/')
async def get_audit_logs(action: Optional[str]=Query(None, description='Filter by action'), resource_type: Optional[str]=Query(None, description='Filter by resource type'), user_id: Optional[int]=Query(None, description='Filter by user ID'), status_filter: Optional[str]=Query(None, alias='status', description='Filter by status'), search: Optional[str]=Query(None, description='Search in action, resource_type, or details'), start_date: Optional[str]=Query(None, description='Start date (YYYY-MM-DD)'), end_date: Optional[str]=Query(None, description='End date (YYYY-MM-DD)'), page: int=Query(1, ge=1, description='Page number'), limit: int=Query(20, ge=1, le=100, description='Items per page'), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
query = db.query(AuditLog)
# Apply filters
if action:
query = query.filter(AuditLog.action.like(f"%{action}%"))
query = query.filter(AuditLog.action.like(f'%{action}%'))
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if user_id:
query = query.filter(AuditLog.user_id == user_id)
if status_filter:
query = query.filter(AuditLog.status == status_filter)
if search:
search_filter = or_(
AuditLog.action.like(f"%{search}%"),
AuditLog.resource_type.like(f"%{search}%"),
AuditLog.ip_address.like(f"%{search}%")
)
search_filter = or_(AuditLog.action.like(f'%{search}%'), AuditLog.resource_type.like(f'%{search}%'), AuditLog.ip_address.like(f'%{search}%'))
query = query.filter(search_filter)
# Date range filter
if start_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(AuditLog.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, "%Y-%m-%d")
# Set to end of day
end = datetime.strptime(end_date, '%Y-%m-%d')
end = end.replace(hour=23, minute=59, second=59)
query = query.filter(AuditLog.created_at <= end)
except ValueError:
pass
# Get total count
total = query.count()
# Apply pagination and ordering
offset = (page - 1) * limit
logs = query.order_by(desc(AuditLog.created_at)).offset(offset).limit(limit).all()
# Format response
result = []
for log in logs:
log_dict = {
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"details": log.details,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
# Add user info if available
log_dict = {'id': log.id, 'user_id': log.user_id, 'action': log.action, 'resource_type': log.resource_type, 'resource_id': log.resource_id, 'ip_address': log.ip_address, 'user_agent': log.user_agent, 'request_id': log.request_id, 'details': log.details, 'status': log.status, 'error_message': log.error_message, 'created_at': log.created_at.isoformat() if log.created_at else None}
if log.user:
log_dict["user"] = {
"id": log.user.id,
"full_name": log.user.full_name,
"email": log.user.email,
}
log_dict['user'] = {'id': log.user.id, 'full_name': log.user.full_name, 'email': log.user.email}
result.append(log_dict)
return {
"status": "success",
"data": {
"logs": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
return {'status': 'success', 'data': {'logs': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stats")
async def get_audit_stats(
start_date: Optional[str] = Query(None, description="Start date (YYYY-MM-DD)"),
end_date: Optional[str] = Query(None, description="End date (YYYY-MM-DD)"),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit log statistics (Admin only)"""
@router.get('/stats')
async def get_audit_stats(start_date: Optional[str]=Query(None, description='Start date (YYYY-MM-DD)'), end_date: Optional[str]=Query(None, description='End date (YYYY-MM-DD)'), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
query = db.query(AuditLog)
# Date range filter
if start_date:
try:
start = datetime.strptime(start_date, "%Y-%m-%d")
start = datetime.strptime(start_date, '%Y-%m-%d')
query = query.filter(AuditLog.created_at >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, "%Y-%m-%d")
end = datetime.strptime(end_date, '%Y-%m-%d')
end = end.replace(hour=23, minute=59, second=59)
query = query.filter(AuditLog.created_at <= end)
except ValueError:
pass
# Get statistics
total_logs = query.count()
success_count = query.filter(AuditLog.status == "success").count()
failed_count = query.filter(AuditLog.status == "failed").count()
error_count = query.filter(AuditLog.status == "error").count()
# Get top actions
top_actions = (
db.query(
AuditLog.action,
func.count(AuditLog.id).label("count")
)
.group_by(AuditLog.action)
.order_by(desc("count"))
.limit(10)
.all()
)
# Get top resource types
top_resource_types = (
db.query(
AuditLog.resource_type,
func.count(AuditLog.id).label("count")
)
.group_by(AuditLog.resource_type)
.order_by(desc("count"))
.limit(10)
.all()
)
return {
"status": "success",
"data": {
"total": total_logs,
"by_status": {
"success": success_count,
"failed": failed_count,
"error": error_count,
},
"top_actions": [{"action": action, "count": count} for action, count in top_actions],
"top_resource_types": [{"resource_type": rt, "count": count} for rt, count in top_resource_types],
},
}
success_count = query.filter(AuditLog.status == 'success').count()
failed_count = query.filter(AuditLog.status == 'failed').count()
error_count = query.filter(AuditLog.status == 'error').count()
top_actions = db.query(AuditLog.action, func.count(AuditLog.id).label('count')).group_by(AuditLog.action).order_by(desc('count')).limit(10).all()
top_resource_types = db.query(AuditLog.resource_type, func.count(AuditLog.id).label('count')).group_by(AuditLog.resource_type).order_by(desc('count')).limit(10).all()
return {'status': 'success', 'data': {'total': total_logs, 'by_status': {'success': success_count, 'failed': failed_count, 'error': error_count}, 'top_actions': [{'action': action, 'count': count} for action, count in top_actions], 'top_resource_types': [{'resource_type': rt, 'count': count} for rt, count in top_resource_types]}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_audit_log_by_id(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get audit log by ID (Admin only)"""
@router.get('/{id}')
async def get_audit_log_by_id(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
log = db.query(AuditLog).filter(AuditLog.id == id).first()
if not log:
raise HTTPException(status_code=404, detail="Audit log not found")
log_dict = {
"id": log.id,
"user_id": log.user_id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"request_id": log.request_id,
"details": log.details,
"status": log.status,
"error_message": log.error_message,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
raise HTTPException(status_code=404, detail='Audit log not found')
log_dict = {'id': log.id, 'user_id': log.user_id, 'action': log.action, 'resource_type': log.resource_type, 'resource_id': log.resource_id, 'ip_address': log.ip_address, 'user_agent': log.user_agent, 'request_id': log.request_id, 'details': log.details, 'status': log.status, 'error_message': log.error_message, 'created_at': log.created_at.isoformat() if log.created_at else None}
if log.user:
log_dict["user"] = {
"id": log.user.id,
"full_name": log.user.full_name,
"email": log.user.email,
}
return {
"status": "success",
"data": {"log": log_dict}
}
log_dict['user'] = {'id': log.user.id, 'full_name': log.user.full_name, 'email': log.user.email}
return {'status': 'success', 'data': {'log': log_dict}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -5,518 +5,193 @@ from pathlib import Path
import aiofiles
import uuid
import os
from ..config.database import get_db
from ..services.auth_service import auth_service
from ..schemas.auth import (
RegisterRequest,
LoginRequest,
RefreshTokenRequest,
ForgotPasswordRequest,
ResetPasswordRequest,
AuthResponse,
TokenResponse,
MessageResponse,
MFAInitResponse,
EnableMFARequest,
VerifyMFARequest,
MFAStatusResponse
)
from ..schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, ForgotPasswordRequest, ResetPasswordRequest, AuthResponse, TokenResponse, MessageResponse, MFAInitResponse, EnableMFARequest, VerifyMFARequest, MFAStatusResponse
from ..middleware.auth import get_current_user
from ..models.user import User
router = APIRouter(prefix="/auth", tags=["auth"])
router = APIRouter(prefix='/auth', tags=['auth'])
def get_base_url(request: Request) -> str:
"""Get base URL for image normalization"""
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:8000')}"
return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:8000')}'
def normalize_image_url(image_url: str, base_url: str) -> str:
"""Normalize image URL to absolute URL"""
if not image_url:
return image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
return image_url
if image_url.startswith('/'):
return f"{base_url}{image_url}"
return f"{base_url}/{image_url}"
return f'{base_url}{image_url}'
return f'{base_url}/{image_url}'
@router.post("/register", status_code=status.HTTP_201_CREATED)
async def register(
request: RegisterRequest,
response: Response,
db: Session = Depends(get_db)
):
"""Register new user"""
@router.post('/register', status_code=status.HTTP_201_CREATED)
async def register(request: RegisterRequest, response: Response, db: Session=Depends(get_db)):
try:
result = await auth_service.register(
db=db,
name=request.name,
email=request.email,
password=request.password,
phone=request.phone
)
# Set refresh token as HttpOnly cookie
response.set_cookie(
key="refreshToken",
value=result["refreshToken"],
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="strict",
max_age=7 * 24 * 60 * 60, # 7 days
path="/"
)
# Format response to match frontend expectations
return {
"status": "success",
"message": "Registration successful",
"data": {
"token": result["token"],
"user": result["user"]
}
}
result = await auth_service.register(db=db, name=request.name, email=request.email, password=request.password, phone=request.phone)
response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=7 * 24 * 60 * 60, path='/')
return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}}
except ValueError as e:
error_message = str(e)
return JSONResponse(
status_code=status.HTTP_400_BAD_REQUEST,
content={
"status": "error",
"message": error_message
}
)
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': error_message})
@router.post("/login")
async def login(
request: LoginRequest,
response: Response,
db: Session = Depends(get_db)
):
"""Login user"""
@router.post('/login')
async def login(request: LoginRequest, response: Response, db: Session=Depends(get_db)):
try:
result = await auth_service.login(
db=db,
email=request.email,
password=request.password,
remember_me=request.rememberMe or False,
mfa_token=request.mfaToken
)
# Check if MFA is required
if result.get("requires_mfa"):
return {
"status": "success",
"requires_mfa": True,
"user_id": result["user_id"]
}
# Set refresh token as HttpOnly cookie
result = await auth_service.login(db=db, email=request.email, password=request.password, remember_me=request.rememberMe or False, mfa_token=request.mfaToken)
if result.get('requires_mfa'):
return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']}
max_age = 7 * 24 * 60 * 60 if request.rememberMe else 1 * 24 * 60 * 60
response.set_cookie(
key="refreshToken",
value=result["refreshToken"],
httponly=True,
secure=False, # Set to True in production with HTTPS
samesite="strict",
max_age=max_age,
path="/"
)
# Format response to match frontend expectations
return {
"status": "success",
"data": {
"token": result["token"],
"user": result["user"]
}
}
response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=max_age, path='/')
return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}}
except ValueError as e:
error_message = str(e)
status_code = status.HTTP_401_UNAUTHORIZED if "Invalid email or password" in error_message or "Invalid MFA token" in error_message else status.HTTP_400_BAD_REQUEST
return JSONResponse(
status_code=status_code,
content={
"status": "error",
"message": error_message
}
)
status_code = status.HTTP_401_UNAUTHORIZED if 'Invalid email or password' in error_message or 'Invalid MFA token' in error_message else status.HTTP_400_BAD_REQUEST
return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message})
@router.post("/refresh-token", response_model=TokenResponse)
async def refresh_token(
refreshToken: str = Cookie(None),
db: Session = Depends(get_db)
):
"""Refresh access token"""
@router.post('/refresh-token', response_model=TokenResponse)
async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_db)):
if not refreshToken:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token not found"
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='Refresh token not found')
try:
result = await auth_service.refresh_access_token(db, refreshToken)
return result
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e)
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
@router.post("/logout", response_model=MessageResponse)
async def logout(
response: Response,
refreshToken: str = Cookie(None),
db: Session = Depends(get_db)
):
"""Logout user"""
@router.post('/logout', response_model=MessageResponse)
async def logout(response: Response, refreshToken: str=Cookie(None), db: Session=Depends(get_db)):
if refreshToken:
await auth_service.logout(db, refreshToken)
response.delete_cookie(key='refreshToken', path='/')
return {'status': 'success', 'message': 'Logout successful'}
# Clear refresh token cookie
response.delete_cookie(key="refreshToken", path="/")
return {
"status": "success",
"message": "Logout successful"
}
@router.get("/profile")
async def get_profile(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get current user profile"""
@router.get('/profile')
async def get_profile(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
user = await auth_service.get_profile(db, current_user.id)
return {
"status": "success",
"data": {
"user": user
}
}
return {'status': 'success', 'data': {'user': user}}
except ValueError as e:
if "User not found" in str(e):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
if 'User not found' in str(e):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.put("/profile")
async def update_profile(
profile_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update current user profile"""
@router.put('/profile')
async def update_profile(profile_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
user = await auth_service.update_profile(
db=db,
user_id=current_user.id,
full_name=profile_data.get("full_name"),
email=profile_data.get("email"),
phone_number=profile_data.get("phone_number"),
password=profile_data.get("password"),
current_password=profile_data.get("currentPassword"),
currency=profile_data.get("currency")
)
return {
"status": "success",
"message": "Profile updated successfully",
"data": {
"user": user
}
}
user = await auth_service.update_profile(db=db, user_id=current_user.id, full_name=profile_data.get('full_name'), email=profile_data.get('email'), phone_number=profile_data.get('phone_number'), password=profile_data.get('password'), current_password=profile_data.get('currentPassword'), currency=profile_data.get('currency'))
return {'status': 'success', 'message': 'Profile updated successfully', 'data': {'user': user}}
except ValueError as e:
error_message = str(e)
status_code = status.HTTP_400_BAD_REQUEST
if "not found" in error_message.lower():
if 'not found' in error_message.lower():
status_code = status.HTTP_404_NOT_FOUND
raise HTTPException(
status_code=status_code,
detail=error_message
)
raise HTTPException(status_code=status_code, detail=error_message)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"An error occurred: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'An error occurred: {str(e)}')
@router.post("/forgot-password", response_model=MessageResponse)
async def forgot_password(
request: ForgotPasswordRequest,
db: Session = Depends(get_db)
):
"""Send password reset link"""
@router.post('/forgot-password', response_model=MessageResponse)
async def forgot_password(request: ForgotPasswordRequest, db: Session=Depends(get_db)):
result = await auth_service.forgot_password(db, request.email)
return {
"status": "success",
"message": result["message"]
}
return {'status': 'success', 'message': result['message']}
@router.post("/reset-password", response_model=MessageResponse)
async def reset_password(
request: ResetPasswordRequest,
db: Session = Depends(get_db)
):
"""Reset password with token"""
@router.post('/reset-password', response_model=MessageResponse)
async def reset_password(request: ResetPasswordRequest, db: Session=Depends(get_db)):
try:
result = await auth_service.reset_password(
db=db,
token=request.token,
password=request.password
)
return {
"status": "success",
"message": result["message"]
}
result = await auth_service.reset_password(db=db, token=request.token, password=request.password)
return {'status': 'success', 'message': result['message']}
except ValueError as e:
status_code = status.HTTP_400_BAD_REQUEST
if "User not found" in str(e):
if 'User not found' in str(e):
status_code = status.HTTP_404_NOT_FOUND
raise HTTPException(
status_code=status_code,
detail=str(e)
)
# MFA Routes
raise HTTPException(status_code=status_code, detail=str(e))
from ..services.mfa_service import mfa_service
from ..config.settings import settings
@router.get("/mfa/init")
async def init_mfa(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Initialize MFA setup - generate secret and QR code"""
@router.get('/mfa/init')
async def init_mfa(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
if current_user.mfa_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="MFA is already enabled"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='MFA is already enabled')
secret = mfa_service.generate_secret()
app_name = getattr(settings, 'APP_NAME', 'Hotel Booking')
qr_code = mfa_service.generate_qr_code(secret, current_user.email, app_name)
return {
"status": "success",
"data": {
"secret": secret,
"qr_code": qr_code
}
}
return {'status': 'success', 'data': {'secret': secret, 'qr_code': qr_code}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error initializing MFA: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error initializing MFA: {str(e)}')
@router.post("/mfa/enable")
async def enable_mfa(
request: EnableMFARequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Enable MFA after verifying token"""
@router.post('/mfa/enable')
async def enable_mfa(request: EnableMFARequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
success, backup_codes = mfa_service.enable_mfa(
db=db,
user_id=current_user.id,
secret=request.secret,
verification_token=request.verification_token
)
return {
"status": "success",
"message": "MFA enabled successfully",
"data": {
"backup_codes": backup_codes
}
}
success, backup_codes = mfa_service.enable_mfa(db=db, user_id=current_user.id, secret=request.secret, verification_token=request.verification_token)
return {'status': 'success', 'message': 'MFA enabled successfully', 'data': {'backup_codes': backup_codes}}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error enabling MFA: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error enabling MFA: {str(e)}')
@router.post("/mfa/disable")
async def disable_mfa(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Disable MFA"""
@router.post('/mfa/disable')
async def disable_mfa(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
mfa_service.disable_mfa(db=db, user_id=current_user.id)
return {
"status": "success",
"message": "MFA disabled successfully"
}
return {'status': 'success', 'message': 'MFA disabled successfully'}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error disabling MFA: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error disabling MFA: {str(e)}')
@router.get("/mfa/status", response_model=MFAStatusResponse)
async def get_mfa_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get MFA status for current user"""
@router.get('/mfa/status', response_model=MFAStatusResponse)
async def get_mfa_status(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
status_data = mfa_service.get_mfa_status(db=db, user_id=current_user.id)
return status_data
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting MFA status: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error getting MFA status: {str(e)}')
@router.post("/mfa/regenerate-backup-codes")
async def regenerate_backup_codes(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Regenerate backup codes for MFA"""
@router.post('/mfa/regenerate-backup-codes')
async def regenerate_backup_codes(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
backup_codes = mfa_service.regenerate_backup_codes(db=db, user_id=current_user.id)
return {
"status": "success",
"message": "Backup codes regenerated successfully",
"data": {
"backup_codes": backup_codes
}
}
return {'status': 'success', 'message': 'Backup codes regenerated successfully', 'data': {'backup_codes': backup_codes}}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error regenerating backup codes: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error regenerating backup codes: {str(e)}')
@router.post("/avatar/upload")
async def upload_avatar(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Upload user avatar"""
@router.post('/avatar/upload')
async def upload_avatar(request: Request, image: UploadFile=File(...), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Validate file size (max 2MB)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File must be an image')
content = await image.read()
if len(content) > 2 * 1024 * 1024: # 2MB
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Avatar file size must be less than 2MB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "avatars"
if len(content) > 2 * 1024 * 1024:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Avatar file size must be less than 2MB')
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars'
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old avatar if exists
if current_user.avatar:
old_avatar_path = Path(__file__).parent.parent.parent / current_user.avatar.lstrip('/')
if old_avatar_path.exists() and old_avatar_path.is_file():
try:
old_avatar_path.unlink()
except Exception:
pass # Ignore deletion errors
# Generate filename
pass
ext = Path(image.filename).suffix or '.png'
filename = f"avatar-{current_user.id}-{uuid.uuid4()}{ext}"
filename = f'avatar-{current_user.id}-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Update user avatar
image_url = f"/uploads/avatars/{filename}"
image_url = f'/uploads/avatars/{filename}'
current_user.avatar = image_url
db.commit()
db.refresh(current_user)
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Avatar uploaded successfully",
"data": {
"avatar_url": image_url,
"full_url": full_url,
"user": {
"id": current_user.id,
"name": current_user.full_name,
"email": current_user.email,
"phone": current_user.phone,
"avatar": image_url,
"role": current_user.role.name if current_user.role else "customer"
}
}
}
return {'success': True, 'status': 'success', 'message': 'Avatar uploaded successfully', 'data': {'avatar_url': image_url, 'full_url': full_url, 'user': {'id': current_user.id, 'name': current_user.full_name, 'email': current_user.email, 'phone': current_user.phone, 'avatar': image_url, 'role': current_user.role.name if current_user.role else 'customer'}}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error uploading avatar: {str(e)}"
)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error uploading avatar: {str(e)}')

View File

@@ -7,300 +7,144 @@ from pathlib import Path
import os
import aiofiles
import uuid
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.banner import Banner
router = APIRouter(prefix="/banners", tags=["banners"])
router = APIRouter(prefix='/banners', tags=['banners'])
def normalize_image_url(image_url: str, base_url: str) -> str:
"""Normalize image URL to absolute URL"""
if not image_url:
return image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
return image_url
if image_url.startswith('/'):
return f"{base_url}{image_url}"
return f"{base_url}/{image_url}"
return f'{base_url}{image_url}'
return f'{base_url}/{image_url}'
def get_base_url(request: Request) -> str:
"""Get base URL for image normalization"""
return os.getenv("SERVER_URL") or f"http://{request.headers.get('host', 'localhost:3000')}"
return os.getenv('SERVER_URL') or f'http://{request.headers.get('host', 'localhost:3000')}'
@router.get("/")
async def get_banners(
request: Request,
position: Optional[str] = Query(None),
db: Session = Depends(get_db)
):
"""Get all active banners"""
@router.get('/')
async def get_banners(request: Request, position: Optional[str]=Query(None), db: Session=Depends(get_db)):
try:
query = db.query(Banner).filter(Banner.is_active == True)
# Filter by position
if position:
query = query.filter(Banner.position == position)
# Filter by date range
now = datetime.utcnow()
query = query.filter(
or_(
Banner.start_date == None,
Banner.start_date <= now
)
).filter(
or_(
Banner.end_date == None,
Banner.end_date >= now
)
)
query = query.filter(or_(Banner.start_date == None, Banner.start_date <= now)).filter(or_(Banner.end_date == None, Banner.end_date >= now))
banners = query.order_by(Banner.display_order.asc(), Banner.created_at.desc()).all()
base_url = get_base_url(request)
result = []
for banner in banners:
banner_dict = {
"id": banner.id,
"title": banner.title,
"description": banner.description,
"image_url": normalize_image_url(banner.image_url, base_url),
"link_url": banner.link_url,
"position": banner.position,
"display_order": banner.display_order,
"is_active": banner.is_active,
"start_date": banner.start_date.isoformat() if banner.start_date else None,
"end_date": banner.end_date.isoformat() if banner.end_date else None,
"created_at": banner.created_at.isoformat() if banner.created_at else None,
}
banner_dict = {'id': banner.id, 'title': banner.title, 'description': banner.description, 'image_url': normalize_image_url(banner.image_url, base_url), 'link_url': banner.link_url, 'position': banner.position, 'display_order': banner.display_order, 'is_active': banner.is_active, 'start_date': banner.start_date.isoformat() if banner.start_date else None, 'end_date': banner.end_date.isoformat() if banner.end_date else None, 'created_at': banner.created_at.isoformat() if banner.created_at else None}
result.append(banner_dict)
return {
"status": "success",
"data": {"banners": result}
}
return {'status': 'success', 'data': {'banners': result}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_banner_by_id(
id: int,
request: Request,
db: Session = Depends(get_db)
):
"""Get banner by ID"""
@router.get('/{id}')
async def get_banner_by_id(id: int, request: Request, db: Session=Depends(get_db)):
try:
banner = db.query(Banner).filter(Banner.id == id).first()
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
raise HTTPException(status_code=404, detail='Banner not found')
base_url = get_base_url(request)
banner_dict = {
"id": banner.id,
"title": banner.title,
"description": banner.description,
"image_url": normalize_image_url(banner.image_url, base_url),
"link_url": banner.link_url,
"position": banner.position,
"display_order": banner.display_order,
"is_active": banner.is_active,
"start_date": banner.start_date.isoformat() if banner.start_date else None,
"end_date": banner.end_date.isoformat() if banner.end_date else None,
"created_at": banner.created_at.isoformat() if banner.created_at else None,
}
return {
"status": "success",
"data": {"banner": banner_dict}
}
banner_dict = {'id': banner.id, 'title': banner.title, 'description': banner.description, 'image_url': normalize_image_url(banner.image_url, base_url), 'link_url': banner.link_url, 'position': banner.position, 'display_order': banner.display_order, 'is_active': banner.is_active, 'start_date': banner.start_date.isoformat() if banner.start_date else None, 'end_date': banner.end_date.isoformat() if banner.end_date else None, 'created_at': banner.created_at.isoformat() if banner.created_at else None}
return {'status': 'success', 'data': {'banner': banner_dict}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
async def create_banner(
banner_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Create new banner (Admin only)"""
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
async def create_banner(banner_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
banner = Banner(
title=banner_data.get("title"),
description=banner_data.get("description"),
image_url=banner_data.get("image_url"),
link_url=banner_data.get("link"),
position=banner_data.get("position", "home"),
display_order=banner_data.get("display_order", 0),
is_active=True,
start_date=datetime.fromisoformat(banner_data["start_date"].replace('Z', '+00:00')) if banner_data.get("start_date") else None,
end_date=datetime.fromisoformat(banner_data["end_date"].replace('Z', '+00:00')) if banner_data.get("end_date") else None,
)
banner = Banner(title=banner_data.get('title'), description=banner_data.get('description'), image_url=banner_data.get('image_url'), link_url=banner_data.get('link'), position=banner_data.get('position', 'home'), display_order=banner_data.get('display_order', 0), is_active=True, start_date=datetime.fromisoformat(banner_data['start_date'].replace('Z', '+00:00')) if banner_data.get('start_date') else None, end_date=datetime.fromisoformat(banner_data['end_date'].replace('Z', '+00:00')) if banner_data.get('end_date') else None)
db.add(banner)
db.commit()
db.refresh(banner)
return {
"status": "success",
"message": "Banner created successfully",
"data": {"banner": banner}
}
return {'status': 'success', 'message': 'Banner created successfully', 'data': {'banner': banner}}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def update_banner(
id: int,
banner_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update banner (Admin only)"""
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def update_banner(id: int, banner_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
banner = db.query(Banner).filter(Banner.id == id).first()
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
if "title" in banner_data:
banner.title = banner_data["title"]
if "description" in banner_data:
banner.description = banner_data["description"]
if "image_url" in banner_data:
banner.image_url = banner_data["image_url"]
if "link" in banner_data:
banner.link_url = banner_data["link"]
if "position" in banner_data:
banner.position = banner_data["position"]
if "display_order" in banner_data:
banner.display_order = banner_data["display_order"]
if "is_active" in banner_data:
banner.is_active = banner_data["is_active"]
if "start_date" in banner_data:
banner.start_date = datetime.fromisoformat(banner_data["start_date"].replace('Z', '+00:00')) if banner_data["start_date"] else None
if "end_date" in banner_data:
banner.end_date = datetime.fromisoformat(banner_data["end_date"].replace('Z', '+00:00')) if banner_data["end_date"] else None
raise HTTPException(status_code=404, detail='Banner not found')
if 'title' in banner_data:
banner.title = banner_data['title']
if 'description' in banner_data:
banner.description = banner_data['description']
if 'image_url' in banner_data:
banner.image_url = banner_data['image_url']
if 'link' in banner_data:
banner.link_url = banner_data['link']
if 'position' in banner_data:
banner.position = banner_data['position']
if 'display_order' in banner_data:
banner.display_order = banner_data['display_order']
if 'is_active' in banner_data:
banner.is_active = banner_data['is_active']
if 'start_date' in banner_data:
banner.start_date = datetime.fromisoformat(banner_data['start_date'].replace('Z', '+00:00')) if banner_data['start_date'] else None
if 'end_date' in banner_data:
banner.end_date = datetime.fromisoformat(banner_data['end_date'].replace('Z', '+00:00')) if banner_data['end_date'] else None
db.commit()
db.refresh(banner)
return {
"status": "success",
"message": "Banner updated successfully",
"data": {"banner": banner}
}
return {'status': 'success', 'message': 'Banner updated successfully', 'data': {'banner': banner}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def delete_banner(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete banner (Admin only)"""
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def delete_banner(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
banner = db.query(Banner).filter(Banner.id == id).first()
if not banner:
raise HTTPException(status_code=404, detail="Banner not found")
# Delete image file if it exists and is a local upload
raise HTTPException(status_code=404, detail='Banner not found')
if banner.image_url and banner.image_url.startswith('/uploads/banners/'):
file_path = Path(__file__).parent.parent.parent / "uploads" / "banners" / Path(banner.image_url).name
file_path = Path(__file__).parent.parent.parent / 'uploads' / 'banners' / Path(banner.image_url).name
if file_path.exists():
file_path.unlink()
db.delete(banner)
db.commit()
return {
"status": "success",
"message": "Banner deleted successfully"
}
return {'status': 'success', 'message': 'Banner deleted successfully'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/upload", dependencies=[Depends(authorize_roles("admin"))])
async def upload_banner_image(
request: Request,
image: UploadFile = File(...),
current_user: User = Depends(authorize_roles("admin")),
):
"""Upload banner image (Admin only)"""
@router.post('/upload', dependencies=[Depends(authorize_roles('admin'))])
async def upload_banner_image(request: Request, image: UploadFile=File(...), current_user: User=Depends(authorize_roles('admin'))):
try:
# Validate file exists
if not image:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No file provided"
)
# Validate file type
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='No file provided')
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"File must be an image. Received: {image.content_type}"
)
# Validate filename
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'File must be an image. Received: {image.content_type}')
if not image.filename:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Filename is required"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "banners"
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Filename is required')
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'banners'
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate filename
ext = Path(image.filename).suffix or '.jpg'
filename = f"banner-{uuid.uuid4()}{ext}"
filename = f'banner-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File is empty"
)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty')
await f.write(content)
# Return the image URL
image_url = f"/uploads/banners/{filename}"
image_url = f'/uploads/banners/{filename}'
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
return {
"success": True,
"status": "success",
"message": "Image uploaded successfully",
"data": {
"image_url": image_url,
"full_url": full_url
}
}
return {'success': True, 'status': 'success', 'message': 'Image uploaded successfully', 'data': {'image_url': image_url, 'full_url': full_url}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,335 @@
from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from typing import List, Optional
from datetime import datetime
import json
from ..config.database import get_db
from ..middleware.auth import get_current_user, get_current_user_optional
from ..models.user import User
from ..models.chat import Chat, ChatMessage, ChatStatus
from ..models.role import Role
router = APIRouter(prefix='/chat', tags=['chat'])
class ConnectionManager:
def __init__(self):
self.active_connections: dict[int, List[WebSocket]] = {}
self.staff_connections: dict[int, WebSocket] = {}
self.visitor_connections: dict[int, WebSocket] = {}
async def connect_chat(self, websocket: WebSocket, chat_id: int, user_type: str):
await websocket.accept()
if chat_id not in self.active_connections:
self.active_connections[chat_id] = []
self.active_connections[chat_id].append(websocket)
if user_type == 'staff':
pass
elif user_type == 'visitor':
self.visitor_connections[chat_id] = websocket
def disconnect_chat(self, websocket: WebSocket, chat_id: int):
if chat_id in self.active_connections:
if websocket in self.active_connections[chat_id]:
self.active_connections[chat_id].remove(websocket)
if not self.active_connections[chat_id]:
del self.active_connections[chat_id]
if chat_id in self.visitor_connections and self.visitor_connections[chat_id] == websocket:
del self.visitor_connections[chat_id]
async def send_personal_message(self, message: dict, websocket: WebSocket):
try:
await websocket.send_json(message)
except Exception as e:
print(f'Error sending message: {e}')
async def broadcast_to_chat(self, message: dict, chat_id: int):
if chat_id in self.active_connections:
disconnected = []
for connection in self.active_connections[chat_id]:
try:
await connection.send_json(message)
except Exception as e:
print(f'Error broadcasting to connection: {e}')
disconnected.append(connection)
for conn in disconnected:
self.active_connections[chat_id].remove(conn)
async def notify_staff_new_chat(self, chat_data: dict):
disconnected = []
for user_id, websocket in self.staff_connections.items():
try:
await websocket.send_json({'type': 'new_chat', 'data': chat_data})
except Exception as e:
print(f'Error notifying staff {user_id}: {e}')
disconnected.append(user_id)
for user_id in disconnected:
del self.staff_connections[user_id]
async def notify_staff_new_message(self, chat_id: int, message_data: dict, chat: Chat):
if message_data.get('sender_type') == 'visitor':
notification_data = {'type': 'new_message_notification', 'data': {'chat_id': chat_id, 'chat': {'id': chat.id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'status': chat.status.value, 'created_at': chat.created_at.isoformat()}, 'message': {'id': message_data.get('id'), 'message': message_data.get('message'), 'sender_type': message_data.get('sender_type'), 'created_at': message_data.get('created_at')}}}
disconnected = []
for user_id, websocket in self.staff_connections.items():
try:
await websocket.send_json(notification_data)
except Exception as e:
print(f'Error notifying staff {user_id}: {e}')
disconnected.append(user_id)
for user_id in disconnected:
del self.staff_connections[user_id]
def connect_staff(self, user_id: int, websocket: WebSocket):
self.staff_connections[user_id] = websocket
def disconnect_staff(self, user_id: int):
if user_id in self.staff_connections:
del self.staff_connections[user_id]
manager = ConnectionManager()
@router.post('/create', status_code=status.HTTP_201_CREATED)
async def create_chat(visitor_name: Optional[str]=None, visitor_email: Optional[str]=None, visitor_phone: Optional[str]=None, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)):
if current_user:
chat = Chat(visitor_id=current_user.id, visitor_name=current_user.full_name, visitor_email=current_user.email, status=ChatStatus.pending)
else:
if not visitor_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Visitor name is required')
if not visitor_email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Visitor email is required')
chat = Chat(visitor_name=visitor_name, visitor_email=visitor_email, status=ChatStatus.pending)
db.add(chat)
db.commit()
db.refresh(chat)
chat_data = {'id': chat.id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'status': chat.status.value, 'created_at': chat.created_at.isoformat()}
await manager.notify_staff_new_chat(chat_data)
return {'success': True, 'data': {'id': chat.id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'status': chat.status.value, 'created_at': chat.created_at.isoformat()}}
@router.post('/{chat_id}/accept')
async def accept_chat(chat_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
if current_user.role.name not in ['staff', 'admin']:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Only staff members can accept chats')
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found')
if chat.status != ChatStatus.pending:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Chat is not pending')
chat.staff_id = current_user.id
chat.status = ChatStatus.active
db.commit()
db.refresh(chat)
await manager.broadcast_to_chat({'type': 'chat_accepted', 'data': {'staff_name': current_user.full_name, 'staff_id': current_user.id}}, chat_id)
return {'success': True, 'data': {'id': chat.id, 'staff_id': chat.staff_id, 'staff_name': current_user.full_name, 'status': chat.status.value}}
@router.get('/list')
async def list_chats(status_filter: Optional[str]=None, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
if current_user.role.name in ['staff', 'admin']:
query = db.query(Chat)
if status_filter:
try:
status_enum = ChatStatus(status_filter)
query = query.filter(Chat.status == status_enum)
except ValueError:
pass
chats = query.order_by(Chat.created_at.desc()).all()
else:
chats = db.query(Chat).filter(Chat.visitor_id == current_user.id).order_by(Chat.created_at.desc()).all()
return {'success': True, 'data': [{'id': chat.id, 'visitor_id': chat.visitor_id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'staff_id': chat.staff_id, 'staff_name': chat.staff.full_name if chat.staff else None, 'status': chat.status.value, 'created_at': chat.created_at.isoformat(), 'updated_at': chat.updated_at.isoformat(), 'message_count': len(chat.messages)} for chat in chats]}
@router.get('/{chat_id}')
async def get_chat(chat_id: int, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found')
if current_user:
if current_user.role.name not in ['staff', 'admin']:
if chat.visitor_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to view this chat")
return {'success': True, 'data': {'id': chat.id, 'visitor_id': chat.visitor_id, 'visitor_name': chat.visitor_name, 'visitor_email': chat.visitor_email, 'staff_id': chat.staff_id, 'staff_name': chat.staff.full_name if chat.staff else None, 'status': chat.status.value, 'created_at': chat.created_at.isoformat(), 'updated_at': chat.updated_at.isoformat()}}
@router.get('/{chat_id}/messages')
async def get_messages(chat_id: int, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found')
if current_user:
if current_user.role.name not in ['staff', 'admin']:
if chat.visitor_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to view this chat")
else:
pass
messages = db.query(ChatMessage).filter(ChatMessage.chat_id == chat_id).order_by(ChatMessage.created_at.asc()).all()
return {'success': True, 'data': [{'id': msg.id, 'chat_id': msg.chat_id, 'sender_id': msg.sender_id, 'sender_type': msg.sender_type, 'sender_name': msg.sender.full_name if msg.sender else None, 'message': msg.message, 'is_read': msg.is_read, 'created_at': msg.created_at.isoformat()} for msg in messages]}
@router.post('/{chat_id}/message')
async def send_message(chat_id: int, message: str, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found')
if chat.status == ChatStatus.closed:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Chat is closed')
sender_type = 'visitor'
sender_id = None
if current_user:
if current_user.role.name in ['staff', 'admin']:
sender_type = 'staff'
sender_id = current_user.id
else:
sender_type = 'visitor'
sender_id = current_user.id
if chat.visitor_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to send messages in this chat")
else:
sender_type = 'visitor'
sender_id = None
chat_message = ChatMessage(chat_id=chat_id, sender_id=sender_id, sender_type=sender_type, message=message)
db.add(chat_message)
db.commit()
db.refresh(chat_message)
message_data = {'type': 'new_message', 'data': {'id': chat_message.id, 'chat_id': chat_message.chat_id, 'sender_id': chat_message.sender_id, 'sender_type': chat_message.sender_type, 'sender_name': chat_message.sender.full_name if chat_message.sender else None, 'message': chat_message.message, 'is_read': chat_message.is_read, 'created_at': chat_message.created_at.isoformat()}}
await manager.broadcast_to_chat(message_data, chat_id)
if chat_message.sender_type == 'visitor':
await manager.notify_staff_new_message(chat_id, message_data['data'], chat)
return {'success': True, 'data': {'id': chat_message.id, 'chat_id': chat_message.chat_id, 'sender_type': chat_message.sender_type, 'message': chat_message.message, 'created_at': chat_message.created_at.isoformat()}}
@router.post('/{chat_id}/close')
async def close_chat(chat_id: int, current_user: Optional[User]=Depends(get_current_user_optional), db: Session=Depends(get_db)):
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='Chat not found')
if current_user:
if current_user.role.name not in ['staff', 'admin']:
if chat.visitor_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You don't have permission to close this chat")
else:
pass
chat.status = ChatStatus.closed
chat.closed_at = datetime.utcnow()
db.commit()
await manager.broadcast_to_chat({'type': 'chat_closed', 'data': {'chat_id': chat_id}}, chat_id)
return {'success': True, 'data': {'id': chat.id, 'status': chat.status.value}}
@router.websocket('/ws/{chat_id}')
async def websocket_chat(websocket: WebSocket, chat_id: int, user_type: str=None, token: Optional[str]=None):
query_params = dict(websocket.query_params)
user_type = query_params.get('user_type', 'visitor')
token = query_params.get('token')
current_user = None
if user_type == 'staff' and token:
try:
from ..middleware.auth import verify_token
from ..config.database import get_db
payload = verify_token(token)
user_id = payload.get('userId')
db_gen = get_db()
db = next(db_gen)
try:
current_user = db.query(User).filter(User.id == user_id).first()
if not current_user or current_user.role.name not in ['staff', 'admin']:
await websocket.close(code=1008, reason='Unauthorized')
return
finally:
db.close()
except Exception as e:
await websocket.close(code=1008, reason='Invalid token')
return
await manager.connect_chat(websocket, chat_id, user_type)
if user_type == 'staff' and current_user:
manager.connect_staff(current_user.id, websocket)
try:
while True:
data = await websocket.receive_text()
message_data = json.loads(data)
if message_data.get('type') == 'message':
from ..config.database import get_db
db_gen = get_db()
db = next(db_gen)
try:
chat = db.query(Chat).filter(Chat.id == chat_id).first()
if not chat:
continue
sender_id = current_user.id if current_user else None
sender_type = 'staff' if user_type == 'staff' else 'visitor'
chat_message = ChatMessage(chat_id=chat_id, sender_id=sender_id, sender_type=sender_type, message=message_data.get('message', ''))
db.add(chat_message)
db.commit()
db.refresh(chat_message)
finally:
db.close()
message_data = {'type': 'new_message', 'data': {'id': chat_message.id, 'chat_id': chat_message.chat_id, 'sender_id': chat_message.sender_id, 'sender_type': chat_message.sender_type, 'sender_name': chat_message.sender.full_name if chat_message.sender else None, 'message': chat_message.message, 'is_read': chat_message.is_read, 'created_at': chat_message.created_at.isoformat()}}
await manager.broadcast_to_chat(message_data, chat_id)
if chat_message.sender_type == 'visitor':
await manager.notify_staff_new_message(chat_id, message_data['data'], chat)
except WebSocketDisconnect:
manager.disconnect_chat(websocket, chat_id)
if user_type == 'staff' and current_user:
manager.disconnect_staff(current_user.id)
@router.websocket('/ws/staff/notifications')
async def websocket_staff_notifications(websocket: WebSocket):
current_user = None
try:
await websocket.accept()
query_params = dict(websocket.query_params)
token = query_params.get('token')
if not token:
await websocket.close(code=1008, reason='Token required')
return
try:
from ..middleware.auth import verify_token
from ..config.database import get_db
payload = verify_token(token)
user_id = payload.get('userId')
if not user_id:
await websocket.close(code=1008, reason='Invalid token payload')
return
db_gen = get_db()
db = next(db_gen)
try:
current_user = db.query(User).filter(User.id == user_id).first()
if not current_user:
await websocket.close(code=1008, reason='User not found')
return
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if not role or role.name not in ['staff', 'admin']:
await websocket.close(code=1008, reason='Unauthorized role')
return
finally:
db.close()
except Exception as e:
print(f'WebSocket token verification error: {e}')
import traceback
traceback.print_exc()
await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}')
return
manager.connect_staff(current_user.id, websocket)
try:
await websocket.send_json({'type': 'connected', 'data': {'message': 'WebSocket connected'}})
except Exception as e:
print(f'Error sending initial message: {e}')
while True:
try:
data = await websocket.receive_text()
try:
message_data = json.loads(data)
if message_data.get('type') == 'ping':
await websocket.send_json({'type': 'pong', 'data': 'pong'})
except json.JSONDecodeError:
await websocket.send_json({'type': 'pong', 'data': 'pong'})
except WebSocketDisconnect:
print('WebSocket disconnected normally')
break
except Exception as e:
print(f'WebSocket receive error: {e}')
break
except WebSocketDisconnect:
print('WebSocket disconnected')
except Exception as e:
print(f'WebSocket error: {e}')
import traceback
traceback.print_exc()
finally:
if current_user:
try:
manager.disconnect_staff(current_user.id)
except:
pass

View File

@@ -1,68 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/contact-content", tags=["contact-content"])
router = APIRouter(prefix='/contact-content', tags=['contact-content'])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"contact_info": json.loads(content.contact_info) if content.contact_info else None,
"map_url": content.map_url,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None}
@router.get("/")
async def get_contact_content(
db: Session = Depends(get_db)
):
"""Get contact page content"""
@router.get('/')
async def get_contact_content(db: Session=Depends(get_db)):
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.CONTACT).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
return {'status': 'success', 'data': {'page_content': None}}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
return {'status': 'success', 'data': {'page_content': content_dict}}
except Exception as e:
logger.error(f"Error fetching contact content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching contact content: {str(e)}"
)
logger.error(f'Error fetching contact content: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching contact content: {str(e)}')

View File

@@ -3,17 +3,13 @@ from sqlalchemy.orm import Session
from pydantic import BaseModel, EmailStr
from typing import Optional
import logging
from ..config.database import get_db
from ..models.user import User
from ..models.role import Role
from ..models.system_settings import SystemSettings
from ..utils.mailer import send_email
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/contact", tags=["contact"])
router = APIRouter(prefix='/contact', tags=['contact'])
class ContactForm(BaseModel):
name: str
@@ -22,182 +18,35 @@ class ContactForm(BaseModel):
message: str
phone: Optional[str] = None
def get_admin_email(db: Session) -> str:
"""Get admin email from system settings or find admin user"""
# First, try to get from company_email (company settings)
company_email_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_email"
).first()
company_email_setting = db.query(SystemSettings).filter(SystemSettings.key == 'company_email').first()
if company_email_setting and company_email_setting.value:
return company_email_setting.value
# Second, try to get from admin_email (legacy setting)
admin_email_setting = db.query(SystemSettings).filter(
SystemSettings.key == "admin_email"
).first()
admin_email_setting = db.query(SystemSettings).filter(SystemSettings.key == 'admin_email').first()
if admin_email_setting and admin_email_setting.value:
return admin_email_setting.value
# If not found in settings, find the first admin user
admin_role = db.query(Role).filter(Role.name == "admin").first()
admin_role = db.query(Role).filter(Role.name == 'admin').first()
if admin_role:
admin_user = db.query(User).filter(
User.role_id == admin_role.id,
User.is_active == True
).first()
admin_user = db.query(User).filter(User.role_id == admin_role.id, User.is_active == True).first()
if admin_user:
return admin_user.email
# Fallback to SMTP_FROM_EMAIL if configured
from ..config.settings import settings
if settings.SMTP_FROM_EMAIL:
return settings.SMTP_FROM_EMAIL
# Last resort: raise error
raise HTTPException(
status_code=500,
detail="Admin email not configured. Please set company_email in system settings or ensure an admin user exists."
)
raise HTTPException(status_code=500, detail='Admin email not configured. Please set company_email in system settings or ensure an admin user exists.')
@router.post("/submit")
async def submit_contact_form(
contact_data: ContactForm,
db: Session = Depends(get_db)
):
"""Submit contact form and send email to admin"""
@router.post('/submit')
async def submit_contact_form(contact_data: ContactForm, db: Session=Depends(get_db)):
try:
# Get admin email
admin_email = get_admin_email(db)
# Create email subject
subject = f"Contact Form: {contact_data.subject}"
# Create email body (HTML)
html_body = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}}
.container {{
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f9f9f9;
}}
.header {{
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
color: #0f0f0f;
padding: 20px;
text-align: center;
border-radius: 8px 8px 0 0;
}}
.content {{
background-color: #ffffff;
padding: 30px;
border-radius: 0 0 8px 8px;
}}
.field {{
margin-bottom: 20px;
}}
.label {{
font-weight: bold;
color: #d4af37;
display: block;
margin-bottom: 5px;
}}
.value {{
color: #333;
padding: 10px;
background-color: #f5f5f5;
border-radius: 4px;
}}
.footer {{
text-align: center;
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>New Contact Form Submission</h2>
</div>
<div class="content">
<div class="field">
<span class="label">Name:</span>
<div class="value">{contact_data.name}</div>
</div>
<div class="field">
<span class="label">Email:</span>
<div class="value">{contact_data.email}</div>
</div>
{f'<div class="field"><span class="label">Phone:</span><div class="value">{contact_data.phone}</div></div>' if contact_data.phone else ''}
<div class="field">
<span class="label">Subject:</span>
<div class="value">{contact_data.subject}</div>
</div>
<div class="field">
<span class="label">Message:</span>
<div class="value" style="white-space: pre-wrap;">{contact_data.message}</div>
</div>
</div>
<div class="footer">
<p>This email was sent from the hotel booking contact form.</p>
</div>
</div>
</body>
</html>
"""
# Create plain text version
text_body = f"""
New Contact Form Submission
Name: {contact_data.name}
Email: {contact_data.email}
{f'Phone: {contact_data.phone}' if contact_data.phone else ''}
Subject: {contact_data.subject}
Message:
{contact_data.message}
"""
# Send email to admin
await send_email(
to=admin_email,
subject=subject,
html=html_body,
text=text_body
)
logger.info(f"Contact form submitted successfully. Email sent to {admin_email}")
return {
"status": "success",
"message": "Thank you for contacting us! We will get back to you soon."
}
subject = f'Contact Form: {contact_data.subject}'
html_body = f
text_body = f
await send_email(to=admin_email, subject=subject, html=html_body, text=text_body)
logger.info(f'Contact form submitted successfully. Email sent to {admin_email}')
return {'status': 'success', 'message': 'Thank you for contacting us! We will get back to you soon.'}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to submit contact form: {type(e).__name__}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500,
detail="Failed to submit contact form. Please try again later."
)
logger.error(f'Failed to submit contact form: {type(e).__name__}: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='Failed to submit contact form. Please try again later.')

View File

@@ -1,7 +1,6 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy import func
from ..config.database import get_db
from ..middleware.auth import get_current_user
from ..models.user import User
@@ -9,179 +8,74 @@ from ..models.favorite import Favorite
from ..models.room import Room
from ..models.room_type import RoomType
from ..models.review import Review, ReviewStatus
router = APIRouter(prefix='/favorites', tags=['favorites'])
router = APIRouter(prefix="/favorites", tags=["favorites"])
@router.get("/")
async def get_favorites(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get user's favorite rooms"""
@router.get('/')
async def get_favorites(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
if current_user.role in ['admin', 'staff']:
raise HTTPException(status_code=403, detail='Admin and staff users cannot have favorites')
try:
favorites = db.query(Favorite).filter(
Favorite.user_id == current_user.id
).order_by(Favorite.created_at.desc()).all()
favorites = db.query(Favorite).filter(Favorite.user_id == current_user.id).order_by(Favorite.created_at.desc()).all()
result = []
for favorite in favorites:
if not favorite.room:
continue
room = favorite.room
# Get review stats
review_stats = db.query(
func.avg(Review.rating).label('average_rating'),
func.count(Review.id).label('total_reviews')
).filter(
Review.room_id == room.id,
Review.status == ReviewStatus.approved
).first()
room_dict = {
"id": room.id,
"room_type_id": room.room_type_id,
"room_number": room.room_number,
"floor": room.floor,
"status": room.status.value if hasattr(room.status, 'value') else room.status,
"price": float(room.price) if room.price else 0.0,
"featured": room.featured,
"description": room.description,
"amenities": room.amenities,
"images": room.images or [],
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
}
review_stats = db.query(func.avg(Review.rating).label('average_rating'), func.count(Review.id).label('total_reviews')).filter(Review.room_id == room.id, Review.status == ReviewStatus.approved).first()
room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if hasattr(room.status, 'value') else room.status, 'price': float(room.price) if room.price else 0.0, 'featured': room.featured, 'description': room.description, 'amenities': room.amenities, 'images': room.images or [], 'average_rating': round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, 'total_reviews': review_stats.total_reviews or 0 if review_stats else 0}
if room.room_type:
room_dict["room_type"] = {
"id": room.room_type.id,
"name": room.room_type.name,
"description": room.room_type.description,
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
"capacity": room.room_type.capacity,
"amenities": room.room_type.amenities,
}
favorite_dict = {
"id": favorite.id,
"user_id": favorite.user_id,
"room_id": favorite.room_id,
"room": room_dict,
"created_at": favorite.created_at.isoformat() if favorite.created_at else None,
}
room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities}
favorite_dict = {'id': favorite.id, 'user_id': favorite.user_id, 'room_id': favorite.room_id, 'room': room_dict, 'created_at': favorite.created_at.isoformat() if favorite.created_at else None}
result.append(favorite_dict)
return {
"status": "success",
"data": {
"favorites": result,
"total": len(result),
}
}
return {'status': 'success', 'data': {'favorites': result, 'total': len(result)}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{room_id}")
async def add_favorite(
room_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Add room to favorites"""
@router.post('/{room_id}')
async def add_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
if current_user.role in ['admin', 'staff']:
raise HTTPException(status_code=403, detail='Admin and staff users cannot add favorites')
try:
# Check if room exists
room = db.query(Room).filter(Room.id == room_id).first()
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Check if already favorited
existing = db.query(Favorite).filter(
Favorite.user_id == current_user.id,
Favorite.room_id == room_id
).first()
raise HTTPException(status_code=404, detail='Room not found')
existing = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
if existing:
raise HTTPException(
status_code=400,
detail="Room already in favorites list"
)
# Create favorite
favorite = Favorite(
user_id=current_user.id,
room_id=room_id
)
raise HTTPException(status_code=400, detail='Room already in favorites list')
favorite = Favorite(user_id=current_user.id, room_id=room_id)
db.add(favorite)
db.commit()
db.refresh(favorite)
return {
"status": "success",
"message": "Added to favorites list",
"data": {"favorite": favorite}
}
return {'status': 'success', 'message': 'Added to favorites list', 'data': {'favorite': favorite}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{room_id}")
async def remove_favorite(
room_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Remove room from favorites"""
@router.delete('/{room_id}')
async def remove_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
if current_user.role in ['admin', 'staff']:
raise HTTPException(status_code=403, detail='Admin and staff users cannot remove favorites')
try:
favorite = db.query(Favorite).filter(
Favorite.user_id == current_user.id,
Favorite.room_id == room_id
).first()
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
if not favorite:
raise HTTPException(
status_code=404,
detail="Room not found in favorites list"
)
raise HTTPException(status_code=404, detail='Room not found in favorites list')
db.delete(favorite)
db.commit()
return {
"status": "success",
"message": "Removed from favorites list"
}
return {'status': 'success', 'message': 'Removed from favorites list'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/check/{room_id}")
async def check_favorite(
room_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Check if room is favorited by user"""
@router.get('/check/{room_id}')
async def check_favorite(room_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
if current_user.role in ['admin', 'staff']:
return {'status': 'success', 'data': {'isFavorited': False}}
try:
favorite = db.query(Favorite).filter(
Favorite.user_id == current_user.id,
Favorite.room_id == room_id
).first()
return {
"status": "success",
"data": {"isFavorited": favorite is not None}
}
favorite = db.query(Favorite).filter(Favorite.user_id == current_user.id, Favorite.room_id == room_id).first()
return {'status': 'success', 'data': {'isFavorited': favorite is not None}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,63 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/footer", tags=["footer"])
router = APIRouter(prefix='/footer', tags=['footer'])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"social_links": json.loads(content.social_links) if content.social_links else None,
"footer_links": json.loads(content.footer_links) if content.footer_links else None,
"badges": json.loads(content.badges) if content.badges else None,
"copyright_text": content.copyright_text,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None}
@router.get("/")
async def get_footer_content(
db: Session = Depends(get_db)
):
"""Get footer content"""
@router.get('/')
async def get_footer_content(db: Session=Depends(get_db)):
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.FOOTER).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
return {'status': 'success', 'data': {'page_content': None}}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
return {'status': 'success', 'data': {'page_content': content_dict}}
except Exception as e:
logger.error(f"Error fetching footer content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching footer content: {str(e)}"
)
logger.error(f'Error fetching footer content: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching footer content: {str(e)}')

View File

@@ -1,110 +1,23 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
import json
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..models.page_content import PageContent, PageType
logger = get_logger(__name__)
router = APIRouter(prefix="/home", tags=["home"])
router = APIRouter(prefix='/home', tags=['home'])
def serialize_page_content(content: PageContent) -> dict:
"""Serialize PageContent model to dictionary"""
return {
"id": content.id,
"page_type": content.page_type.value,
"title": content.title,
"subtitle": content.subtitle,
"description": content.description,
"content": content.content,
"meta_title": content.meta_title,
"meta_description": content.meta_description,
"meta_keywords": content.meta_keywords,
"og_title": content.og_title,
"og_description": content.og_description,
"og_image": content.og_image,
"canonical_url": content.canonical_url,
"hero_title": content.hero_title,
"hero_subtitle": content.hero_subtitle,
"hero_image": content.hero_image,
"amenities_section_title": content.amenities_section_title,
"amenities_section_subtitle": content.amenities_section_subtitle,
"amenities": json.loads(content.amenities) if content.amenities else None,
"testimonials_section_title": content.testimonials_section_title,
"testimonials_section_subtitle": content.testimonials_section_subtitle,
"testimonials": json.loads(content.testimonials) if content.testimonials else None,
"gallery_section_title": content.gallery_section_title,
"gallery_section_subtitle": content.gallery_section_subtitle,
"gallery_images": json.loads(content.gallery_images) if content.gallery_images else None,
"luxury_section_title": content.luxury_section_title,
"luxury_section_subtitle": content.luxury_section_subtitle,
"luxury_section_image": content.luxury_section_image,
"luxury_features": json.loads(content.luxury_features) if content.luxury_features else None,
"luxury_gallery_section_title": content.luxury_gallery_section_title,
"luxury_gallery_section_subtitle": content.luxury_gallery_section_subtitle,
"luxury_gallery": json.loads(content.luxury_gallery) if content.luxury_gallery else None,
"luxury_testimonials_section_title": content.luxury_testimonials_section_title,
"luxury_testimonials_section_subtitle": content.luxury_testimonials_section_subtitle,
"luxury_testimonials": json.loads(content.luxury_testimonials) if content.luxury_testimonials else None,
"about_preview_title": content.about_preview_title,
"about_preview_subtitle": content.about_preview_subtitle,
"about_preview_content": content.about_preview_content,
"about_preview_image": content.about_preview_image,
"stats": json.loads(content.stats) if content.stats else None,
"luxury_services_section_title": content.luxury_services_section_title,
"luxury_services_section_subtitle": content.luxury_services_section_subtitle,
"luxury_services": json.loads(content.luxury_services) if content.luxury_services else None,
"luxury_experiences_section_title": content.luxury_experiences_section_title,
"luxury_experiences_section_subtitle": content.luxury_experiences_section_subtitle,
"luxury_experiences": json.loads(content.luxury_experiences) if content.luxury_experiences else None,
"awards_section_title": content.awards_section_title,
"awards_section_subtitle": content.awards_section_subtitle,
"awards": json.loads(content.awards) if content.awards else None,
"cta_title": content.cta_title,
"cta_subtitle": content.cta_subtitle,
"cta_button_text": content.cta_button_text,
"cta_button_link": content.cta_button_link,
"cta_image": content.cta_image,
"partners_section_title": content.partners_section_title,
"partners_section_subtitle": content.partners_section_subtitle,
"partners": json.loads(content.partners) if content.partners else None,
"is_active": content.is_active,
"created_at": content.created_at.isoformat() if content.created_at else None,
"updated_at": content.updated_at.isoformat() if content.updated_at else None,
}
return {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None}
@router.get("/")
async def get_home_content(
db: Session = Depends(get_db)
):
"""Get homepage content"""
@router.get('/')
async def get_home_content(db: Session=Depends(get_db)):
try:
content = db.query(PageContent).filter(PageContent.page_type == PageType.HOME).first()
if not content:
return {
"status": "success",
"data": {
"page_content": None
}
}
return {'status': 'success', 'data': {'page_content': None}}
content_dict = serialize_page_content(content)
return {
"status": "success",
"data": {
"page_content": content_dict
}
}
return {'status': 'success', 'data': {'page_content': content_dict}}
except Exception as e:
logger.error(f"Error fetching home content: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error fetching home content: {str(e)}"
)
logger.error(f'Error fetching home content: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching home content: {str(e)}')

View File

@@ -2,139 +2,60 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.invoice import Invoice, InvoiceStatus
from ..models.booking import Booking
from ..services.invoice_service import InvoiceService
router = APIRouter(prefix='/invoices', tags=['invoices'])
router = APIRouter(prefix="/invoices", tags=["invoices"])
@router.get("/")
async def get_invoices(
booking_id: Optional[int] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get invoices for current user (or all invoices for admin)"""
@router.get('/')
async def get_invoices(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
# Admin can see all invoices, users can only see their own
user_id = None if current_user.role_id == 1 else current_user.id
result = InvoiceService.get_invoices(
db=db,
user_id=user_id,
booking_id=booking_id,
status=status_filter,
page=page,
limit=limit
)
return {
"status": "success",
"data": result
}
result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit)
return {'status': 'success', 'data': result}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_invoice_by_id(
id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get invoice by ID"""
@router.get('/{id}')
async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
invoice = InvoiceService.get_invoice(id, db)
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Check access: admin can see all, users can only see their own
if current_user.role_id != 1 and invoice["user_id"] != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return {
"status": "success",
"data": {"invoice": invoice}
}
raise HTTPException(status_code=404, detail='Invoice not found')
if current_user.role_id != 1 and invoice['user_id'] != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden')
return {'status': 'success', 'data': {'invoice': invoice}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/")
async def create_invoice(
invoice_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new invoice from a booking (Admin/Staff only)"""
@router.post('/')
async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
# Only admin/staff can create invoices
if current_user.role_id not in [1, 2]:
raise HTTPException(status_code=403, detail="Forbidden")
booking_id = invoice_data.get("booking_id")
raise HTTPException(status_code=403, detail='Forbidden')
booking_id = invoice_data.get('booking_id')
if not booking_id:
raise HTTPException(status_code=400, detail="booking_id is required")
# Ensure booking_id is an integer
raise HTTPException(status_code=400, detail='booking_id is required')
try:
booking_id = int(booking_id)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="booking_id must be a valid integer")
# Check if booking exists
raise HTTPException(status_code=400, detail='booking_id must be a valid integer')
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Prepare invoice kwargs
invoice_kwargs = {
"company_name": invoice_data.get("company_name"),
"company_address": invoice_data.get("company_address"),
"company_phone": invoice_data.get("company_phone"),
"company_email": invoice_data.get("company_email"),
"company_tax_id": invoice_data.get("company_tax_id"),
"company_logo_url": invoice_data.get("company_logo_url"),
"customer_tax_id": invoice_data.get("customer_tax_id"),
"notes": invoice_data.get("notes"),
"terms_and_conditions": invoice_data.get("terms_and_conditions"),
"payment_instructions": invoice_data.get("payment_instructions"),
}
# Add promotion code to invoice notes if present in booking
invoice_notes = invoice_kwargs.get("notes", "")
raise HTTPException(status_code=404, detail='Booking not found')
invoice_kwargs = {'company_name': invoice_data.get('company_name'), 'company_address': invoice_data.get('company_address'), 'company_phone': invoice_data.get('company_phone'), 'company_email': invoice_data.get('company_email'), 'company_tax_id': invoice_data.get('company_tax_id'), 'company_logo_url': invoice_data.get('company_logo_url'), 'customer_tax_id': invoice_data.get('customer_tax_id'), 'notes': invoice_data.get('notes'), 'terms_and_conditions': invoice_data.get('terms_and_conditions'), 'payment_instructions': invoice_data.get('payment_instructions')}
invoice_notes = invoice_kwargs.get('notes', '')
if booking.promotion_code:
promotion_note = f"Promotion Code: {booking.promotion_code}"
invoice_notes = f"{promotion_note}\n{invoice_notes}".strip() if invoice_notes else promotion_note
invoice_kwargs["notes"] = invoice_notes
# Create invoice
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking_id,
db=db,
created_by_id=current_user.id,
tax_rate=invoice_data.get("tax_rate", 0.0),
discount_amount=invoice_data.get("discount_amount", 0.0),
due_days=invoice_data.get("due_days", 30),
**invoice_kwargs
)
return {
"status": "success",
"message": "Invoice created successfully",
"data": {"invoice": invoice}
}
promotion_note = f'Promotion Code: {booking.promotion_code}'
invoice_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note
invoice_kwargs['notes'] = invoice_notes
invoice = InvoiceService.create_invoice_from_booking(booking_id=booking_id, db=db, created_by_id=current_user.id, tax_rate=invoice_data.get('tax_rate', 0.0), discount_amount=invoice_data.get('discount_amount', 0.0), due_days=invoice_data.get('due_days', 30), **invoice_kwargs)
return {'status': 'success', 'message': 'Invoice created successfully', 'data': {'invoice': invoice}}
except HTTPException:
raise
except ValueError as e:
@@ -142,33 +63,14 @@ async def create_invoice(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}")
async def update_invoice(
id: int,
invoice_data: dict,
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Update an invoice (Admin/Staff only)"""
@router.put('/{id}')
async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
# Update invoice
updated_invoice = InvoiceService.update_invoice(
invoice_id=id,
db=db,
updated_by_id=current_user.id,
**invoice_data
)
return {
"status": "success",
"message": "Invoice updated successfully",
"data": {"invoice": updated_invoice}
}
raise HTTPException(status_code=404, detail='Invoice not found')
updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, **invoice_data)
return {'status': 'success', 'message': 'Invoice updated successfully', 'data': {'invoice': updated_invoice}}
except HTTPException:
raise
except ValueError as e:
@@ -176,30 +78,12 @@ async def update_invoice(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/mark-paid")
async def mark_invoice_as_paid(
id: int,
payment_data: dict,
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Mark an invoice as paid (Admin/Staff only)"""
@router.post('/{id}/mark-paid')
async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
amount = payment_data.get("amount")
updated_invoice = InvoiceService.mark_invoice_as_paid(
invoice_id=id,
db=db,
amount=amount,
updated_by_id=current_user.id
)
return {
"status": "success",
"message": "Invoice marked as paid successfully",
"data": {"invoice": updated_invoice}
}
amount = payment_data.get('amount')
updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id)
return {'status': 'success', 'message': 'Invoice marked as paid successfully', 'data': {'invoice': updated_invoice}}
except HTTPException:
raise
except ValueError as e:
@@ -207,61 +91,32 @@ async def mark_invoice_as_paid(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}")
async def delete_invoice(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete an invoice (Admin only)"""
@router.delete('/{id}')
async def delete_invoice(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
raise HTTPException(status_code=404, detail='Invoice not found')
db.delete(invoice)
db.commit()
return {
"status": "success",
"message": "Invoice deleted successfully"
}
return {'status': 'success', 'message': 'Invoice deleted successfully'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/booking/{booking_id}")
async def get_invoices_by_booking(
booking_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all invoices for a specific booking"""
@router.get('/booking/{booking_id}')
async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
# Check if booking exists and user has access
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Check access: admin can see all, users can only see their own bookings
raise HTTPException(status_code=404, detail='Booking not found')
if current_user.role_id != 1 and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail="Forbidden")
result = InvoiceService.get_invoices(
db=db,
booking_id=booking_id
)
return {
"status": "success",
"data": result
}
raise HTTPException(status_code=403, detail='Forbidden')
result = InvoiceService.get_invoices(db=db, booking_id=booking_id)
return {'status': 'success', 'data': result}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,39 @@
from fastapi import APIRouter, Depends, Request, Response, status
from sqlalchemy.orm import Session
from ..config.database import get_db
from ..config.logging_config import get_logger
from ..config.settings import settings
from ..middleware.cookie_consent import COOKIE_CONSENT_COOKIE_NAME, _parse_consent_cookie
from ..schemas.admin_privacy import PublicPrivacyConfigResponse
from ..schemas.privacy import (
CookieCategoryPreferences,
CookieConsent,
CookieConsentResponse,
UpdateCookieConsentRequest,
)
from ..schemas.privacy import CookieCategoryPreferences, CookieConsent, CookieConsentResponse, UpdateCookieConsentRequest
from ..services.privacy_admin_service import privacy_admin_service
logger = get_logger(__name__)
router = APIRouter(prefix='/privacy', tags=['privacy'])
router = APIRouter(prefix="/privacy", tags=["privacy"])
@router.get(
"/cookie-consent",
response_model=CookieConsentResponse,
status_code=status.HTTP_200_OK,
)
@router.get('/cookie-consent', response_model=CookieConsentResponse, status_code=status.HTTP_200_OK)
async def get_cookie_consent(request: Request) -> CookieConsentResponse:
"""
Return the current cookie consent preferences.
Reads from the cookie (if present) or returns default (necessary only).
"""
raw_cookie = request.cookies.get(COOKIE_CONSENT_COOKIE_NAME)
consent = _parse_consent_cookie(raw_cookie)
# Ensure necessary is always true
consent.categories.necessary = True
return CookieConsentResponse(data=consent)
@router.post(
"/cookie-consent",
response_model=CookieConsentResponse,
status_code=status.HTTP_200_OK,
)
async def update_cookie_consent(
request: UpdateCookieConsentRequest, response: Response
) -> CookieConsentResponse:
"""
Update cookie consent preferences.
The 'necessary' category is controlled by the server and always true.
"""
# Build categories from existing cookie (if any) so partial updates work
existing_raw = response.headers.get("cookie") # usually empty here
# We can't reliably read cookies from the response; rely on defaults.
# For the purposes of this API, we always start from defaults and then
# override with the request payload.
@router.post('/cookie-consent', response_model=CookieConsentResponse, status_code=status.HTTP_200_OK)
async def update_cookie_consent(request: UpdateCookieConsentRequest, response: Response) -> CookieConsentResponse:
existing_raw = response.headers.get('cookie')
categories = CookieCategoryPreferences()
if request.analytics is not None:
categories.analytics = request.analytics
if request.marketing is not None:
categories.marketing = request.marketing
if request.preferences is not None:
categories.preferences = request.preferences
# 'necessary' enforced server-side
categories.necessary = True
consent = CookieConsent(categories=categories, has_decided=True)
# Persist consent as a secure, HttpOnly cookie
response.set_cookie(
key=COOKIE_CONSENT_COOKIE_NAME,
value=consent.model_dump_json(),
httponly=True,
secure=settings.is_production,
samesite="lax",
max_age=365 * 24 * 60 * 60, # 1 year
path="/",
)
logger.info(
"Cookie consent updated: analytics=%s, marketing=%s, preferences=%s",
consent.categories.analytics,
consent.categories.marketing,
consent.categories.preferences,
)
response.set_cookie(key=COOKIE_CONSENT_COOKIE_NAME, value=consent.model_dump_json(), httponly=True, secure=settings.is_production, samesite='lax', max_age=365 * 24 * 60 * 60, path='/')
logger.info('Cookie consent updated: analytics=%s, marketing=%s, preferences=%s', consent.categories.analytics, consent.categories.marketing, consent.categories.preferences)
return CookieConsentResponse(data=consent)
@router.get(
"/config",
response_model=PublicPrivacyConfigResponse,
status_code=status.HTTP_200_OK,
)
async def get_public_privacy_config(
db: Session = Depends(get_db),
) -> PublicPrivacyConfigResponse:
"""
Public privacy configuration for the frontend:
- Global policy flags
- Public integration IDs (e.g. GA measurement ID)
"""
@router.get('/config', response_model=PublicPrivacyConfigResponse, status_code=status.HTTP_200_OK)
async def get_public_privacy_config(db: Session=Depends(get_db)) -> PublicPrivacyConfigResponse:
config = privacy_admin_service.get_public_privacy_config(db)
return PublicPrivacyConfigResponse(data=config)
return PublicPrivacyConfigResponse(data=config)

View File

@@ -3,346 +3,158 @@ from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional
from datetime import datetime
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.promotion import Promotion, DiscountType
router = APIRouter(prefix='/promotions', tags=['promotions'])
router = APIRouter(prefix="/promotions", tags=["promotions"])
@router.get("/")
async def get_promotions(
search: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
type: Optional[str] = Query(None),
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
"""Get all promotions with filters"""
@router.get('/')
async def get_promotions(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), type: Optional[str]=Query(None), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), db: Session=Depends(get_db)):
try:
query = db.query(Promotion)
# Filter by search (code or name)
if search:
query = query.filter(
or_(
Promotion.code.like(f"%{search}%"),
Promotion.name.like(f"%{search}%")
)
)
# Filter by status (is_active)
query = query.filter(or_(Promotion.code.like(f'%{search}%'), Promotion.name.like(f'%{search}%')))
if status_filter:
is_active = status_filter == "active"
is_active = status_filter == 'active'
query = query.filter(Promotion.is_active == is_active)
# Filter by discount type
if type:
try:
query = query.filter(Promotion.discount_type == DiscountType(type))
except ValueError:
pass
total = query.count()
offset = (page - 1) * limit
promotions = query.order_by(Promotion.created_at.desc()).offset(offset).limit(limit).all()
result = []
for promo in promotions:
promo_dict = {
"id": promo.id,
"code": promo.code,
"name": promo.name,
"description": promo.description,
"discount_type": promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type,
"discount_value": float(promo.discount_value) if promo.discount_value else 0.0,
"min_booking_amount": float(promo.min_booking_amount) if promo.min_booking_amount else None,
"max_discount_amount": float(promo.max_discount_amount) if promo.max_discount_amount else None,
"start_date": promo.start_date.isoformat() if promo.start_date else None,
"end_date": promo.end_date.isoformat() if promo.end_date else None,
"usage_limit": promo.usage_limit,
"used_count": promo.used_count,
"is_active": promo.is_active,
"created_at": promo.created_at.isoformat() if promo.created_at else None,
}
promo_dict = {'id': promo.id, 'code': promo.code, 'name': promo.name, 'description': promo.description, 'discount_type': promo.discount_type.value if isinstance(promo.discount_type, DiscountType) else promo.discount_type, 'discount_value': float(promo.discount_value) if promo.discount_value else 0.0, 'min_booking_amount': float(promo.min_booking_amount) if promo.min_booking_amount else None, 'max_discount_amount': float(promo.max_discount_amount) if promo.max_discount_amount else None, 'start_date': promo.start_date.isoformat() if promo.start_date else None, 'end_date': promo.end_date.isoformat() if promo.end_date else None, 'usage_limit': promo.usage_limit, 'used_count': promo.used_count, 'is_active': promo.is_active, 'created_at': promo.created_at.isoformat() if promo.created_at else None}
result.append(promo_dict)
return {
"status": "success",
"data": {
"promotions": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
return {'status': 'success', 'data': {'promotions': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{code}")
async def get_promotion_by_code(code: str, db: Session = Depends(get_db)):
"""Get promotion by code"""
@router.get('/{code}')
async def get_promotion_by_code(code: str, db: Session=Depends(get_db)):
try:
promotion = db.query(Promotion).filter(Promotion.code == code).first()
if not promotion:
raise HTTPException(status_code=404, detail="Promotion not found")
promo_dict = {
"id": promotion.id,
"code": promotion.code,
"name": promotion.name,
"description": promotion.description,
"discount_type": promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type,
"discount_value": float(promotion.discount_value) if promotion.discount_value else 0.0,
"min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None,
"max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None,
"start_date": promotion.start_date.isoformat() if promotion.start_date else None,
"end_date": promotion.end_date.isoformat() if promotion.end_date else None,
"usage_limit": promotion.usage_limit,
"used_count": promotion.used_count,
"is_active": promotion.is_active,
}
return {
"status": "success",
"data": {"promotion": promo_dict}
}
raise HTTPException(status_code=404, detail='Promotion not found')
promo_dict = {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type, 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0.0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'is_active': promotion.is_active}
return {'status': 'success', 'data': {'promotion': promo_dict}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/validate")
async def validate_promotion(
validation_data: dict,
db: Session = Depends(get_db)
):
"""Validate and apply promotion"""
@router.post('/validate')
async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)):
try:
code = validation_data.get("code")
# Accept both booking_value (from frontend) and booking_amount (for backward compatibility)
booking_amount = float(validation_data.get("booking_value") or validation_data.get("booking_amount", 0))
code = validation_data.get('code')
booking_amount = float(validation_data.get('booking_value') or validation_data.get('booking_amount', 0))
promotion = db.query(Promotion).filter(Promotion.code == code).first()
if not promotion:
raise HTTPException(status_code=404, detail="Promotion code not found")
# Check if promotion is active
raise HTTPException(status_code=404, detail='Promotion code not found')
if not promotion.is_active:
raise HTTPException(status_code=400, detail="Promotion is not active")
# Check date validity
raise HTTPException(status_code=400, detail='Promotion is not active')
now = datetime.utcnow()
if promotion.start_date and now < promotion.start_date:
raise HTTPException(status_code=400, detail="Promotion is not valid at this time")
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
if promotion.end_date and now > promotion.end_date:
raise HTTPException(status_code=400, detail="Promotion is not valid at this time")
# Check usage limit
raise HTTPException(status_code=400, detail='Promotion is not valid at this time')
if promotion.usage_limit and promotion.used_count >= promotion.usage_limit:
raise HTTPException(status_code=400, detail="Promotion usage limit reached")
# Check minimum booking amount
raise HTTPException(status_code=400, detail='Promotion usage limit reached')
if promotion.min_booking_amount and booking_amount < float(promotion.min_booking_amount):
raise HTTPException(
status_code=400,
detail=f"Minimum booking amount is {promotion.min_booking_amount}"
)
# Calculate discount
raise HTTPException(status_code=400, detail=f'Minimum booking amount is {promotion.min_booking_amount}')
discount_amount = promotion.calculate_discount(booking_amount)
final_amount = booking_amount - discount_amount
return {
"success": True,
"status": "success",
"data": {
"promotion": {
"id": promotion.id,
"code": promotion.code,
"name": promotion.name,
"description": promotion.description,
"discount_type": promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type),
"discount_value": float(promotion.discount_value) if promotion.discount_value else 0,
"min_booking_amount": float(promotion.min_booking_amount) if promotion.min_booking_amount else None,
"max_discount_amount": float(promotion.max_discount_amount) if promotion.max_discount_amount else None,
"start_date": promotion.start_date.isoformat() if promotion.start_date else None,
"end_date": promotion.end_date.isoformat() if promotion.end_date else None,
"usage_limit": promotion.usage_limit,
"used_count": promotion.used_count,
"status": "active" if promotion.is_active else "inactive",
},
"discount": discount_amount,
"original_amount": booking_amount,
"discount_amount": discount_amount,
"final_amount": final_amount,
},
"message": "Promotion validated successfully"
}
return {'success': True, 'status': 'success', 'data': {'promotion': {'id': promotion.id, 'code': promotion.code, 'name': promotion.name, 'description': promotion.description, 'discount_type': promotion.discount_type.value if hasattr(promotion.discount_type, 'value') else str(promotion.discount_type), 'discount_value': float(promotion.discount_value) if promotion.discount_value else 0, 'min_booking_amount': float(promotion.min_booking_amount) if promotion.min_booking_amount else None, 'max_discount_amount': float(promotion.max_discount_amount) if promotion.max_discount_amount else None, 'start_date': promotion.start_date.isoformat() if promotion.start_date else None, 'end_date': promotion.end_date.isoformat() if promotion.end_date else None, 'usage_limit': promotion.usage_limit, 'used_count': promotion.used_count, 'status': 'active' if promotion.is_active else 'inactive'}, 'discount': discount_amount, 'original_amount': booking_amount, 'discount_amount': discount_amount, 'final_amount': final_amount}, 'message': 'Promotion validated successfully'}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
async def create_promotion(
promotion_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Create new promotion (Admin only)"""
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
async def create_promotion(promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
code = promotion_data.get("code")
# Check if code exists
code = promotion_data.get('code')
existing = db.query(Promotion).filter(Promotion.code == code).first()
if existing:
raise HTTPException(status_code=400, detail="Promotion code already exists")
discount_type = promotion_data.get("discount_type")
discount_value = float(promotion_data.get("discount_value", 0))
# Validate discount value
if discount_type == "percentage" and discount_value > 100:
raise HTTPException(
status_code=400,
detail="Percentage discount cannot exceed 100%"
)
promotion = Promotion(
code=code,
name=promotion_data.get("name"),
description=promotion_data.get("description"),
discount_type=DiscountType(discount_type),
discount_value=discount_value,
min_booking_amount=float(promotion_data["min_booking_amount"]) if promotion_data.get("min_booking_amount") else None,
max_discount_amount=float(promotion_data["max_discount_amount"]) if promotion_data.get("max_discount_amount") else None,
start_date=datetime.fromisoformat(promotion_data["start_date"].replace('Z', '+00:00')) if promotion_data.get("start_date") else None,
end_date=datetime.fromisoformat(promotion_data["end_date"].replace('Z', '+00:00')) if promotion_data.get("end_date") else None,
usage_limit=promotion_data.get("usage_limit"),
used_count=0,
is_active=promotion_data.get("status") == "active" if promotion_data.get("status") else True,
)
raise HTTPException(status_code=400, detail='Promotion code already exists')
discount_type = promotion_data.get('discount_type')
discount_value = float(promotion_data.get('discount_value', 0))
if discount_type == 'percentage' and discount_value > 100:
raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%')
promotion = Promotion(code=code, name=promotion_data.get('name'), description=promotion_data.get('description'), discount_type=DiscountType(discount_type), discount_value=discount_value, min_booking_amount=float(promotion_data['min_booking_amount']) if promotion_data.get('min_booking_amount') else None, max_discount_amount=float(promotion_data['max_discount_amount']) if promotion_data.get('max_discount_amount') else None, start_date=datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data.get('start_date') else None, end_date=datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data.get('end_date') else None, usage_limit=promotion_data.get('usage_limit'), used_count=0, is_active=promotion_data.get('status') == 'active' if promotion_data.get('status') else True)
db.add(promotion)
db.commit()
db.refresh(promotion)
return {
"status": "success",
"message": "Promotion created successfully",
"data": {"promotion": promotion}
}
return {'status': 'success', 'message': 'Promotion created successfully', 'data': {'promotion': promotion}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def update_promotion(
id: int,
promotion_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update promotion (Admin only)"""
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def update_promotion(id: int, promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
promotion = db.query(Promotion).filter(Promotion.id == id).first()
if not promotion:
raise HTTPException(status_code=404, detail="Promotion not found")
# Check if new code exists (excluding current)
code = promotion_data.get("code")
raise HTTPException(status_code=404, detail='Promotion not found')
code = promotion_data.get('code')
if code and code != promotion.code:
existing = db.query(Promotion).filter(
Promotion.code == code,
Promotion.id != id
).first()
existing = db.query(Promotion).filter(Promotion.code == code, Promotion.id != id).first()
if existing:
raise HTTPException(status_code=400, detail="Promotion code already exists")
# Validate discount value
discount_type = promotion_data.get("discount_type", promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type)
discount_value = promotion_data.get("discount_value")
raise HTTPException(status_code=400, detail='Promotion code already exists')
discount_type = promotion_data.get('discount_type', promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type)
discount_value = promotion_data.get('discount_value')
if discount_value is not None:
discount_value = float(discount_value)
if discount_type == "percentage" and discount_value > 100:
raise HTTPException(
status_code=400,
detail="Percentage discount cannot exceed 100%"
)
# Update fields
if "code" in promotion_data:
promotion.code = promotion_data["code"]
if "name" in promotion_data:
promotion.name = promotion_data["name"]
if "description" in promotion_data:
promotion.description = promotion_data["description"]
if "discount_type" in promotion_data:
promotion.discount_type = DiscountType(promotion_data["discount_type"])
if "discount_value" in promotion_data:
if discount_type == 'percentage' and discount_value > 100:
raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%')
if 'code' in promotion_data:
promotion.code = promotion_data['code']
if 'name' in promotion_data:
promotion.name = promotion_data['name']
if 'description' in promotion_data:
promotion.description = promotion_data['description']
if 'discount_type' in promotion_data:
promotion.discount_type = DiscountType(promotion_data['discount_type'])
if 'discount_value' in promotion_data:
promotion.discount_value = discount_value
if "min_booking_amount" in promotion_data:
promotion.min_booking_amount = float(promotion_data["min_booking_amount"]) if promotion_data["min_booking_amount"] else None
if "max_discount_amount" in promotion_data:
promotion.max_discount_amount = float(promotion_data["max_discount_amount"]) if promotion_data["max_discount_amount"] else None
if "start_date" in promotion_data:
promotion.start_date = datetime.fromisoformat(promotion_data["start_date"].replace('Z', '+00:00')) if promotion_data["start_date"] else None
if "end_date" in promotion_data:
promotion.end_date = datetime.fromisoformat(promotion_data["end_date"].replace('Z', '+00:00')) if promotion_data["end_date"] else None
if "usage_limit" in promotion_data:
promotion.usage_limit = promotion_data["usage_limit"]
if "status" in promotion_data:
promotion.is_active = promotion_data["status"] == "active"
if 'min_booking_amount' in promotion_data:
promotion.min_booking_amount = float(promotion_data['min_booking_amount']) if promotion_data['min_booking_amount'] else None
if 'max_discount_amount' in promotion_data:
promotion.max_discount_amount = float(promotion_data['max_discount_amount']) if promotion_data['max_discount_amount'] else None
if 'start_date' in promotion_data:
promotion.start_date = datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data['start_date'] else None
if 'end_date' in promotion_data:
promotion.end_date = datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data['end_date'] else None
if 'usage_limit' in promotion_data:
promotion.usage_limit = promotion_data['usage_limit']
if 'status' in promotion_data:
promotion.is_active = promotion_data['status'] == 'active'
db.commit()
db.refresh(promotion)
return {
"status": "success",
"message": "Promotion updated successfully",
"data": {"promotion": promotion}
}
return {'status': 'success', 'message': 'Promotion updated successfully', 'data': {'promotion': promotion}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def delete_promotion(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete promotion (Admin only)"""
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def delete_promotion(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
promotion = db.query(Promotion).filter(Promotion.id == id).first()
if not promotion:
raise HTTPException(status_code=404, detail="Promotion not found")
raise HTTPException(status_code=404, detail='Promotion not found')
db.delete(promotion)
db.commit()
return {
"status": "success",
"message": "Promotion deleted successfully"
}
return {'status': 'success', 'message': 'Promotion deleted successfully'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -3,7 +3,6 @@ from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from typing import Optional
from datetime import datetime, timedelta
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
@@ -12,55 +11,34 @@ from ..models.payment import Payment, PaymentStatus
from ..models.room import Room
from ..models.service_usage import ServiceUsage
from ..models.service import Service
router = APIRouter(prefix='/reports', tags=['reports'])
router = APIRouter(prefix="/reports", tags=["reports"])
@router.get("")
async def get_reports(
from_date: Optional[str] = Query(None, alias="from"),
to_date: Optional[str] = Query(None, alias="to"),
type: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Get comprehensive reports (Admin/Staff only)"""
@router.get('')
async def get_reports(from_date: Optional[str]=Query(None, alias='from'), to_date: Optional[str]=Query(None, alias='to'), type: Optional[str]=Query(None), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
# Parse dates if provided
start_date = None
end_date = None
if from_date:
try:
start_date = datetime.strptime(from_date, "%Y-%m-%d")
start_date = datetime.strptime(from_date, '%Y-%m-%d')
except ValueError:
start_date = datetime.fromisoformat(from_date.replace('Z', '+00:00'))
if to_date:
try:
end_date = datetime.strptime(to_date, "%Y-%m-%d")
# Set to end of day
end_date = datetime.strptime(to_date, '%Y-%m-%d')
end_date = end_date.replace(hour=23, minute=59, second=59)
except ValueError:
end_date = datetime.fromisoformat(to_date.replace('Z', '+00:00'))
# Base queries
booking_query = db.query(Booking)
payment_query = db.query(Payment).filter(Payment.payment_status == PaymentStatus.completed)
# Apply date filters
if start_date:
booking_query = booking_query.filter(Booking.created_at >= start_date)
payment_query = payment_query.filter(Payment.payment_date >= start_date)
if end_date:
booking_query = booking_query.filter(Booking.created_at <= end_date)
payment_query = payment_query.filter(Payment.payment_date <= end_date)
# Total bookings
total_bookings = booking_query.count()
# Total revenue
total_revenue = payment_query.with_entities(func.sum(Payment.amount)).scalar() or 0.0
# Total customers (unique users with bookings)
total_customers = db.query(func.count(func.distinct(Booking.user_id))).scalar() or 0
if start_date or end_date:
customer_query = db.query(func.count(func.distinct(Booking.user_id)))
@@ -69,415 +47,126 @@ async def get_reports(
if end_date:
customer_query = customer_query.filter(Booking.created_at <= end_date)
total_customers = customer_query.scalar() or 0
# Available rooms
available_rooms = db.query(Room).filter(Room.status == "available").count()
# Occupied rooms (rooms with active bookings)
occupied_rooms = db.query(func.count(func.distinct(Booking.room_id))).filter(
Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in])
).scalar() or 0
# Revenue by date (daily breakdown)
available_rooms = db.query(Room).filter(Room.status == 'available').count()
occupied_rooms = db.query(func.count(func.distinct(Booking.room_id))).filter(Booking.status.in_([BookingStatus.confirmed, BookingStatus.checked_in])).scalar() or 0
revenue_by_date = []
if start_date and end_date:
daily_revenue_query = db.query(
func.date(Payment.payment_date).label('date'),
func.sum(Payment.amount).label('revenue'),
func.count(func.distinct(Payment.booking_id)).label('bookings')
).filter(Payment.payment_status == PaymentStatus.completed)
daily_revenue_query = db.query(func.date(Payment.payment_date).label('date'), func.sum(Payment.amount).label('revenue'), func.count(func.distinct(Payment.booking_id)).label('bookings')).filter(Payment.payment_status == PaymentStatus.completed)
if start_date:
daily_revenue_query = daily_revenue_query.filter(Payment.payment_date >= start_date)
if end_date:
daily_revenue_query = daily_revenue_query.filter(Payment.payment_date <= end_date)
daily_revenue_query = daily_revenue_query.group_by(
func.date(Payment.payment_date)
).order_by(func.date(Payment.payment_date))
daily_revenue_query = daily_revenue_query.group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date))
daily_data = daily_revenue_query.all()
revenue_by_date = [
{
"date": str(date),
"revenue": float(revenue or 0),
"bookings": int(bookings or 0)
}
for date, revenue, bookings in daily_data
]
# Bookings by status
revenue_by_date = [{'date': str(date), 'revenue': float(revenue or 0), 'bookings': int(bookings or 0)} for date, revenue, bookings in daily_data]
bookings_by_status = {}
for status in BookingStatus:
count = booking_query.filter(Booking.status == status).count()
status_name = status.value if hasattr(status, 'value') else str(status)
bookings_by_status[status_name] = count
# Top rooms (by revenue)
top_rooms_query = db.query(
Room.id,
Room.room_number,
func.count(Booking.id).label('bookings'),
func.sum(Payment.amount).label('revenue')
).join(Booking, Room.id == Booking.room_id).join(
Payment, Booking.id == Payment.booking_id
).filter(Payment.payment_status == PaymentStatus.completed)
top_rooms_query = db.query(Room.id, Room.room_number, func.count(Booking.id).label('bookings'), func.sum(Payment.amount).label('revenue')).join(Booking, Room.id == Booking.room_id).join(Payment, Booking.id == Payment.booking_id).filter(Payment.payment_status == PaymentStatus.completed)
if start_date:
top_rooms_query = top_rooms_query.filter(Booking.created_at >= start_date)
if end_date:
top_rooms_query = top_rooms_query.filter(Booking.created_at <= end_date)
top_rooms_data = top_rooms_query.group_by(Room.id, Room.room_number).order_by(
func.sum(Payment.amount).desc()
).limit(10).all()
top_rooms = [
{
"room_id": room_id,
"room_number": room_number,
"bookings": int(bookings or 0),
"revenue": float(revenue or 0)
}
for room_id, room_number, bookings, revenue in top_rooms_data
]
# Service usage statistics
service_usage_query = db.query(
Service.id,
Service.name,
func.count(ServiceUsage.id).label('usage_count'),
func.sum(ServiceUsage.total_price).label('total_revenue')
).join(ServiceUsage, Service.id == ServiceUsage.service_id)
top_rooms_data = top_rooms_query.group_by(Room.id, Room.room_number).order_by(func.sum(Payment.amount).desc()).limit(10).all()
top_rooms = [{'room_id': room_id, 'room_number': room_number, 'bookings': int(bookings or 0), 'revenue': float(revenue or 0)} for room_id, room_number, bookings, revenue in top_rooms_data]
service_usage_query = db.query(Service.id, Service.name, func.count(ServiceUsage.id).label('usage_count'), func.sum(ServiceUsage.total_price).label('total_revenue')).join(ServiceUsage, Service.id == ServiceUsage.service_id)
if start_date:
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date >= start_date)
if end_date:
service_usage_query = service_usage_query.filter(ServiceUsage.usage_date <= end_date)
service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by(
func.sum(ServiceUsage.total_price).desc()
).limit(10).all()
service_usage = [
{
"service_id": service_id,
"service_name": service_name,
"usage_count": int(usage_count or 0),
"total_revenue": float(total_revenue or 0)
}
for service_id, service_name, usage_count, total_revenue in service_usage_data
]
return {
"status": "success",
"success": True,
"data": {
"total_bookings": total_bookings,
"total_revenue": float(total_revenue),
"total_customers": int(total_customers),
"available_rooms": available_rooms,
"occupied_rooms": occupied_rooms,
"revenue_by_date": revenue_by_date if revenue_by_date else None,
"bookings_by_status": bookings_by_status,
"top_rooms": top_rooms if top_rooms else None,
"service_usage": service_usage if service_usage else None,
}
}
service_usage_data = service_usage_query.group_by(Service.id, Service.name).order_by(func.sum(ServiceUsage.total_price).desc()).limit(10).all()
service_usage = [{'service_id': service_id, 'service_name': service_name, 'usage_count': int(usage_count or 0), 'total_revenue': float(total_revenue or 0)} for service_id, service_name, usage_count, total_revenue in service_usage_data]
return {'status': 'success', 'success': True, 'data': {'total_bookings': total_bookings, 'total_revenue': float(total_revenue), 'total_customers': int(total_customers), 'available_rooms': available_rooms, 'occupied_rooms': occupied_rooms, 'revenue_by_date': revenue_by_date if revenue_by_date else None, 'bookings_by_status': bookings_by_status, 'top_rooms': top_rooms if top_rooms else None, 'service_usage': service_usage if service_usage else None}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/dashboard")
async def get_dashboard_stats(
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Get dashboard statistics (Admin/Staff only)"""
@router.get('/dashboard')
async def get_dashboard_stats(current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
# Total bookings
total_bookings = db.query(Booking).count()
# Active bookings
active_bookings = db.query(Booking).filter(
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
).count()
# Total revenue (from completed payments)
total_revenue = db.query(func.sum(Payment.amount)).filter(
Payment.payment_status == PaymentStatus.completed
).scalar() or 0.0
# Today's revenue
active_bookings = db.query(Booking).filter(Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count()
total_revenue = db.query(func.sum(Payment.amount)).filter(Payment.payment_status == PaymentStatus.completed).scalar() or 0.0
today_start = datetime.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
today_revenue = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= today_start
)
).scalar() or 0.0
# Total rooms
today_revenue = db.query(func.sum(Payment.amount)).filter(and_(Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= today_start)).scalar() or 0.0
total_rooms = db.query(Room).count()
# Available rooms
available_rooms = db.query(Room).filter(Room.status == "available").count()
# Recent bookings (last 7 days)
available_rooms = db.query(Room).filter(Room.status == 'available').count()
week_ago = datetime.utcnow() - timedelta(days=7)
recent_bookings = db.query(Booking).filter(
Booking.created_at >= week_ago
).count()
# Pending payments
pending_payments = db.query(Payment).filter(
Payment.payment_status == PaymentStatus.pending
).count()
return {
"status": "success",
"data": {
"total_bookings": total_bookings,
"active_bookings": active_bookings,
"total_revenue": float(total_revenue),
"today_revenue": float(today_revenue),
"total_rooms": total_rooms,
"available_rooms": available_rooms,
"recent_bookings": recent_bookings,
"pending_payments": pending_payments,
}
}
recent_bookings = db.query(Booking).filter(Booking.created_at >= week_ago).count()
pending_payments = db.query(Payment).filter(Payment.payment_status == PaymentStatus.pending).count()
return {'status': 'success', 'data': {'total_bookings': total_bookings, 'active_bookings': active_bookings, 'total_revenue': float(total_revenue), 'today_revenue': float(today_revenue), 'total_rooms': total_rooms, 'available_rooms': available_rooms, 'recent_bookings': recent_bookings, 'pending_payments': pending_payments}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/customer/dashboard")
async def get_customer_dashboard_stats(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get customer dashboard statistics"""
@router.get('/customer/dashboard')
async def get_customer_dashboard_stats(current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
from datetime import datetime, timedelta
# Total bookings count for user
total_bookings = db.query(Booking).filter(
Booking.user_id == current_user.id
).count()
# Total spending (sum of completed payments from user's bookings)
user_bookings = db.query(Booking.id).filter(
Booking.user_id == current_user.id
).subquery()
total_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed
)
).scalar() or 0.0
# Currently staying (checked_in bookings)
total_bookings = db.query(Booking).filter(Booking.user_id == current_user.id).count()
user_bookings = db.query(Booking.id).filter(Booking.user_id == current_user.id).subquery()
total_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed)).scalar() or 0.0
now = datetime.utcnow()
currently_staying = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.status == BookingStatus.checked_in,
Booking.check_in_date <= now,
Booking.check_out_date >= now
)
).count()
# Upcoming bookings (confirmed/pending with check_in_date in future)
upcoming_bookings_query = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]),
Booking.check_in_date > now
)
).order_by(Booking.check_in_date.asc()).limit(5).all()
currently_staying = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status == BookingStatus.checked_in, Booking.check_in_date <= now, Booking.check_out_date >= now)).count()
upcoming_bookings_query = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.status.in_([BookingStatus.confirmed, BookingStatus.pending]), Booking.check_in_date > now)).order_by(Booking.check_in_date.asc()).limit(5).all()
upcoming_bookings = []
for booking in upcoming_bookings_query:
booking_dict = {
"id": booking.id,
"booking_number": booking.booking_number,
"check_in_date": booking.check_in_date.isoformat() if booking.check_in_date else None,
"check_out_date": booking.check_out_date.isoformat() if booking.check_out_date else None,
"status": booking.status.value if isinstance(booking.status, BookingStatus) else booking.status,
"total_price": float(booking.total_price) if booking.total_price else 0.0,
}
booking_dict = {'id': booking.id, 'booking_number': booking.booking_number, 'check_in_date': booking.check_in_date.isoformat() if booking.check_in_date else None, 'check_out_date': booking.check_out_date.isoformat() if booking.check_out_date else None, 'status': booking.status.value if isinstance(booking.status, BookingStatus) else booking.status, 'total_price': float(booking.total_price) if booking.total_price else 0.0}
if booking.room:
booking_dict["room"] = {
"id": booking.room.id,
"room_number": booking.room.room_number,
"room_type": {
"name": booking.room.room_type.name if booking.room.room_type else None
}
}
booking_dict['room'] = {'id': booking.room.id, 'room_number': booking.room.room_number, 'room_type': {'name': booking.room.room_type.name if booking.room.room_type else None}}
upcoming_bookings.append(booking_dict)
# Recent activity (last 5 bookings ordered by created_at)
recent_bookings_query = db.query(Booking).filter(
Booking.user_id == current_user.id
).order_by(Booking.created_at.desc()).limit(5).all()
recent_bookings_query = db.query(Booking).filter(Booking.user_id == current_user.id).order_by(Booking.created_at.desc()).limit(5).all()
recent_activity = []
for booking in recent_bookings_query:
activity_type = None
if booking.status == BookingStatus.checked_out:
activity_type = "Check-out"
activity_type = 'Check-out'
elif booking.status == BookingStatus.checked_in:
activity_type = "Check-in"
activity_type = 'Check-in'
elif booking.status == BookingStatus.confirmed:
activity_type = "Booking Confirmed"
activity_type = 'Booking Confirmed'
elif booking.status == BookingStatus.pending:
activity_type = "Booking"
activity_type = 'Booking'
else:
activity_type = "Booking"
activity_dict = {
"action": activity_type,
"booking_id": booking.id,
"booking_number": booking.booking_number,
"created_at": booking.created_at.isoformat() if booking.created_at else None,
}
activity_type = 'Booking'
activity_dict = {'action': activity_type, 'booking_id': booking.id, 'booking_number': booking.booking_number, 'created_at': booking.created_at.isoformat() if booking.created_at else None}
if booking.room:
activity_dict["room"] = {
"room_number": booking.room.room_number,
}
activity_dict['room'] = {'room_number': booking.room.room_number}
recent_activity.append(activity_dict)
# Calculate percentage change (placeholder - can be enhanced)
# For now, compare last month vs this month
last_month_start = (now - timedelta(days=30)).replace(day=1, hour=0, minute=0, second=0)
last_month_end = now.replace(day=1, hour=0, minute=0, second=0) - timedelta(seconds=1)
last_month_bookings = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.created_at >= last_month_start,
Booking.created_at <= last_month_end
)
).count()
this_month_bookings = db.query(Booking).filter(
and_(
Booking.user_id == current_user.id,
Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0),
Booking.created_at <= now
)
).count()
last_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= last_month_start, Booking.created_at <= last_month_end)).count()
this_month_bookings = db.query(Booking).filter(and_(Booking.user_id == current_user.id, Booking.created_at >= now.replace(day=1, hour=0, minute=0, second=0), Booking.created_at <= now)).count()
booking_change_percentage = 0
if last_month_bookings > 0:
booking_change_percentage = ((this_month_bookings - last_month_bookings) / last_month_bookings) * 100
last_month_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= last_month_start,
Payment.payment_date <= last_month_end
)
).scalar() or 0.0
this_month_spending = db.query(func.sum(Payment.amount)).filter(
and_(
Payment.booking_id.in_(db.query(user_bookings.c.id)),
Payment.payment_status == PaymentStatus.completed,
Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0),
Payment.payment_date <= now
)
).scalar() or 0.0
booking_change_percentage = (this_month_bookings - last_month_bookings) / last_month_bookings * 100
last_month_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= last_month_start, Payment.payment_date <= last_month_end)).scalar() or 0.0
this_month_spending = db.query(func.sum(Payment.amount)).filter(and_(Payment.booking_id.in_(db.query(user_bookings.c.id)), Payment.payment_status == PaymentStatus.completed, Payment.payment_date >= now.replace(day=1, hour=0, minute=0, second=0), Payment.payment_date <= now)).scalar() or 0.0
spending_change_percentage = 0
if last_month_spending > 0:
spending_change_percentage = ((this_month_spending - last_month_spending) / last_month_spending) * 100
return {
"status": "success",
"success": True,
"data": {
"total_bookings": total_bookings,
"total_spending": float(total_spending),
"currently_staying": currently_staying,
"upcoming_bookings": upcoming_bookings,
"recent_activity": recent_activity,
"booking_change_percentage": round(booking_change_percentage, 1),
"spending_change_percentage": round(spending_change_percentage, 1),
}
}
spending_change_percentage = (this_month_spending - last_month_spending) / last_month_spending * 100
return {'status': 'success', 'success': True, 'data': {'total_bookings': total_bookings, 'total_spending': float(total_spending), 'currently_staying': currently_staying, 'upcoming_bookings': upcoming_bookings, 'recent_activity': recent_activity, 'booking_change_percentage': round(booking_change_percentage, 1), 'spending_change_percentage': round(spending_change_percentage, 1)}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/revenue")
async def get_revenue_report(
start_date: Optional[str] = Query(None),
end_date: Optional[str] = Query(None),
current_user: User = Depends(authorize_roles("admin", "staff")),
db: Session = Depends(get_db)
):
"""Get revenue report (Admin/Staff only)"""
@router.get('/revenue')
async def get_revenue_report(start_date: Optional[str]=Query(None), end_date: Optional[str]=Query(None), current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
try:
query = db.query(Payment).filter(
Payment.payment_status == PaymentStatus.completed
)
query = db.query(Payment).filter(Payment.payment_status == PaymentStatus.completed)
if start_date:
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
query = query.filter(Payment.payment_date >= start)
if end_date:
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
query = query.filter(Payment.payment_date <= end)
# Total revenue
total_revenue = db.query(func.sum(Payment.amount)).filter(
Payment.payment_status == PaymentStatus.completed
).scalar() or 0.0
# Revenue by payment method
revenue_by_method = db.query(
Payment.payment_method,
func.sum(Payment.amount).label('total')
).filter(
Payment.payment_status == PaymentStatus.completed
).group_by(Payment.payment_method).all()
total_revenue = db.query(func.sum(Payment.amount)).filter(Payment.payment_status == PaymentStatus.completed).scalar() or 0.0
revenue_by_method = db.query(Payment.payment_method, func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(Payment.payment_method).all()
method_breakdown = {}
for method, total in revenue_by_method:
method_name = method.value if hasattr(method, 'value') else str(method)
method_breakdown[method_name] = float(total or 0)
# Revenue by date (daily breakdown)
daily_revenue = db.query(
func.date(Payment.payment_date).label('date'),
func.sum(Payment.amount).label('total')
).filter(
Payment.payment_status == PaymentStatus.completed
).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all()
daily_breakdown = [
{
"date": date.isoformat() if isinstance(date, datetime) else str(date),
"revenue": float(total or 0)
}
for date, total in daily_revenue
]
return {
"status": "success",
"data": {
"total_revenue": float(total_revenue),
"revenue_by_method": method_breakdown,
"daily_breakdown": daily_breakdown,
}
}
daily_revenue = db.query(func.date(Payment.payment_date).label('date'), func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all()
daily_breakdown = [{'date': date.isoformat() if isinstance(date, datetime) else str(date), 'revenue': float(total or 0)} for date, total in daily_revenue]
return {'status': 'success', 'data': {'total_revenue': float(total_revenue), 'revenue_by_method': method_breakdown, 'daily_breakdown': daily_breakdown}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,251 +1,117 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import Optional
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.review import Review, ReviewStatus
from ..models.room import Room
router = APIRouter(prefix='/reviews', tags=['reviews'])
router = APIRouter(prefix="/reviews", tags=["reviews"])
@router.get("/room/{room_id}")
async def get_room_reviews(room_id: int, db: Session = Depends(get_db)):
"""Get reviews for a room"""
@router.get('/room/{room_id}')
async def get_room_reviews(room_id: int, db: Session=Depends(get_db)):
try:
reviews = db.query(Review).filter(
Review.room_id == room_id,
Review.status == ReviewStatus.approved
).order_by(Review.created_at.desc()).all()
reviews = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved).order_by(Review.created_at.desc()).all()
result = []
for review in reviews:
review_dict = {
"id": review.id,
"user_id": review.user_id,
"room_id": review.room_id,
"rating": review.rating,
"comment": review.comment,
"status": review.status.value if isinstance(review.status, ReviewStatus) else review.status,
"created_at": review.created_at.isoformat() if review.created_at else None,
}
review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None}
if review.user:
review_dict["user"] = {
"id": review.user.id,
"full_name": review.user.full_name,
"email": review.user.email,
}
review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email}
result.append(review_dict)
return {
"status": "success",
"data": {"reviews": result}
}
return {'status': 'success', 'data': {'reviews': result}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/", dependencies=[Depends(authorize_roles("admin"))])
async def get_all_reviews(
status_filter: Optional[str] = Query(None, alias="status"),
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get all reviews (Admin only)"""
@router.get('/', dependencies=[Depends(authorize_roles('admin'))])
async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
query = db.query(Review)
if status_filter:
try:
query = query.filter(Review.status == ReviewStatus(status_filter))
except ValueError:
pass
total = query.count()
offset = (page - 1) * limit
reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all()
result = []
for review in reviews:
review_dict = {
"id": review.id,
"user_id": review.user_id,
"room_id": review.room_id,
"rating": review.rating,
"comment": review.comment,
"status": review.status.value if isinstance(review.status, ReviewStatus) else review.status,
"created_at": review.created_at.isoformat() if review.created_at else None,
}
review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None}
if review.user:
review_dict["user"] = {
"id": review.user.id,
"full_name": review.user.full_name,
"email": review.user.email,
"phone": review.user.phone,
}
review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email, 'phone': review.user.phone}
if review.room:
review_dict["room"] = {
"id": review.room.id,
"room_number": review.room.room_number,
}
review_dict['room'] = {'id': review.room.id, 'room_number': review.room.room_number}
result.append(review_dict)
return {
"status": "success",
"data": {
"reviews": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
return {'status': 'success', 'data': {'reviews': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/")
async def create_review(
review_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create new review"""
@router.post('/')
async def create_review(review_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
room_id = review_data.get("room_id")
rating = review_data.get("rating")
comment = review_data.get("comment")
# Check if room exists
room_id = review_data.get('room_id')
rating = review_data.get('rating')
comment = review_data.get('comment')
room = db.query(Room).filter(Room.id == room_id).first()
if not room:
raise HTTPException(status_code=404, detail="Room not found")
# Check if user already reviewed this room
existing = db.query(Review).filter(
Review.user_id == current_user.id,
Review.room_id == room_id
).first()
raise HTTPException(status_code=404, detail='Room not found')
existing = db.query(Review).filter(Review.user_id == current_user.id, Review.room_id == room_id).first()
if existing:
raise HTTPException(
status_code=400,
detail="You have already reviewed this room"
)
# Create review
review = Review(
user_id=current_user.id,
room_id=room_id,
rating=rating,
comment=comment,
status=ReviewStatus.pending,
)
raise HTTPException(status_code=400, detail='You have already reviewed this room')
review = Review(user_id=current_user.id, room_id=room_id, rating=rating, comment=comment, status=ReviewStatus.pending)
db.add(review)
db.commit()
db.refresh(review)
return {
"status": "success",
"message": "Review submitted successfully and is pending approval",
"data": {"review": review}
}
return {'status': 'success', 'message': 'Review submitted successfully and is pending approval', 'data': {'review': review}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}/approve", dependencies=[Depends(authorize_roles("admin"))])
async def approve_review(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Approve review (Admin only)"""
@router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))])
async def approve_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
review = db.query(Review).filter(Review.id == id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
raise HTTPException(status_code=404, detail='Review not found')
review.status = ReviewStatus.approved
db.commit()
db.refresh(review)
return {
"status": "success",
"message": "Review approved successfully",
"data": {"review": review}
}
return {'status': 'success', 'message': 'Review approved successfully', 'data': {'review': review}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}/reject", dependencies=[Depends(authorize_roles("admin"))])
async def reject_review(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Reject review (Admin only)"""
@router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))])
async def reject_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
review = db.query(Review).filter(Review.id == id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
raise HTTPException(status_code=404, detail='Review not found')
review.status = ReviewStatus.rejected
db.commit()
db.refresh(review)
return {
"status": "success",
"message": "Review rejected successfully",
"data": {"review": review}
}
return {'status': 'success', 'message': 'Review rejected successfully', 'data': {'review': review}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def delete_review(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete review (Admin only)"""
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def delete_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
review = db.query(Review).filter(Review.id == id).first()
if not review:
raise HTTPException(status_code=404, detail="Review not found")
raise HTTPException(status_code=404, detail='Review not found')
db.delete(review)
db.commit()
return {
"status": "success",
"message": "Review deleted successfully"
}
return {'status': 'success', 'message': 'Review deleted successfully'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

File diff suppressed because it is too large Load Diff

View File

@@ -21,22 +21,18 @@ from ..config.settings import settings
router = APIRouter(prefix="/service-bookings", tags=["service-bookings"])
def generate_service_booking_number() -> str:
"""Generate unique service booking number"""
prefix = "SB"
timestamp = datetime.utcnow().strftime("%Y%m%d")
random_suffix = random.randint(1000, 9999)
return f"{prefix}{timestamp}{random_suffix}"
@router.post("/")
async def create_service_booking(
booking_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new service booking"""
try:
services = booking_data.get("services", [])
total_amount = float(booking_data.get("total_amount", 0))
@@ -48,7 +44,7 @@ async def create_service_booking(
if total_amount <= 0:
raise HTTPException(status_code=400, detail="Total amount must be greater than 0")
# Validate services and calculate total
calculated_total = 0
service_items_data = []
@@ -59,7 +55,7 @@ async def create_service_booking(
if not service_id:
raise HTTPException(status_code=400, detail="Service ID is required for each item")
# Check if service exists and is active
service = db.query(Service).filter(Service.id == service_id).first()
if not service:
raise HTTPException(status_code=404, detail=f"Service with ID {service_id} not found")
@@ -78,17 +74,17 @@ async def create_service_booking(
"total_price": item_total
})
# Verify calculated total matches provided total (with small tolerance for floating point)
if abs(calculated_total - total_amount) > 0.01:
raise HTTPException(
status_code=400,
detail=f"Total amount mismatch. Calculated: {calculated_total}, Provided: {total_amount}"
)
# Generate booking number
booking_number = generate_service_booking_number()
# Create service booking
service_booking = ServiceBooking(
booking_number=booking_number,
user_id=current_user.id,
@@ -98,9 +94,9 @@ async def create_service_booking(
)
db.add(service_booking)
db.flush() # Flush to get the ID
db.flush()
# Create service booking items
for item_data in service_items_data:
booking_item = ServiceBookingItem(
service_booking_id=service_booking.id,
@@ -114,12 +110,12 @@ async def create_service_booking(
db.commit()
db.refresh(service_booking)
# Load relationships
service_booking = db.query(ServiceBooking).options(
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
).filter(ServiceBooking.id == service_booking.id).first()
# Format response
booking_dict = {
"id": service_booking.id,
"booking_number": service_booking.booking_number,
@@ -157,13 +153,11 @@ async def create_service_booking(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/me")
async def get_my_service_bookings(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get all service bookings for current user"""
try:
bookings = db.query(ServiceBooking).options(
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
@@ -204,14 +198,12 @@ async def get_my_service_bookings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_service_booking_by_id(
id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get service booking by ID"""
try:
booking = db.query(ServiceBooking).options(
joinedload(ServiceBooking.service_items).joinedload(ServiceBookingItem.service)
@@ -220,7 +212,7 @@ async def get_service_booking_by_id(
if not booking:
raise HTTPException(status_code=404, detail="Service booking not found")
# Check access
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden")
@@ -259,7 +251,6 @@ async def get_service_booking_by_id(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/payment/stripe/create-intent")
async def create_service_stripe_payment_intent(
id: int,
@@ -267,9 +258,8 @@ async def create_service_stripe_payment_intent(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create Stripe payment intent for service booking"""
try:
# Check if Stripe is configured
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
@@ -286,7 +276,7 @@ async def create_service_stripe_payment_intent(
if amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be greater than 0")
# Verify service booking exists and user has access
booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail="Service booking not found")
@@ -294,22 +284,22 @@ async def create_service_stripe_payment_intent(
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden")
# Verify amount matches booking total
if abs(float(booking.total_amount) - amount) > 0.01:
raise HTTPException(
status_code=400,
detail=f"Amount mismatch. Booking total: {booking.total_amount}, Provided: {amount}"
)
# Create payment intent
intent = StripeService.create_payment_intent(
amount=amount,
currency=currency,
description=f"Service Booking #{booking.booking_number}",
description=f"Service Booking
db=db
)
# Get publishable key
publishable_key = get_stripe_publishable_key(db)
if not publishable_key:
publishable_key = settings.STRIPE_PUBLISHABLE_KEY
@@ -333,7 +323,6 @@ async def create_service_stripe_payment_intent(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/payment/stripe/confirm")
async def confirm_service_stripe_payment(
id: int,
@@ -341,14 +330,13 @@ async def confirm_service_stripe_payment(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Confirm Stripe payment for service booking"""
try:
payment_intent_id = payment_data.get("payment_intent_id")
if not payment_intent_id:
raise HTTPException(status_code=400, detail="payment_intent_id is required")
# Verify service booking exists and user has access
booking = db.query(ServiceBooking).filter(ServiceBooking.id == id).first()
if not booking:
raise HTTPException(status_code=404, detail="Service booking not found")
@@ -356,7 +344,7 @@ async def confirm_service_stripe_payment(
if booking.user_id != current_user.id and current_user.role_id != 1:
raise HTTPException(status_code=403, detail="Forbidden")
# Retrieve and verify payment intent
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
if intent_data["status"] != "succeeded":
@@ -365,15 +353,15 @@ async def confirm_service_stripe_payment(
detail=f"Payment intent status is {intent_data['status']}, expected 'succeeded'"
)
# Verify amount matches
amount_paid = intent_data["amount"] / 100 # Convert from cents
amount_paid = intent_data["amount"] / 100
if abs(float(booking.total_amount) - amount_paid) > 0.01:
raise HTTPException(
status_code=400,
detail="Payment amount does not match booking total"
)
# Create payment record
payment = ServicePayment(
service_booking_id=booking.id,
amount=booking.total_amount,
@@ -386,7 +374,7 @@ async def confirm_service_stripe_payment(
db.add(payment)
# Update booking status
booking.status = ServiceBookingStatus.confirmed
db.commit()

View File

@@ -2,276 +2,133 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.service import Service
from ..models.service_usage import ServiceUsage
from ..models.booking import Booking, BookingStatus
router = APIRouter(prefix='/services', tags=['services'])
router = APIRouter(prefix="/services", tags=["services"])
@router.get("/")
async def get_services(
search: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
"""Get all services with filters"""
@router.get('/')
async def get_services(search: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), db: Session=Depends(get_db)):
try:
query = db.query(Service)
# Filter by search (name or description)
if search:
query = query.filter(
or_(
Service.name.like(f"%{search}%"),
Service.description.like(f"%{search}%")
)
)
# Filter by status (is_active)
query = query.filter(or_(Service.name.like(f'%{search}%'), Service.description.like(f'%{search}%')))
if status_filter:
is_active = status_filter == "active"
is_active = status_filter == 'active'
query = query.filter(Service.is_active == is_active)
total = query.count()
offset = (page - 1) * limit
services = query.order_by(Service.created_at.desc()).offset(offset).limit(limit).all()
result = []
for service in services:
service_dict = {
"id": service.id,
"name": service.name,
"description": service.description,
"price": float(service.price) if service.price else 0.0,
"category": service.category,
"is_active": service.is_active,
"created_at": service.created_at.isoformat() if service.created_at else None,
}
service_dict = {'id': service.id, 'name': service.name, 'description': service.description, 'price': float(service.price) if service.price else 0.0, 'category': service.category, 'is_active': service.is_active, 'created_at': service.created_at.isoformat() if service.created_at else None}
result.append(service_dict)
return {
"status": "success",
"data": {
"services": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
return {'status': 'success', 'data': {'services': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}")
async def get_service_by_id(id: int, db: Session = Depends(get_db)):
"""Get service by ID"""
@router.get('/{id}')
async def get_service_by_id(id: int, db: Session=Depends(get_db)):
try:
service = db.query(Service).filter(Service.id == id).first()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
service_dict = {
"id": service.id,
"name": service.name,
"description": service.description,
"price": float(service.price) if service.price else 0.0,
"category": service.category,
"is_active": service.is_active,
"created_at": service.created_at.isoformat() if service.created_at else None,
}
return {
"status": "success",
"data": {"service": service_dict}
}
raise HTTPException(status_code=404, detail='Service not found')
service_dict = {'id': service.id, 'name': service.name, 'description': service.description, 'price': float(service.price) if service.price else 0.0, 'category': service.category, 'is_active': service.is_active, 'created_at': service.created_at.isoformat() if service.created_at else None}
return {'status': 'success', 'data': {'service': service_dict}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
async def create_service(
service_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Create new service (Admin only)"""
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
async def create_service(service_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
name = service_data.get("name")
# Check if name exists
name = service_data.get('name')
existing = db.query(Service).filter(Service.name == name).first()
if existing:
raise HTTPException(status_code=400, detail="Service name already exists")
service = Service(
name=name,
description=service_data.get("description"),
price=float(service_data.get("price", 0)),
category=service_data.get("category"),
is_active=service_data.get("status") == "active" if service_data.get("status") else True,
)
raise HTTPException(status_code=400, detail='Service name already exists')
service = Service(name=name, description=service_data.get('description'), price=float(service_data.get('price', 0)), category=service_data.get('category'), is_active=service_data.get('status') == 'active' if service_data.get('status') else True)
db.add(service)
db.commit()
db.refresh(service)
return {
"status": "success",
"message": "Service created successfully",
"data": {"service": service}
}
return {'status': 'success', 'message': 'Service created successfully', 'data': {'service': service}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def update_service(
id: int,
service_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update service (Admin only)"""
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def update_service(id: int, service_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
service = db.query(Service).filter(Service.id == id).first()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
# Check if new name exists (excluding current)
name = service_data.get("name")
raise HTTPException(status_code=404, detail='Service not found')
name = service_data.get('name')
if name and name != service.name:
existing = db.query(Service).filter(
Service.name == name,
Service.id != id
).first()
existing = db.query(Service).filter(Service.name == name, Service.id != id).first()
if existing:
raise HTTPException(status_code=400, detail="Service name already exists")
# Update fields
if "name" in service_data:
service.name = service_data["name"]
if "description" in service_data:
service.description = service_data["description"]
if "price" in service_data:
service.price = float(service_data["price"])
if "category" in service_data:
service.category = service_data["category"]
if "status" in service_data:
service.is_active = service_data["status"] == "active"
raise HTTPException(status_code=400, detail='Service name already exists')
if 'name' in service_data:
service.name = service_data['name']
if 'description' in service_data:
service.description = service_data['description']
if 'price' in service_data:
service.price = float(service_data['price'])
if 'category' in service_data:
service.category = service_data['category']
if 'status' in service_data:
service.is_active = service_data['status'] == 'active'
db.commit()
db.refresh(service)
return {
"status": "success",
"message": "Service updated successfully",
"data": {"service": service}
}
return {'status': 'success', 'message': 'Service updated successfully', 'data': {'service': service}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def delete_service(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete service (Admin only)"""
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def delete_service(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
service = db.query(Service).filter(Service.id == id).first()
if not service:
raise HTTPException(status_code=404, detail="Service not found")
# Check if service is used in active bookings
active_usage = db.query(ServiceUsage).join(Booking).filter(
ServiceUsage.service_id == id,
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
).count()
raise HTTPException(status_code=404, detail='Service not found')
active_usage = db.query(ServiceUsage).join(Booking).filter(ServiceUsage.service_id == id, Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count()
if active_usage > 0:
raise HTTPException(
status_code=400,
detail="Cannot delete service that is used in active bookings"
)
raise HTTPException(status_code=400, detail='Cannot delete service that is used in active bookings')
db.delete(service)
db.commit()
return {
"status": "success",
"message": "Service deleted successfully"
}
return {'status': 'success', 'message': 'Service deleted successfully'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/use")
async def use_service(
usage_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Add service to booking"""
@router.post('/use')
async def use_service(usage_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
booking_id = usage_data.get("booking_id")
service_id = usage_data.get("service_id")
quantity = usage_data.get("quantity", 1)
# Check if booking exists
booking_id = usage_data.get('booking_id')
service_id = usage_data.get('service_id')
quantity = usage_data.get('quantity', 1)
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise HTTPException(status_code=404, detail="Booking not found")
# Check if service exists and is active
raise HTTPException(status_code=404, detail='Booking not found')
service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.is_active:
raise HTTPException(status_code=404, detail="Service not found or inactive")
# Calculate total price
raise HTTPException(status_code=404, detail='Service not found or inactive')
total_price = float(service.price) * quantity
# Create service usage
service_usage = ServiceUsage(
booking_id=booking_id,
service_id=service_id,
quantity=quantity,
unit_price=service.price,
total_price=total_price,
)
service_usage = ServiceUsage(booking_id=booking_id, service_id=service_id, quantity=quantity, unit_price=service.price, total_price=total_price)
db.add(service_usage)
db.commit()
db.refresh(service_usage)
return {
"status": "success",
"message": "Service added to booking successfully",
"data": {"bookingService": service_usage}
}
return {'status': 'success', 'message': 'Service added to booking successfully', 'data': {'bookingService': service_usage}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -16,9 +16,7 @@ from ..models.system_settings import SystemSettings
from ..utils.mailer import send_email
from ..services.room_service import get_base_url
def normalize_image_url(image_url: str, base_url: str) -> str:
"""Normalize image URL to absolute URL"""
if not image_url:
return image_url
if image_url.startswith('http://') or image_url.startswith('https://'):
@@ -31,19 +29,17 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/system-settings", tags=["admin-system-settings"])
@router.get("/currency")
async def get_platform_currency(
db: Session = Depends(get_db)
):
"""Get platform currency setting (public endpoint for frontend)"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "platform_currency"
).first()
if not setting:
# Default to VND if not set
return {
"status": "success",
"data": {
@@ -64,25 +60,23 @@ async def get_platform_currency(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/currency")
async def update_platform_currency(
currency_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update platform currency (Admin only)"""
try:
currency = currency_data.get("currency", "").upper()
# Validate currency code
if not currency or len(currency) != 3 or not currency.isalpha():
raise HTTPException(
status_code=400,
detail="Invalid currency code. Must be a 3-letter ISO 4217 code (e.g., USD, EUR, VND)"
)
# Get or create setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == "platform_currency"
).first()
@@ -117,13 +111,11 @@ async def update_platform_currency(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/")
async def get_all_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get all system settings (Admin only)"""
try:
settings = db.query(SystemSettings).all()
@@ -146,13 +138,11 @@ async def get_all_settings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/stripe")
async def get_stripe_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get Stripe payment settings (Admin only)"""
try:
secret_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
@@ -166,7 +156,7 @@ async def get_stripe_settings(
SystemSettings.key == "stripe_webhook_secret"
).first()
# Mask secret keys for security (only show last 4 characters)
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
@@ -206,41 +196,39 @@ async def get_stripe_settings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/stripe")
async def update_stripe_settings(
stripe_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update Stripe payment settings (Admin only)"""
try:
secret_key = stripe_data.get("stripe_secret_key", "").strip()
publishable_key = stripe_data.get("stripe_publishable_key", "").strip()
webhook_secret = stripe_data.get("stripe_webhook_secret", "").strip()
# Validate secret key format (should start with sk_)
if secret_key and not secret_key.startswith("sk_"):
raise HTTPException(
status_code=400,
detail="Invalid Stripe secret key format. Must start with 'sk_'"
)
# Validate publishable key format (should start with pk_)
if publishable_key and not publishable_key.startswith("pk_"):
raise HTTPException(
status_code=400,
detail="Invalid Stripe publishable key format. Must start with 'pk_'"
)
# Validate webhook secret format (should start with whsec_)
if webhook_secret and not webhook_secret.startswith("whsec_"):
raise HTTPException(
status_code=400,
detail="Invalid Stripe webhook secret format. Must start with 'whsec_'"
)
# Update or create secret key setting
if secret_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
@@ -258,7 +246,7 @@ async def update_stripe_settings(
)
db.add(setting)
# Update or create publishable key setting
if publishable_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_publishable_key"
@@ -276,7 +264,7 @@ async def update_stripe_settings(
)
db.add(setting)
# Update or create webhook secret setting
if webhook_secret:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_webhook_secret"
@@ -296,7 +284,7 @@ async def update_stripe_settings(
db.commit()
# Return masked values
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
@@ -322,13 +310,11 @@ async def update_stripe_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/paypal")
async def get_paypal_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get PayPal payment settings (Admin only)"""
try:
client_id_setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_id"
@@ -342,7 +328,7 @@ async def get_paypal_settings(
SystemSettings.key == "paypal_mode"
).first()
# Mask secret for security (only show last 4 characters)
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
@@ -378,27 +364,25 @@ async def get_paypal_settings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/paypal")
async def update_paypal_settings(
paypal_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update PayPal payment settings (Admin only)"""
try:
client_id = paypal_data.get("paypal_client_id", "").strip()
client_secret = paypal_data.get("paypal_client_secret", "").strip()
mode = paypal_data.get("paypal_mode", "sandbox").strip().lower()
# Validate mode
if mode and mode not in ["sandbox", "live"]:
raise HTTPException(
status_code=400,
detail="Invalid PayPal mode. Must be 'sandbox' or 'live'"
)
# Update or create client ID setting
if client_id:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_id"
@@ -416,7 +400,7 @@ async def update_paypal_settings(
)
db.add(setting)
# Update or create client secret setting
if client_secret:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_secret"
@@ -434,7 +418,7 @@ async def update_paypal_settings(
)
db.add(setting)
# Update or create mode setting
if mode:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_mode"
@@ -454,7 +438,7 @@ async def update_paypal_settings(
db.commit()
# Return masked values
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
@@ -478,15 +462,13 @@ async def update_paypal_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/smtp")
async def get_smtp_settings(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get SMTP email server settings (Admin only)"""
try:
# Get all SMTP settings
smtp_settings = {}
setting_keys = [
"smtp_host",
@@ -505,7 +487,7 @@ async def get_smtp_settings(
if setting:
smtp_settings[key] = setting.value
# Mask password for security (only show last 4 characters if set)
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
return ""
@@ -525,7 +507,7 @@ async def get_smtp_settings(
"has_password": bool(smtp_settings.get("smtp_password")),
}
# Get updated_at and updated_by from any setting (prefer password setting if exists)
password_setting = db.query(SystemSettings).filter(
SystemSettings.key == "smtp_password"
).first()
@@ -534,7 +516,7 @@ async def get_smtp_settings(
result["updated_at"] = password_setting.updated_at.isoformat() if password_setting.updated_at else None
result["updated_by"] = password_setting.updated_by.full_name if password_setting.updated_by else None
else:
# Try to get from any other SMTP setting
any_setting = db.query(SystemSettings).filter(
SystemSettings.key.in_(setting_keys)
).first()
@@ -552,14 +534,12 @@ async def get_smtp_settings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/smtp")
async def update_smtp_settings(
smtp_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update SMTP email server settings (Admin only)"""
try:
smtp_host = smtp_data.get("smtp_host", "").strip()
smtp_port = smtp_data.get("smtp_port", "").strip()
@@ -569,7 +549,7 @@ async def update_smtp_settings(
smtp_from_name = smtp_data.get("smtp_from_name", "").strip()
smtp_use_tls = smtp_data.get("smtp_use_tls", True)
# Validate required fields if provided
if smtp_host and not smtp_host:
raise HTTPException(
status_code=400,
@@ -591,14 +571,14 @@ async def update_smtp_settings(
)
if smtp_from_email:
# Basic email validation
if "@" not in smtp_from_email or "." not in smtp_from_email.split("@")[1]:
raise HTTPException(
status_code=400,
detail="Invalid email address format for 'From Email'"
)
# Helper function to update or create setting
def update_setting(key: str, value: str, description: str):
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
@@ -616,7 +596,7 @@ async def update_smtp_settings(
)
db.add(setting)
# Update or create settings (only update if value is provided)
if smtp_host:
update_setting(
"smtp_host",
@@ -659,7 +639,7 @@ async def update_smtp_settings(
"Default 'From' name for outgoing emails"
)
# Update TLS setting (convert boolean to string)
if smtp_use_tls is not None:
update_setting(
"smtp_use_tls",
@@ -669,13 +649,13 @@ async def update_smtp_settings(
db.commit()
# Return updated settings with masked password
def mask_password(password_value: str) -> str:
if not password_value or len(password_value) < 4:
return ""
return "*" * (len(password_value) - 4) + password_value[-4:]
# Get updated settings
updated_settings = {}
for key in ["smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from_email", "smtp_from_name", "smtp_use_tls"]:
setting = db.query(SystemSettings).filter(
@@ -698,7 +678,7 @@ async def update_smtp_settings(
"has_password": bool(updated_settings.get("smtp_password")),
}
# Get updated_by from password setting if it exists
password_setting = db.query(SystemSettings).filter(
SystemSettings.key == "smtp_password"
).first()
@@ -717,131 +697,28 @@ async def update_smtp_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
class TestEmailRequest(BaseModel):
email: EmailStr
@router.post("/smtp/test")
async def test_smtp_email(
request: TestEmailRequest,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Send a test email to verify SMTP settings (Admin only)"""
try:
test_email = str(request.email)
admin_name = str(current_user.full_name or current_user.email or "Admin")
timestamp_str = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC")
# Create test email HTML content
test_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}}
.header {{
background: linear-gradient(135deg, #d4af37 0%, #c9a227 100%);
color: white;
padding: 30px;
text-align: center;
border-radius: 10px 10px 0 0;
}}
.content {{
background: #f9f9f9;
padding: 30px;
border: 1px solid #e0e0e0;
border-radius: 0 0 10px 10px;
}}
.success-icon {{
font-size: 48px;
margin-bottom: 20px;
}}
.info-box {{
background: white;
padding: 20px;
margin: 20px 0;
border-left: 4px solid #d4af37;
border-radius: 5px;
}}
.footer {{
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
color: #666;
font-size: 12px;
}}
</style>
</head>
<body>
<div class="header">
<h1>✅ SMTP Test Email</h1>
</div>
<div class="content">
<div style="text-align: center;">
<div class="success-icon">🎉</div>
<h2>Email Configuration Test Successful!</h2>
</div>
<p>This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.</p>
<div class="info-box">
<strong>📧 Test Details:</strong>
<ul>
<li><strong>Recipient:</strong> {test_email}</li>
<li><strong>Sent by:</strong> {admin_name}</li>
<li><strong>Time:</strong> {timestamp_str}</li>
</ul>
</div>
<p>If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.</p>
<p><strong>What's next?</strong></p>
<ul>
<li>Welcome emails for new user registrations</li>
<li>Password reset emails</li>
<li>Booking confirmation emails</li>
<li>Payment notifications</li>
<li>And other system notifications</li>
</ul>
<div class="footer">
<p>This is an automated test email from Hotel Booking System</p>
<p>If you did not request this test, please ignore this email.</p>
</div>
</div>
</body>
</html>
"""
test_html = f
# Plain text version
test_text = f"""
SMTP Test Email
This is a test email sent from your Hotel Booking system to verify that the SMTP email settings are configured correctly.
Test Details:
- Recipient: {test_email}
- Sent by: {admin_name}
- Time: {timestamp_str}
If you received this email, it means your SMTP server settings are working correctly and the system can send emails through your configured email server.
This is an automated test email from Hotel Booking System
If you did not request this test, please ignore this email.
""".strip()
test_text = f
.strip()
# Send the test email
await send_email(
to=test_email,
subject="SMTP Test Email - Hotel Booking System",
@@ -860,13 +737,13 @@ If you did not request this test, please ignore this email.
}
}
except HTTPException:
# Re-raise HTTP exceptions (like validation errors from send_email)
raise
except Exception as e:
error_msg = str(e)
logger.error(f"Error sending test email: {type(e).__name__}: {error_msg}", exc_info=True)
# Provide more user-friendly error messages
if "SMTP mailer not configured" in error_msg:
raise HTTPException(
status_code=400,
@@ -888,7 +765,6 @@ If you did not request this test, please ignore this email.
detail=f"Failed to send test email: {error_msg}"
)
class UpdateCompanySettingsRequest(BaseModel):
company_name: Optional[str] = None
company_tagline: Optional[str] = None
@@ -897,12 +773,10 @@ class UpdateCompanySettingsRequest(BaseModel):
company_address: Optional[str] = None
tax_rate: Optional[float] = None
@router.get("/company")
async def get_company_settings(
db: Session = Depends(get_db)
):
"""Get company settings (public endpoint for frontend)"""
try:
setting_keys = [
"company_name",
@@ -925,7 +799,7 @@ async def get_company_settings(
else:
settings_dict[key] = None
# Get updated_at and updated_by from logo setting if exists
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
@@ -954,14 +828,12 @@ async def get_company_settings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/company")
async def update_company_settings(
request_data: UpdateCompanySettingsRequest,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update company settings (Admin only)"""
try:
db_settings = {}
@@ -979,18 +851,18 @@ async def update_company_settings(
db_settings["tax_rate"] = str(request_data.tax_rate)
for key, value in db_settings.items():
# Find or create setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
if setting:
# Update existing
setting.value = value if value else None
setting.updated_at = datetime.utcnow()
setting.updated_by_id = current_user.id
else:
# Create new
setting = SystemSettings(
key=key,
value=value if value else None,
@@ -1000,7 +872,7 @@ async def update_company_settings(
db.commit()
# Get updated settings
updated_settings = {}
for key in ["company_name", "company_tagline", "company_logo_url", "company_favicon_url", "company_phone", "company_email", "company_address", "tax_rate"]:
setting = db.query(SystemSettings).filter(
@@ -1011,7 +883,7 @@ async def update_company_settings(
else:
updated_settings[key] = None
# Get updated_at and updated_by
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
@@ -1048,7 +920,6 @@ async def update_company_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/company/logo")
async def upload_company_logo(
request: Request,
@@ -1056,28 +927,27 @@ async def upload_company_logo(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Upload company logo (Admin only)"""
try:
# Validate file type
if not image.content_type or not image.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File must be an image"
)
# Validate file size (max 2MB)
content = await image.read()
if len(content) > 2 * 1024 * 1024: # 2MB
if len(content) > 2 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Logo file size must be less than 2MB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old logo if exists
old_logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
@@ -1090,20 +960,20 @@ async def upload_company_logo(
except Exception as e:
logger.warning(f"Could not delete old logo: {e}")
# Generate filename
ext = Path(image.filename).suffix or '.png'
# Always use logo.png to ensure we only have one logo
filename = "logo.png"
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Store the URL in system_settings
image_url = f"/uploads/company/{filename}"
# Update or create setting
logo_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_logo_url"
).first()
@@ -1122,7 +992,7 @@ async def upload_company_logo(
db.commit()
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
@@ -1142,7 +1012,6 @@ async def upload_company_logo(
logger.error(f"Error uploading logo: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/company/favicon")
async def upload_company_favicon(
request: Request,
@@ -1150,9 +1019,8 @@ async def upload_company_favicon(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Upload company favicon (Admin only)"""
try:
# Validate file type (favicon can be ico, png, svg)
if not image.content_type:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
@@ -1161,7 +1029,7 @@ async def upload_company_favicon(
allowed_types = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml', 'image/ico']
if image.content_type not in allowed_types:
# Check filename extension as fallback
filename_lower = (image.filename or '').lower()
if not any(filename_lower.endswith(ext) for ext in ['.ico', '.png', '.svg']):
raise HTTPException(
@@ -1169,19 +1037,19 @@ async def upload_company_favicon(
detail="Favicon must be .ico, .png, or .svg file"
)
# Validate file size (max 500KB)
content = await image.read()
if len(content) > 500 * 1024: # 500KB
if len(content) > 500 * 1024:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Favicon file size must be less than 500KB"
)
# Create uploads directory
upload_dir = Path(__file__).parent.parent.parent / "uploads" / "company"
upload_dir.mkdir(parents=True, exist_ok=True)
# Delete old favicon if exists
old_favicon_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_favicon_url"
).first()
@@ -1194,7 +1062,7 @@ async def upload_company_favicon(
except Exception as e:
logger.warning(f"Could not delete old favicon: {e}")
# Generate filename - preserve extension but use standard name
filename_lower = (image.filename or '').lower()
if filename_lower.endswith('.ico'):
filename = "favicon.ico"
@@ -1205,14 +1073,14 @@ async def upload_company_favicon(
file_path = upload_dir / filename
# Save file
async with aiofiles.open(file_path, 'wb') as f:
await f.write(content)
# Store the URL in system_settings
image_url = f"/uploads/company/{filename}"
# Update or create setting
favicon_setting = db.query(SystemSettings).filter(
SystemSettings.key == "company_favicon_url"
).first()
@@ -1231,7 +1099,7 @@ async def upload_company_favicon(
db.commit()
# Return the image URL
base_url = get_base_url(request)
full_url = normalize_image_url(image_url, base_url)
@@ -1251,12 +1119,10 @@ async def upload_company_favicon(
logger.error(f"Error uploading favicon: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/recaptcha")
async def get_recaptcha_settings(
db: Session = Depends(get_db)
):
"""Get reCAPTCHA settings (Public endpoint for frontend)"""
try:
site_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_site_key"
@@ -1284,13 +1150,11 @@ async def get_recaptcha_settings(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/recaptcha/admin")
async def get_recaptcha_settings_admin(
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get reCAPTCHA settings (Admin only - includes secret key)"""
try:
site_key_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_site_key"
@@ -1304,7 +1168,7 @@ async def get_recaptcha_settings_admin(
SystemSettings.key == "recaptcha_enabled"
).first()
# Mask secret for security (only show last 4 characters)
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
@@ -1340,20 +1204,18 @@ async def get_recaptcha_settings_admin(
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.put("/recaptcha")
async def update_recaptcha_settings(
recaptcha_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Update reCAPTCHA settings (Admin only)"""
try:
site_key = recaptcha_data.get("recaptcha_site_key", "").strip()
secret_key = recaptcha_data.get("recaptcha_secret_key", "").strip()
enabled = recaptcha_data.get("recaptcha_enabled", False)
# Update or create site key setting
if site_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_site_key"
@@ -1371,7 +1233,7 @@ async def update_recaptcha_settings(
)
db.add(setting)
# Update or create secret key setting
if secret_key:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_secret_key"
@@ -1389,7 +1251,7 @@ async def update_recaptcha_settings(
)
db.add(setting)
# Update or create enabled setting
setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_enabled"
).first()
@@ -1408,7 +1270,7 @@ async def update_recaptcha_settings(
db.commit()
# Return masked values
def mask_key(key_value: str) -> str:
if not key_value or len(key_value) < 4:
return ""
@@ -1432,13 +1294,11 @@ async def update_recaptcha_settings(
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/recaptcha/verify")
async def verify_recaptcha(
verification_data: dict,
db: Session = Depends(get_db)
):
"""Verify reCAPTCHA token (Public endpoint)"""
try:
token = verification_data.get("token", "").strip()
@@ -1448,7 +1308,7 @@ async def verify_recaptcha(
detail="reCAPTCHA token is required"
)
# Get reCAPTCHA settings
enabled_setting = db.query(SystemSettings).filter(
SystemSettings.key == "recaptcha_enabled"
).first()
@@ -1457,13 +1317,13 @@ async def verify_recaptcha(
SystemSettings.key == "recaptcha_secret_key"
).first()
# Check if reCAPTCHA is enabled
is_enabled = False
if enabled_setting:
is_enabled = enabled_setting.value.lower() == "true" if enabled_setting.value else False
if not is_enabled:
# If disabled, always return success
return {
"status": "success",
"data": {
@@ -1478,7 +1338,7 @@ async def verify_recaptcha(
detail="reCAPTCHA secret key is not configured"
)
# Verify with Google reCAPTCHA API
import httpx
async with httpx.AsyncClient() as client:
@@ -1498,8 +1358,8 @@ async def verify_recaptcha(
"status": "success",
"data": {
"verified": True,
"score": result.get("score"), # For v3
"action": result.get("action") # For v3
"score": result.get("score"),
"action": result.get("action")
}
}
else:

View File

@@ -3,323 +3,136 @@ from sqlalchemy.orm import Session
from sqlalchemy import or_
from typing import Optional
import bcrypt
from ..config.database import get_db
from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User
from ..models.role import Role
from ..models.booking import Booking, BookingStatus
router = APIRouter(prefix='/users', tags=['users'])
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", dependencies=[Depends(authorize_roles("admin"))])
async def get_users(
search: Optional[str] = Query(None),
role: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get all users with filters and pagination (Admin only)"""
@router.get('/', dependencies=[Depends(authorize_roles('admin'))])
async def get_users(search: Optional[str]=Query(None), role: Optional[str]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
query = db.query(User)
# Filter by search (full_name, email, phone)
if search:
query = query.filter(
or_(
User.full_name.like(f"%{search}%"),
User.email.like(f"%{search}%"),
User.phone.like(f"%{search}%")
)
)
# Filter by role
query = query.filter(or_(User.full_name.like(f'%{search}%'), User.email.like(f'%{search}%'), User.phone.like(f'%{search}%')))
if role:
role_map = {"admin": 1, "staff": 2, "customer": 3}
role_map = {'admin': 1, 'staff': 2, 'customer': 3}
if role in role_map:
query = query.filter(User.role_id == role_map[role])
# Filter by status
if status_filter:
is_active = status_filter == "active"
is_active = status_filter == 'active'
query = query.filter(User.is_active == is_active)
# Get total count
total = query.count()
# Apply pagination
offset = (page - 1) * limit
users = query.order_by(User.created_at.desc()).offset(offset).limit(limit).all()
# Transform users
result = []
for user in users:
user_dict = {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"phone": user.phone,
"phone_number": user.phone, # For frontend compatibility
"address": user.address,
"avatar": user.avatar,
"currency": getattr(user, 'currency', 'VND'),
"is_active": user.is_active,
"status": "active" if user.is_active else "inactive",
"role_id": user.role_id,
"role": user.role.name if user.role else "customer",
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
}
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'address': user.address, 'avatar': user.avatar, 'currency': getattr(user, 'currency', 'VND'), 'is_active': user.is_active, 'status': 'active' if user.is_active else 'inactive', 'role_id': user.role_id, 'role': user.role.name if user.role else 'customer', 'created_at': user.created_at.isoformat() if user.created_at else None, 'updated_at': user.updated_at.isoformat() if user.updated_at else None}
result.append(user_dict)
return {
"status": "success",
"data": {
"users": result,
"pagination": {
"total": total,
"page": page,
"limit": limit,
"totalPages": (total + limit - 1) // limit,
},
},
}
return {'status': 'success', 'data': {'users': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def get_user_by_id(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Get user by ID (Admin only)"""
@router.get('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get recent bookings
bookings = db.query(Booking).filter(
Booking.user_id == id
).order_by(Booking.created_at.desc()).limit(5).all()
user_dict = {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"phone": user.phone,
"phone_number": user.phone,
"address": user.address,
"avatar": user.avatar,
"currency": getattr(user, 'currency', 'VND'),
"is_active": user.is_active,
"status": "active" if user.is_active else "inactive",
"role_id": user.role_id,
"role": user.role.name if user.role else "customer",
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
"bookings": [
{
"id": b.id,
"booking_number": b.booking_number,
"status": b.status.value if isinstance(b.status, BookingStatus) else b.status,
"created_at": b.created_at.isoformat() if b.created_at else None,
}
for b in bookings
],
}
return {
"status": "success",
"data": {"user": user_dict}
}
raise HTTPException(status_code=404, detail='User not found')
bookings = db.query(Booking).filter(Booking.user_id == id).order_by(Booking.created_at.desc()).limit(5).all()
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'address': user.address, 'avatar': user.avatar, 'currency': getattr(user, 'currency', 'VND'), 'is_active': user.is_active, 'status': 'active' if user.is_active else 'inactive', 'role_id': user.role_id, 'role': user.role.name if user.role else 'customer', 'created_at': user.created_at.isoformat() if user.created_at else None, 'updated_at': user.updated_at.isoformat() if user.updated_at else None, 'bookings': [{'id': b.id, 'booking_number': b.booking_number, 'status': b.status.value if isinstance(b.status, BookingStatus) else b.status, 'created_at': b.created_at.isoformat() if b.created_at else None} for b in bookings]}
return {'status': 'success', 'data': {'user': user_dict}}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/", dependencies=[Depends(authorize_roles("admin"))])
async def create_user(
user_data: dict,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Create new user (Admin only)"""
@router.post('/', dependencies=[Depends(authorize_roles('admin'))])
async def create_user(user_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
email = user_data.get("email")
password = user_data.get("password")
full_name = user_data.get("full_name")
phone_number = user_data.get("phone_number")
role = user_data.get("role", "customer")
status = user_data.get("status", "active")
# Map role string to role_id
role_map = {"admin": 1, "staff": 2, "customer": 3}
email = user_data.get('email')
password = user_data.get('password')
full_name = user_data.get('full_name')
phone_number = user_data.get('phone_number')
role = user_data.get('role', 'customer')
status = user_data.get('status', 'active')
role_map = {'admin': 1, 'staff': 2, 'customer': 3}
role_id = role_map.get(role, 3)
# Check if email exists
existing = db.query(User).filter(User.email == email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already exists")
# Hash password
raise HTTPException(status_code=400, detail='Email already exists')
password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
# Create user
user = User(
email=email,
password=hashed_password,
full_name=full_name,
phone=phone_number,
role_id=role_id,
is_active=status == "active",
)
user = User(email=email, password=hashed_password, full_name=full_name, phone=phone_number, role_id=role_id, is_active=status == 'active')
db.add(user)
db.commit()
db.refresh(user)
# Remove password from response
user_dict = {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"phone": user.phone,
"phone_number": user.phone,
"currency": getattr(user, 'currency', 'VND'),
"role_id": user.role_id,
"is_active": user.is_active,
}
return {
"status": "success",
"message": "User created successfully",
"data": {"user": user_dict}
}
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}
return {'status': 'success', 'message': 'User created successfully', 'data': {'user': user_dict}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.put("/{id}")
async def update_user(
id: int,
user_data: dict,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update user"""
@router.put('/{id}')
async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try:
# Users can only update themselves unless they're admin
if current_user.role_id != 1 and current_user.id != id:
raise HTTPException(status_code=403, detail="Forbidden")
raise HTTPException(status_code=403, detail='Forbidden')
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if email is being changed and if it's taken
email = user_data.get("email")
raise HTTPException(status_code=404, detail='User not found')
email = user_data.get('email')
if email and email != user.email:
existing = db.query(User).filter(User.email == email).first()
if existing:
raise HTTPException(status_code=400, detail="Email already exists")
# Map role string to role_id (only admin can change role)
role_map = {"admin": 1, "staff": 2, "customer": 3}
# Update fields
if "full_name" in user_data:
user.full_name = user_data["full_name"]
if "email" in user_data and current_user.role_id == 1:
user.email = user_data["email"]
if "phone_number" in user_data:
user.phone = user_data["phone_number"]
if "role" in user_data and current_user.role_id == 1:
user.role_id = role_map.get(user_data["role"], 3)
if "status" in user_data and current_user.role_id == 1:
user.is_active = user_data["status"] == "active"
if "currency" in user_data:
currency = user_data["currency"]
raise HTTPException(status_code=400, detail='Email already exists')
role_map = {'admin': 1, 'staff': 2, 'customer': 3}
if 'full_name' in user_data:
user.full_name = user_data['full_name']
if 'email' in user_data and current_user.role_id == 1:
user.email = user_data['email']
if 'phone_number' in user_data:
user.phone = user_data['phone_number']
if 'role' in user_data and current_user.role_id == 1:
user.role_id = role_map.get(user_data['role'], 3)
if 'status' in user_data and current_user.role_id == 1:
user.is_active = user_data['status'] == 'active'
if 'currency' in user_data:
currency = user_data['currency']
if len(currency) == 3 and currency.isalpha():
user.currency = currency.upper()
if "password" in user_data:
password_bytes = user_data["password"].encode('utf-8')
if 'password' in user_data:
password_bytes = user_data['password'].encode('utf-8')
salt = bcrypt.gensalt()
user.password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
db.commit()
db.refresh(user)
# Remove password from response
user_dict = {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"phone": user.phone,
"phone_number": user.phone,
"currency": getattr(user, 'currency', 'VND'),
"role_id": user.role_id,
"is_active": user.is_active,
}
return {
"status": "success",
"message": "User updated successfully",
"data": {"user": user_dict}
}
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}
return {'status': 'success', 'message': 'User updated successfully', 'data': {'user': user_dict}}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{id}", dependencies=[Depends(authorize_roles("admin"))])
async def delete_user(
id: int,
current_user: User = Depends(authorize_roles("admin")),
db: Session = Depends(get_db)
):
"""Delete user (Admin only)"""
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def delete_user(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try:
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Check if user has active bookings
active_bookings = db.query(Booking).filter(
Booking.user_id == id,
Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])
).count()
raise HTTPException(status_code=404, detail='User not found')
active_bookings = db.query(Booking).filter(Booking.user_id == id, Booking.status.in_([BookingStatus.pending, BookingStatus.confirmed, BookingStatus.checked_in])).count()
if active_bookings > 0:
raise HTTPException(
status_code=400,
detail="Cannot delete user with active bookings"
)
raise HTTPException(status_code=400, detail='Cannot delete user with active bookings')
db.delete(user)
db.commit()
return {
"status": "success",
"message": "User deleted successfully"
}
return {'status': 'success', 'message': 'User deleted successfully'}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,68 +1,32 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class CookiePolicySettings(BaseModel):
"""
Admin-configurable global cookie policy.
Controls which categories can be used in the application.
"""
analytics_enabled: bool = Field(
default=True,
description="If false, analytics cookies/scripts should not be used at all.",
)
marketing_enabled: bool = Field(
default=True,
description="If false, marketing cookies/scripts should not be used at all.",
)
preferences_enabled: bool = Field(
default=True,
description="If false, preference cookies should not be used at all.",
)
analytics_enabled: bool = Field(default=True, description='If false, analytics cookies/scripts should not be used at all.')
marketing_enabled: bool = Field(default=True, description='If false, marketing cookies/scripts should not be used at all.')
preferences_enabled: bool = Field(default=True, description='If false, preference cookies should not be used at all.')
class CookiePolicySettingsResponse(BaseModel):
status: str = Field(default="success")
status: str = Field(default='success')
data: CookiePolicySettings
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
class CookieIntegrationSettings(BaseModel):
"""
IDs for well-known third-party integrations, configured by admin.
"""
ga_measurement_id: Optional[str] = Field(
default=None, description="Google Analytics 4 measurement ID (e.g. G-XXXXXXX)."
)
fb_pixel_id: Optional[str] = Field(
default=None, description="Meta (Facebook) Pixel ID."
)
ga_measurement_id: Optional[str] = Field(default=None, description='Google Analytics 4 measurement ID (e.g. G-XXXXXXX).')
fb_pixel_id: Optional[str] = Field(default=None, description='Meta (Facebook) Pixel ID.')
class CookieIntegrationSettingsResponse(BaseModel):
status: str = Field(default="success")
status: str = Field(default='success')
data: CookieIntegrationSettings
updated_at: Optional[datetime] = None
updated_by: Optional[str] = None
class PublicPrivacyConfig(BaseModel):
"""
Publicly consumable privacy configuration for the frontend.
Does not expose any secrets, only IDs and flags.
"""
policy: CookiePolicySettings
integrations: CookieIntegrationSettings
class PublicPrivacyConfigResponse(BaseModel):
status: str = Field(default="success")
data: PublicPrivacyConfig
status: str = Field(default='success')
data: PublicPrivacyConfig

View File

@@ -1,64 +1,58 @@
from pydantic import BaseModel, EmailStr, Field, validator
from typing import Optional
class RegisterRequest(BaseModel):
name: str = Field(..., min_length=2, max_length=50)
email: EmailStr
password: str = Field(..., min_length=8)
phone: Optional[str] = None
@validator("password")
@validator('password')
def validate_password(cls, v):
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one number")
raise ValueError('Password must be at least 8 characters')
if not any((c.isupper() for c in v)):
raise ValueError('Password must contain at least one uppercase letter')
if not any((c.islower() for c in v)):
raise ValueError('Password must contain at least one lowercase letter')
if not any((c.isdigit() for c in v)):
raise ValueError('Password must contain at least one number')
return v
@validator("phone")
@validator('phone')
def validate_phone(cls, v):
if v and not v.isdigit() or (v and len(v) not in [10, 11]):
raise ValueError("Phone must be 10-11 digits")
if v and (not v.isdigit()) or (v and len(v) not in [10, 11]):
raise ValueError('Phone must be 10-11 digits')
return v
class LoginRequest(BaseModel):
email: EmailStr
password: str
rememberMe: Optional[bool] = False
mfaToken: Optional[str] = None
class RefreshTokenRequest(BaseModel):
refreshToken: Optional[str] = None
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
password: str = Field(..., min_length=8)
@validator("password")
@validator('password')
def validate_password(cls, v):
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
if not any(c.islower() for c in v):
raise ValueError("Password must contain at least one lowercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain at least one number")
raise ValueError('Password must be at least 8 characters')
if not any((c.isupper() for c in v)):
raise ValueError('Password must contain at least one uppercase letter')
if not any((c.islower() for c in v)):
raise ValueError('Password must contain at least one lowercase letter')
if not any((c.isdigit() for c in v)):
raise ValueError('Password must contain at least one number')
return v
class UserResponse(BaseModel):
id: int
name: str
@@ -71,38 +65,30 @@ class UserResponse(BaseModel):
class Config:
from_attributes = True
class AuthResponse(BaseModel):
user: UserResponse
token: str
refreshToken: Optional[str] = None
class TokenResponse(BaseModel):
token: str
class MessageResponse(BaseModel):
status: str
message: str
class MFAInitResponse(BaseModel):
secret: str
qr_code: str # Base64 data URL
qr_code: str
class EnableMFARequest(BaseModel):
secret: str
verification_token: str
class VerifyMFARequest(BaseModel):
token: str
is_backup_code: Optional[bool] = False
class MFAStatusResponse(BaseModel):
mfa_enabled: bool
backup_codes_count: int
backup_codes_count: int

View File

@@ -1,70 +1,24 @@
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field
class CookieCategoryPreferences(BaseModel):
"""
Granular consent for different cookie categories.
- necessary: required for the site to function (always true, not revocable)
- analytics: usage analytics, performance tracking
- marketing: advertising, remarketing cookies
- preferences: UI / language / personalization preferences
"""
necessary: bool = Field(
default=True,
description="Strictly necessary cookies (always enabled as they are required for core functionality).",
)
analytics: bool = Field(
default=False, description="Allow anonymous analytics and performance cookies."
)
marketing: bool = Field(
default=False, description="Allow marketing and advertising cookies."
)
preferences: bool = Field(
default=False,
description="Allow preference cookies (e.g. language, layout settings).",
)
necessary: bool = Field(default=True, description='Strictly necessary cookies (always enabled as they are required for core functionality).')
analytics: bool = Field(default=False, description='Allow anonymous analytics and performance cookies.')
marketing: bool = Field(default=False, description='Allow marketing and advertising cookies.')
preferences: bool = Field(default=False, description='Allow preference cookies (e.g. language, layout settings).')
class CookieConsent(BaseModel):
"""
Persisted cookie consent state.
Stored in an HttpOnly cookie and exposed via the API.
"""
version: int = Field(
default=1, description="Consent schema version for future migrations."
)
updated_at: datetime = Field(
default_factory=datetime.utcnow, description="Last time consent was updated."
)
has_decided: bool = Field(
default=False,
description="Whether the user has actively made a consent choice.",
)
categories: CookieCategoryPreferences = Field(
default_factory=CookieCategoryPreferences,
description="Granular per-category consent.",
)
version: int = Field(default=1, description='Consent schema version for future migrations.')
updated_at: datetime = Field(default_factory=datetime.utcnow, description='Last time consent was updated.')
has_decided: bool = Field(default=False, description='Whether the user has actively made a consent choice.')
categories: CookieCategoryPreferences = Field(default_factory=CookieCategoryPreferences, description='Granular per-category consent.')
class CookieConsentResponse(BaseModel):
status: str = Field(default="success")
status: str = Field(default='success')
data: CookieConsent
class UpdateCookieConsentRequest(BaseModel):
"""
Request body for updating cookie consent.
'necessary' is ignored on write and always treated as True by the server.
"""
analytics: Optional[bool] = None
marketing: Optional[bool] = None
preferences: Optional[bool] = None
preferences: Optional[bool] = None

View File

@@ -1,82 +1,20 @@
"""
Audit logging service for tracking important actions
"""
from sqlalchemy.orm import Session
from typing import Optional, Dict, Any
from datetime import datetime
from ..models.audit_log import AuditLog
from ..config.logging_config import get_logger
logger = get_logger(__name__)
class AuditService:
"""Service for creating audit log entries"""
@staticmethod
async def log_action(
db: Session,
action: str,
resource_type: str,
user_id: Optional[int] = None,
resource_id: Optional[int] = None,
ip_address: Optional[str] = None,
user_agent: Optional[str] = None,
request_id: Optional[str] = None,
details: Optional[Dict[str, Any]] = None,
status: str = "success",
error_message: Optional[str] = None
):
"""
Create an audit log entry
Args:
db: Database session
action: Action performed (e.g., "user.created", "booking.cancelled")
resource_type: Type of resource (e.g., "user", "booking")
user_id: ID of user who performed the action
resource_id: ID of the resource affected
ip_address: IP address of the request
user_agent: User agent string
request_id: Request ID for tracing
details: Additional context as dictionary
status: Status of the action (success, failed, error)
error_message: Error message if action failed
"""
async def log_action(db: Session, action: str, resource_type: str, user_id: Optional[int]=None, resource_id: Optional[int]=None, ip_address: Optional[str]=None, user_agent: Optional[str]=None, request_id: Optional[str]=None, details: Optional[Dict[str, Any]]=None, status: str='success', error_message: Optional[str]=None):
try:
audit_log = AuditLog(
user_id=user_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
details=details,
status=status,
error_message=error_message
)
audit_log = AuditLog(user_id=user_id, action=action, resource_type=resource_type, resource_id=resource_id, ip_address=ip_address, user_agent=user_agent, request_id=request_id, details=details, status=status, error_message=error_message)
db.add(audit_log)
db.commit()
logger.info(
f"Audit log created: {action} on {resource_type}",
extra={
"action": action,
"resource_type": resource_type,
"resource_id": resource_id,
"user_id": user_id,
"status": status,
"request_id": request_id
}
)
logger.info(f'Audit log created: {action} on {resource_type}', extra={'action': action, 'resource_type': resource_type, 'resource_id': resource_id, 'user_id': user_id, 'status': status, 'request_id': request_id})
except Exception as e:
logger.error(f"Failed to create audit log: {str(e)}", exc_info=True)
logger.error(f'Failed to create audit log: {str(e)}', exc_info=True)
db.rollback()
# Don't raise exception - audit logging failures shouldn't break the app
# Global audit service instance
audit_service = AuditService()
audit_service = AuditService()

View File

@@ -22,17 +22,15 @@ import os
logger = logging.getLogger(__name__)
class AuthService:
def __init__(self):
# Use settings, fallback to env vars, then to defaults for development
self.jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv("JWT_SECRET", "dev-secret-key-change-in-production-12345")
self.jwt_refresh_secret = os.getenv("JWT_REFRESH_SECRET") or (self.jwt_secret + "-refresh")
self.jwt_expires_in = os.getenv("JWT_EXPIRES_IN", "1h")
self.jwt_refresh_expires_in = os.getenv("JWT_REFRESH_EXPIRES_IN", "7d")
def generate_tokens(self, user_id: int) -> dict:
"""Generate JWT tokens"""
access_token = jwt.encode(
{"userId": user_id},
self.jwt_secret,
@@ -48,24 +46,20 @@ class AuthService:
return {"accessToken": access_token, "refreshToken": refresh_token}
def verify_access_token(self, token: str) -> dict:
"""Verify JWT access token"""
return jwt.decode(token, self.jwt_secret, algorithms=["HS256"])
def verify_refresh_token(self, token: str) -> dict:
"""Verify JWT refresh token"""
return jwt.decode(token, self.jwt_refresh_secret, algorithms=["HS256"])
def hash_password(self, password: str) -> str:
"""Hash password using bcrypt"""
# bcrypt has 72 byte limit, but it handles truncation automatically
password_bytes = password.encode('utf-8')
# Generate salt and hash password
salt = bcrypt.gensalt()
hashed = bcrypt.hashpw(password_bytes, salt)
return hashed.decode('utf-8')
def verify_password(self, plain_password: str, hashed_password: str) -> bool:
"""Verify password using bcrypt"""
try:
password_bytes = plain_password.encode('utf-8')
hashed_bytes = hashed_password.encode('utf-8')
@@ -74,7 +68,6 @@ class AuthService:
return False
def format_user_response(self, user: User) -> dict:
"""Format user response"""
return {
"id": user.id,
"name": user.full_name,
@@ -88,34 +81,28 @@ class AuthService:
}
async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict:
"""Register new user"""
# Check if email exists
existing_user = db.query(User).filter(User.email == email).first()
if existing_user:
raise ValueError("Email already registered")
# Hash password
hashed_password = self.hash_password(password)
# Create user (default role_id = 3 for customer)
user = User(
full_name=name,
email=email,
password=hashed_password,
phone=phone,
role_id=3 # Customer role
role_id=3
)
db.add(user)
db.commit()
db.refresh(user)
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
# Generate tokens
tokens = self.generate_tokens(user.id)
# Save refresh token (expires in 7 days)
expires_at = datetime.utcnow() + timedelta(days=7)
refresh_token = RefreshToken(
user_id=user.id,
@@ -125,7 +112,6 @@ class AuthService:
db.add(refresh_token)
db.commit()
# Send welcome email (non-blocking)
try:
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
email_html = welcome_email_template(user.full_name, user.email, client_url)
@@ -145,62 +131,52 @@ class AuthService:
}
async def login(self, db: Session, email: str, password: str, remember_me: bool = False, mfa_token: str = None) -> dict:
"""Login user with optional MFA verification"""
# Normalize email (lowercase and strip whitespace)
email = email.lower().strip() if email else ""
if not email:
raise ValueError("Invalid email or password")
# Find user with role and password
user = db.query(User).filter(User.email == email).first()
if not user:
logger.warning(f"Login attempt with non-existent email: {email}")
raise ValueError("Invalid email or password")
# Check if user is active
if not user.is_active:
logger.warning(f"Login attempt for inactive user: {email}")
raise ValueError("Account is disabled. Please contact support.")
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
# Check password
if not self.verify_password(password, user.password):
logger.warning(f"Login attempt with invalid password for user: {email}")
raise ValueError("Invalid email or password")
# Check if MFA is enabled
if user.mfa_enabled:
if not mfa_token:
# Return special response indicating MFA is required
return {
"requires_mfa": True,
"user_id": user.id
}
# Verify MFA token
from ..services.mfa_service import mfa_service
is_backup_code = len(mfa_token) == 8 # Backup codes are 8 characters
is_backup_code = len(mfa_token) == 8
if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code):
raise ValueError("Invalid MFA token")
# Generate tokens
tokens = self.generate_tokens(user.id)
# Calculate expiry based on remember_me
expiry_days = 7 if remember_me else 1
expires_at = datetime.utcnow() + timedelta(days=expiry_days)
# Delete old/expired refresh tokens for this user to prevent duplicates
# This ensures we don't have multiple active tokens and prevents unique constraint violations
try:
db.query(RefreshToken).filter(
RefreshToken.user_id == user.id
).delete()
db.flush() # Flush to ensure deletion happens before insert
db.flush()
# Save new refresh token
refresh_token = RefreshToken(
user_id=user.id,
token=tokens["refreshToken"],
@@ -211,7 +187,6 @@ class AuthService:
except Exception as e:
db.rollback()
logger.error(f"Error saving refresh token for user {user.id}: {str(e)}", exc_info=True)
# If there's still a duplicate, try to delete and retry once
try:
db.query(RefreshToken).filter(
RefreshToken.token == tokens["refreshToken"]
@@ -236,14 +211,11 @@ class AuthService:
}
async def refresh_access_token(self, db: Session, refresh_token_str: str) -> dict:
"""Refresh access token"""
if not refresh_token_str:
raise ValueError("Refresh token is required")
# Verify refresh token
decoded = self.verify_refresh_token(refresh_token_str)
# Check if refresh token exists in database
stored_token = db.query(RefreshToken).filter(
RefreshToken.token == refresh_token_str,
RefreshToken.user_id == decoded["userId"]
@@ -252,13 +224,11 @@ class AuthService:
if not stored_token:
raise ValueError("Invalid refresh token")
# Check if token is expired
if datetime.utcnow() > stored_token.expires_at:
db.delete(stored_token)
db.commit()
raise ValueError("Refresh token expired")
# Generate new access token
access_token = jwt.encode(
{"userId": decoded["userId"]},
self.jwt_secret,
@@ -268,19 +238,16 @@ class AuthService:
return {"token": access_token}
async def logout(self, db: Session, refresh_token_str: str) -> bool:
"""Logout user"""
if refresh_token_str:
db.query(RefreshToken).filter(RefreshToken.token == refresh_token_str).delete()
db.commit()
return True
async def get_profile(self, db: Session, user_id: int) -> dict:
"""Get user profile"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
return self.format_user_response(user)
@@ -296,25 +263,22 @@ class AuthService:
current_password: Optional[str] = None,
currency: Optional[str] = None
) -> dict:
"""Update user profile"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
# If password is being changed, verify current password
if password:
if not current_password:
raise ValueError("Current password is required to change password")
if not self.verify_password(current_password, user.password):
raise ValueError("Current password is incorrect")
# Hash new password
user.password = self.hash_password(password)
# Update other fields
if full_name is not None:
user.full_name = full_name
if email is not None:
# Check if email is already taken by another user
existing_user = db.query(User).filter(
User.email == email,
User.id != user_id
@@ -325,7 +289,7 @@ class AuthService:
if phone_number is not None:
user.phone = phone_number
if currency is not None:
# Validate currency code (ISO 4217, 3 characters)
if len(currency) == 3 and currency.isalpha():
user.currency = currency.upper()
else:
@@ -334,36 +298,29 @@ class AuthService:
db.commit()
db.refresh(user)
# Load role
user.role = db.query(Role).filter(Role.id == user.role_id).first()
return self.format_user_response(user)
def generate_reset_token(self) -> tuple:
"""Generate reset token"""
reset_token = secrets.token_hex(32)
hashed_token = hashlib.sha256(reset_token.encode()).hexdigest()
return reset_token, hashed_token
async def forgot_password(self, db: Session, email: str) -> dict:
"""Forgot Password - Send reset link"""
# Find user by email
user = db.query(User).filter(User.email == email).first()
# Always return success to prevent email enumeration
if not user:
return {
"success": True,
"message": "If email exists, reset link has been sent"
}
# Generate reset token
reset_token, hashed_token = self.generate_reset_token()
# Delete old tokens
db.query(PasswordResetToken).filter(PasswordResetToken.user_id == user.id).delete()
# Save token (expires in 1 hour)
expires_at = datetime.utcnow() + timedelta(hours=1)
reset_token_obj = PasswordResetToken(
user_id=user.id,
@@ -373,31 +330,17 @@ class AuthService:
db.add(reset_token_obj)
db.commit()
# Build reset URL
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
reset_url = f"{client_url}/reset-password/{reset_token}"
# Try to send email
try:
logger.info(f"Attempting to send password reset email to {user.email}")
logger.info(f"Reset URL: {reset_url}")
email_html = password_reset_email_template(reset_url)
# Create plain text version for better email deliverability
plain_text = f"""
Password Reset Request
You (or someone) has requested to reset your password for your Hotel Booking account.
Click the link below to reset your password. This link will expire in 1 hour:
{reset_url}
If you did not request this, please ignore this email.
Best regards,
Hotel Booking Team
""".strip()
plain_text = f
.strip()
await send_email(
to=user.email,
@@ -408,7 +351,6 @@ Hotel Booking Team
logger.info(f"Password reset email sent successfully to {user.email} with reset URL: {reset_url}")
except Exception as e:
logger.error(f"Failed to send password reset email to {user.email}: {type(e).__name__}: {str(e)}", exc_info=True)
# Still return success to prevent email enumeration, but log the error
return {
"success": True,
@@ -416,14 +358,11 @@ Hotel Booking Team
}
async def reset_password(self, db: Session, token: str, password: str) -> dict:
"""Reset Password - Update password with token"""
if not token or not password:
raise ValueError("Token and password are required")
# Hash the token to compare
hashed_token = hashlib.sha256(token.encode()).hexdigest()
# Find valid token
reset_token = db.query(PasswordResetToken).filter(
PasswordResetToken.token == hashed_token,
PasswordResetToken.expires_at > datetime.utcnow(),
@@ -433,27 +372,21 @@ Hotel Booking Team
if not reset_token:
raise ValueError("Invalid or expired reset token")
# Find user
user = db.query(User).filter(User.id == reset_token.user_id).first()
if not user:
raise ValueError("User not found")
# Check if new password matches old password
if self.verify_password(password, user.password):
raise ValueError("New password must be different from the old password")
# Hash new password
hashed_password = self.hash_password(password)
# Update password
user.password = hashed_password
db.commit()
# Mark token as used
reset_token.used = True
db.commit()
# Send confirmation email (non-blocking)
try:
logger.info(f"Attempting to send password changed confirmation email to {user.email}")
email_html = password_changed_email_template(user.email)
@@ -471,6 +404,5 @@ Hotel Booking Team
"message": "Password has been reset successfully"
}
auth_service = AuthService()

View File

@@ -1,101 +1,42 @@
"""
Currency conversion service
Handles currency conversion between different currencies
"""
from typing import Dict
from decimal import Decimal
# Base currency is VND (Vietnamese Dong)
# Exchange rates relative to VND (1 VND = base)
# These are approximate rates - in production, fetch from an API like exchangerate-api.com
EXCHANGE_RATES: Dict[str, Decimal] = {
'VND': Decimal('1.0'), # Base currency
'USD': Decimal('0.000041'), # 1 VND = 0.000041 USD (approx 24,000 VND = 1 USD)
'EUR': Decimal('0.000038'), # 1 VND = 0.000038 EUR (approx 26,000 VND = 1 EUR)
'GBP': Decimal('0.000033'), # 1 VND = 0.000033 GBP (approx 30,000 VND = 1 GBP)
'JPY': Decimal('0.0061'), # 1 VND = 0.0061 JPY (approx 164 VND = 1 JPY)
'CNY': Decimal('0.00029'), # 1 VND = 0.00029 CNY (approx 3,400 VND = 1 CNY)
'KRW': Decimal('0.055'), # 1 VND = 0.055 KRW (approx 18 VND = 1 KRW)
'SGD': Decimal('0.000055'), # 1 VND = 0.000055 SGD (approx 18,000 VND = 1 SGD)
'THB': Decimal('0.0015'), # 1 VND = 0.0015 THB (approx 667 VND = 1 THB)
'AUD': Decimal('0.000062'), # 1 VND = 0.000062 AUD (approx 16,000 VND = 1 AUD)
'CAD': Decimal('0.000056'), # 1 VND = 0.000056 CAD (approx 18,000 VND = 1 CAD)
}
# Supported currencies list
EXCHANGE_RATES: Dict[str, Decimal] = {'VND': Decimal('1.0'), 'USD': Decimal('0.000041'), 'EUR': Decimal('0.000038'), 'GBP': Decimal('0.000033'), 'JPY': Decimal('0.0061'), 'CNY': Decimal('0.00029'), 'KRW': Decimal('0.055'), 'SGD': Decimal('0.000055'), 'THB': Decimal('0.0015'), 'AUD': Decimal('0.000062'), 'CAD': Decimal('0.000056')}
SUPPORTED_CURRENCIES = list(EXCHANGE_RATES.keys())
class CurrencyService:
"""Service for currency conversion"""
@staticmethod
def get_supported_currencies() -> list:
"""Get list of supported currency codes"""
return SUPPORTED_CURRENCIES
@staticmethod
def convert_amount(amount: float, from_currency: str, to_currency: str) -> float:
"""
Convert amount from one currency to another
Args:
amount: Amount to convert
from_currency: Source currency code (ISO 4217)
to_currency: Target currency code (ISO 4217)
Returns:
Converted amount
"""
from_currency = from_currency.upper()
to_currency = to_currency.upper()
if from_currency == to_currency:
return amount
if from_currency not in EXCHANGE_RATES:
raise ValueError(f"Unsupported source currency: {from_currency}")
raise ValueError(f'Unsupported source currency: {from_currency}')
if to_currency not in EXCHANGE_RATES:
raise ValueError(f"Unsupported target currency: {to_currency}")
# Convert to VND first, then to target currency
raise ValueError(f'Unsupported target currency: {to_currency}')
amount_vnd = Decimal(str(amount)) / EXCHANGE_RATES[from_currency]
converted_amount = amount_vnd * EXCHANGE_RATES[to_currency]
return float(converted_amount)
@staticmethod
def get_exchange_rate(from_currency: str, to_currency: str) -> float:
"""
Get exchange rate between two currencies
Args:
from_currency: Source currency code
to_currency: Target currency code
Returns:
Exchange rate (1 from_currency = X to_currency)
"""
from_currency = from_currency.upper()
to_currency = to_currency.upper()
if from_currency == to_currency:
return 1.0
if from_currency not in EXCHANGE_RATES:
raise ValueError(f"Unsupported source currency: {from_currency}")
raise ValueError(f'Unsupported source currency: {from_currency}')
if to_currency not in EXCHANGE_RATES:
raise ValueError(f"Unsupported target currency: {to_currency}")
# Rate = (1 / from_rate) * to_rate
raise ValueError(f'Unsupported target currency: {to_currency}')
rate = EXCHANGE_RATES[to_currency] / EXCHANGE_RATES[from_currency]
return float(rate)
@staticmethod
def format_currency_code(currency: str) -> str:
"""Format currency code to uppercase"""
return currency.upper() if currency else 'VND'
currency_service = CurrencyService()
currency_service = CurrencyService()

View File

@@ -1,6 +1,3 @@
"""
Invoice service for managing invoices
"""
from sqlalchemy.orm import Session
from sqlalchemy import func, and_, or_
from typing import Optional, Dict, Any, List
@@ -10,110 +7,46 @@ from ..models.booking import Booking
from ..models.payment import Payment, PaymentStatus
from ..models.user import User
def generate_invoice_number(db: Session, is_proforma: bool = False) -> str:
"""Generate a unique invoice number"""
# Format: INV-YYYYMMDD-XXXX or PRO-YYYYMMDD-XXXX for proforma
prefix = "PRO" if is_proforma else "INV"
today = datetime.utcnow().strftime("%Y%m%d")
# Get the last invoice number for today
last_invoice = db.query(Invoice).filter(
Invoice.invoice_number.like(f"{prefix}-{today}-%")
).order_by(Invoice.invoice_number.desc()).first()
def generate_invoice_number(db: Session, is_proforma: bool=False) -> str:
prefix = 'PRO' if is_proforma else 'INV'
today = datetime.utcnow().strftime('%Y%m%d')
last_invoice = db.query(Invoice).filter(Invoice.invoice_number.like(f'{prefix}-{today}-%')).order_by(Invoice.invoice_number.desc()).first()
if last_invoice:
# Extract the sequence number and increment
try:
sequence = int(last_invoice.invoice_number.split("-")[-1])
sequence = int(last_invoice.invoice_number.split('-')[-1])
sequence += 1
except (ValueError, IndexError):
sequence = 1
else:
sequence = 1
return f"{prefix}-{today}-{sequence:04d}"
return f'{prefix}-{today}-{sequence:04d}'
class InvoiceService:
"""Service for managing invoices"""
@staticmethod
def create_invoice_from_booking(
booking_id: int,
db: Session,
created_by_id: Optional[int] = None,
tax_rate: float = 0.0,
discount_amount: float = 0.0,
due_days: int = 30,
is_proforma: bool = False,
invoice_amount: Optional[float] = None, # For partial invoices (e.g., deposit)
**kwargs
) -> Dict[str, Any]:
"""
Create an invoice from a booking
Args:
booking_id: Booking ID
db: Database session
created_by_id: User ID who created the invoice
tax_rate: Tax rate percentage (default: 0.0)
discount_amount: Discount amount (default: 0.0)
due_days: Number of days until due date (default: 30)
**kwargs: Additional invoice fields (company info, notes, etc.)
Returns:
Invoice dictionary
"""
def create_invoice_from_booking(booking_id: int, db: Session, created_by_id: Optional[int]=None, tax_rate: float=0.0, discount_amount: float=0.0, due_days: int=30, is_proforma: bool=False, invoice_amount: Optional[float]=None, **kwargs) -> Dict[str, Any]:
from sqlalchemy.orm import selectinload
booking = db.query(Booking).options(
selectinload(Booking.service_usages).selectinload("service"),
selectinload(Booking.room).selectinload("room_type"),
selectinload(Booking.payments)
).filter(Booking.id == booking_id).first()
booking = db.query(Booking).options(selectinload(Booking.service_usages).selectinload('service'), selectinload(Booking.room).selectinload('room_type'), selectinload(Booking.payments)).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
raise ValueError('Booking not found')
user = db.query(User).filter(User.id == booking.user_id).first()
if not user:
raise ValueError("User not found")
# Generate invoice number
raise ValueError('User not found')
invoice_number = generate_invoice_number(db, is_proforma=is_proforma)
# If invoice_amount is specified, we need to adjust item calculations
# This will be handled in the item creation section below
# Calculate amounts - subtotal will be recalculated after adding items
# Initial subtotal is booking total (room + services) or invoice_amount if specified
booking_total = float(booking.total_price)
if invoice_amount is not None:
subtotal = float(invoice_amount)
# For partial invoices, ensure discount is proportional
# If discount_amount seems too large (greater than subtotal), recalculate proportionally
if invoice_amount < booking_total and discount_amount > 0:
# Check if discount seems disproportionate (greater than 50% of subtotal suggests it's the full discount)
if discount_amount > subtotal * 0.5:
# Recalculate proportionally from booking's original discount
proportion = float(invoice_amount) / booking_total
original_discount = float(booking.discount_amount) if booking.discount_amount else discount_amount
discount_amount = original_discount * proportion
else:
subtotal = booking_total
# Calculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
# Calculate amount paid from completed payments
amount_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
amount_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
balance_due = total_amount - amount_paid
# Determine status
if balance_due <= 0:
status = InvoiceStatus.paid
paid_date = datetime.utcnow()
@@ -123,252 +56,99 @@ class InvoiceService:
else:
status = InvoiceStatus.draft
paid_date = None
# Create invoice
invoice = Invoice(
invoice_number=invoice_number,
booking_id=booking_id,
user_id=booking.user_id,
issue_date=datetime.utcnow(),
due_date=datetime.utcnow() + timedelta(days=due_days),
paid_date=paid_date,
subtotal=subtotal,
tax_rate=tax_rate,
tax_amount=tax_amount,
discount_amount=discount_amount,
total_amount=total_amount,
amount_paid=amount_paid,
balance_due=balance_due,
status=status,
is_proforma=is_proforma,
company_name=kwargs.get("company_name"),
company_address=kwargs.get("company_address"),
company_phone=kwargs.get("company_phone"),
company_email=kwargs.get("company_email"),
company_tax_id=kwargs.get("company_tax_id"),
company_logo_url=kwargs.get("company_logo_url"),
customer_name=user.full_name or f"{user.email}",
customer_email=user.email,
customer_address=user.address,
customer_phone=user.phone,
customer_tax_id=kwargs.get("customer_tax_id"),
notes=kwargs.get("notes"),
terms_and_conditions=kwargs.get("terms_and_conditions"),
payment_instructions=kwargs.get("payment_instructions"),
created_by_id=created_by_id,
)
invoice = Invoice(invoice_number=invoice_number, booking_id=booking_id, user_id=booking.user_id, issue_date=datetime.utcnow(), due_date=datetime.utcnow() + timedelta(days=due_days), paid_date=paid_date, subtotal=subtotal, tax_rate=tax_rate, tax_amount=tax_amount, discount_amount=discount_amount, total_amount=total_amount, amount_paid=amount_paid, balance_due=balance_due, status=status, is_proforma=is_proforma, company_name=kwargs.get('company_name'), company_address=kwargs.get('company_address'), company_phone=kwargs.get('company_phone'), company_email=kwargs.get('company_email'), company_tax_id=kwargs.get('company_tax_id'), company_logo_url=kwargs.get('company_logo_url'), customer_name=user.full_name or f'{user.email}', customer_email=user.email, customer_address=user.address, customer_phone=user.phone, customer_tax_id=kwargs.get('customer_tax_id'), notes=kwargs.get('notes'), terms_and_conditions=kwargs.get('terms_and_conditions'), payment_instructions=kwargs.get('payment_instructions'), created_by_id=created_by_id)
db.add(invoice)
db.flush() # Flush to get invoice.id before creating invoice items
# Create invoice items from booking
# Calculate room price (total_price includes services, so subtract services)
services_total = sum(
float(su.total_price) for su in booking.service_usages
)
db.flush()
services_total = sum((float(su.total_price) for su in booking.service_usages))
booking_total = float(booking.total_price)
room_price = booking_total - services_total
# Calculate number of nights
nights = (booking.check_out_date - booking.check_in_date).days
if nights <= 0:
nights = 1
# If invoice_amount is specified (for partial invoices), calculate proportion
if invoice_amount is not None and invoice_amount < booking_total:
# Calculate proportion for partial invoice
proportion = float(invoice_amount) / booking_total
room_price = room_price * proportion
services_total = services_total * proportion
item_description_suffix = f" (Partial: {proportion * 100:.0f}%)"
item_description_suffix = f' (Partial: {proportion * 100:.0f}%)'
else:
item_description_suffix = ""
# Room item
room_item = InvoiceItem(
invoice_id=invoice.id,
description=f"Room: {booking.room.room_number} - {booking.room.room_type.name if booking.room.room_type else 'N/A'} ({nights} night{'s' if nights > 1 else ''}){item_description_suffix}",
quantity=nights,
unit_price=room_price / nights if nights > 0 else room_price,
tax_rate=tax_rate,
discount_amount=0.0,
line_total=room_price,
room_id=booking.room_id,
)
item_description_suffix = ''
room_item = InvoiceItem(invoice_id=invoice.id, description=f'Room: {booking.room.room_number} - {(booking.room.room_type.name if booking.room.room_type else 'N/A')} ({nights} night{('s' if nights > 1 else '')}){item_description_suffix}', quantity=nights, unit_price=room_price / nights if nights > 0 else room_price, tax_rate=tax_rate, discount_amount=0.0, line_total=room_price, room_id=booking.room_id)
db.add(room_item)
# Add service items if any
for service_usage in booking.service_usages:
service_item_price = float(service_usage.total_price)
if invoice_amount is not None and invoice_amount < booking_total:
# Apply proportion to service items
proportion = float(invoice_amount) / booking_total
service_item_price = service_item_price * proportion
service_item = InvoiceItem(
invoice_id=invoice.id,
description=f"Service: {service_usage.service.name}{item_description_suffix}",
quantity=float(service_usage.quantity),
unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price,
tax_rate=tax_rate,
discount_amount=0.0,
line_total=service_item_price,
service_id=service_usage.service_id,
)
service_item = InvoiceItem(invoice_id=invoice.id, description=f'Service: {service_usage.service.name}{item_description_suffix}', quantity=float(service_usage.quantity), unit_price=service_item_price / float(service_usage.quantity) if service_usage.quantity > 0 else service_item_price, tax_rate=tax_rate, discount_amount=0.0, line_total=service_item_price, service_id=service_usage.service_id)
db.add(service_item)
# Recalculate subtotal from items (room + services)
subtotal = room_price + services_total
# Recalculate tax and total amounts
tax_amount = (subtotal - discount_amount) * (tax_rate / 100)
total_amount = subtotal + tax_amount - discount_amount
balance_due = total_amount - amount_paid
# Update invoice with correct amounts
invoice.subtotal = subtotal
invoice.tax_amount = tax_amount
invoice.total_amount = total_amount
invoice.balance_due = balance_due
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def update_invoice(
invoice_id: int,
db: Session,
updated_by_id: Optional[int] = None,
**kwargs
) -> Dict[str, Any]:
"""
Update an invoice
Args:
invoice_id: Invoice ID
db: Database session
updated_by_id: User ID who updated the invoice
**kwargs: Fields to update
Returns:
Updated invoice dictionary
"""
def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, **kwargs) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise ValueError("Invoice not found")
# Update allowed fields
allowed_fields = [
"company_name", "company_address", "company_phone", "company_email",
"company_tax_id", "company_logo_url", "notes", "terms_and_conditions",
"payment_instructions", "status", "due_date", "tax_rate", "discount_amount"
]
raise ValueError('Invoice not found')
allowed_fields = ['company_name', 'company_address', 'company_phone', 'company_email', 'company_tax_id', 'company_logo_url', 'notes', 'terms_and_conditions', 'payment_instructions', 'status', 'due_date', 'tax_rate', 'discount_amount']
for field in allowed_fields:
if field in kwargs:
setattr(invoice, field, kwargs[field])
# Recalculate if tax_rate or discount_amount changed
if "tax_rate" in kwargs or "discount_amount" in kwargs:
tax_rate = kwargs.get("tax_rate", invoice.tax_rate)
discount_amount = kwargs.get("discount_amount", invoice.discount_amount)
if 'tax_rate' in kwargs or 'discount_amount' in kwargs:
tax_rate = kwargs.get('tax_rate', invoice.tax_rate)
discount_amount = kwargs.get('discount_amount', invoice.discount_amount)
invoice.tax_amount = (invoice.subtotal - discount_amount) * (float(tax_rate) / 100)
invoice.total_amount = invoice.subtotal + invoice.tax_amount - discount_amount
invoice.balance_due = invoice.total_amount - invoice.amount_paid
# Update status based on balance
if invoice.balance_due <= 0 and invoice.status != InvoiceStatus.paid:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.balance_due > 0 and invoice.status == InvoiceStatus.paid:
invoice.status = InvoiceStatus.sent
invoice.paid_date = None
invoice.updated_by_id = updated_by_id
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def mark_invoice_as_paid(
invoice_id: int,
db: Session,
amount: Optional[float] = None,
updated_by_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Mark an invoice as paid
Args:
invoice_id: Invoice ID
db: Database session
amount: Payment amount (if None, uses balance_due)
updated_by_id: User ID who marked as paid
Returns:
Updated invoice dictionary
"""
def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
raise ValueError("Invoice not found")
raise ValueError('Invoice not found')
payment_amount = amount if amount is not None else float(invoice.balance_due)
invoice.amount_paid += payment_amount
invoice.balance_due = invoice.total_amount - invoice.amount_paid
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
else:
invoice.status = InvoiceStatus.sent
invoice.updated_by_id = updated_by_id
invoice.updated_at = datetime.utcnow()
db.commit()
db.refresh(invoice)
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def get_invoice(invoice_id: int, db: Session) -> Optional[Dict[str, Any]]:
"""Get invoice by ID"""
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice:
return None
return InvoiceService.invoice_to_dict(invoice)
@staticmethod
def get_invoices(
db: Session,
user_id: Optional[int] = None,
booking_id: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
limit: int = 10
) -> Dict[str, Any]:
"""
Get invoices with filters
Args:
db: Database session
user_id: Filter by user ID
booking_id: Filter by booking ID
status: Filter by status
page: Page number
limit: Items per page
Returns:
Dictionary with invoices and pagination info
"""
def get_invoices(db: Session, user_id: Optional[int]=None, booking_id: Optional[int]=None, status: Optional[str]=None, page: int=1, limit: int=10) -> Dict[str, Any]:
query = db.query(Invoice)
if user_id:
query = query.filter(Invoice.user_id == user_id)
if booking_id:
@@ -379,80 +159,17 @@ class InvoiceService:
query = query.filter(Invoice.status == status_enum)
except ValueError:
pass
# Get total count
total = query.count()
# Apply pagination
offset = (page - 1) * limit
invoices = query.order_by(Invoice.created_at.desc()).offset(offset).limit(limit).all()
return {
"invoices": [InvoiceService.invoice_to_dict(inv) for inv in invoices],
"total": total,
"page": page,
"limit": limit,
"total_pages": (total + limit - 1) // limit
}
return {'invoices': [InvoiceService.invoice_to_dict(inv) for inv in invoices], 'total': total, 'page': page, 'limit': limit, 'total_pages': (total + limit - 1) // limit}
@staticmethod
def invoice_to_dict(invoice: Invoice) -> Dict[str, Any]:
"""Convert invoice model to dictionary"""
# Extract promotion code from notes if present
promotion_code = None
if invoice.notes and "Promotion Code:" in invoice.notes:
if invoice.notes and 'Promotion Code:' in invoice.notes:
try:
promotion_code = invoice.notes.split("Promotion Code:")[1].split("\n")[0].strip()
promotion_code = invoice.notes.split('Promotion Code:')[1].split('\n')[0].strip()
except:
pass
return {
"id": invoice.id,
"invoice_number": invoice.invoice_number,
"booking_id": invoice.booking_id,
"user_id": invoice.user_id,
"issue_date": invoice.issue_date.isoformat() if invoice.issue_date else None,
"due_date": invoice.due_date.isoformat() if invoice.due_date else None,
"paid_date": invoice.paid_date.isoformat() if invoice.paid_date else None,
"subtotal": float(invoice.subtotal) if invoice.subtotal else 0.0,
"tax_rate": float(invoice.tax_rate) if invoice.tax_rate else 0.0,
"tax_amount": float(invoice.tax_amount) if invoice.tax_amount else 0.0,
"discount_amount": float(invoice.discount_amount) if invoice.discount_amount else 0.0,
"total_amount": float(invoice.total_amount) if invoice.total_amount else 0.0,
"amount_paid": float(invoice.amount_paid) if invoice.amount_paid else 0.0,
"balance_due": float(invoice.balance_due) if invoice.balance_due else 0.0,
"status": invoice.status.value if invoice.status else None,
"company_name": invoice.company_name,
"company_address": invoice.company_address,
"company_phone": invoice.company_phone,
"company_email": invoice.company_email,
"company_tax_id": invoice.company_tax_id,
"company_logo_url": invoice.company_logo_url,
"customer_name": invoice.customer_name,
"customer_email": invoice.customer_email,
"customer_address": invoice.customer_address,
"customer_phone": invoice.customer_phone,
"customer_tax_id": invoice.customer_tax_id,
"notes": invoice.notes,
"terms_and_conditions": invoice.terms_and_conditions,
"payment_instructions": invoice.payment_instructions,
"is_proforma": invoice.is_proforma if hasattr(invoice, 'is_proforma') else False,
"promotion_code": promotion_code,
"items": [
{
"id": item.id,
"description": item.description,
"quantity": float(item.quantity) if item.quantity else 0.0,
"unit_price": float(item.unit_price) if item.unit_price else 0.0,
"tax_rate": float(item.tax_rate) if item.tax_rate else 0.0,
"discount_amount": float(item.discount_amount) if item.discount_amount else 0.0,
"line_total": float(item.line_total) if item.line_total else 0.0,
"room_id": item.room_id,
"service_id": item.service_id,
}
for item in invoice.items
],
"created_at": invoice.created_at.isoformat() if invoice.created_at else None,
"updated_at": invoice.updated_at.isoformat() if invoice.updated_at else None,
}
return {'id': invoice.id, 'invoice_number': invoice.invoice_number, 'booking_id': invoice.booking_id, 'user_id': invoice.user_id, 'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None, 'due_date': invoice.due_date.isoformat() if invoice.due_date else None, 'paid_date': invoice.paid_date.isoformat() if invoice.paid_date else None, 'subtotal': float(invoice.subtotal) if invoice.subtotal else 0.0, 'tax_rate': float(invoice.tax_rate) if invoice.tax_rate else 0.0, 'tax_amount': float(invoice.tax_amount) if invoice.tax_amount else 0.0, 'discount_amount': float(invoice.discount_amount) if invoice.discount_amount else 0.0, 'total_amount': float(invoice.total_amount) if invoice.total_amount else 0.0, 'amount_paid': float(invoice.amount_paid) if invoice.amount_paid else 0.0, 'balance_due': float(invoice.balance_due) if invoice.balance_due else 0.0, 'status': invoice.status.value if invoice.status else None, 'company_name': invoice.company_name, 'company_address': invoice.company_address, 'company_phone': invoice.company_phone, 'company_email': invoice.company_email, 'company_tax_id': invoice.company_tax_id, 'company_logo_url': invoice.company_logo_url, 'customer_name': invoice.customer_name, 'customer_email': invoice.customer_email, 'customer_address': invoice.customer_address, 'customer_phone': invoice.customer_phone, 'customer_tax_id': invoice.customer_tax_id, 'notes': invoice.notes, 'terms_and_conditions': invoice.terms_and_conditions, 'payment_instructions': invoice.payment_instructions, 'is_proforma': invoice.is_proforma if hasattr(invoice, 'is_proforma') else False, 'promotion_code': promotion_code, 'items': [{'id': item.id, 'description': item.description, 'quantity': float(item.quantity) if item.quantity else 0.0, 'unit_price': float(item.unit_price) if item.unit_price else 0.0, 'tax_rate': float(item.tax_rate) if item.tax_rate else 0.0, 'discount_amount': float(item.discount_amount) if item.discount_amount else 0.0, 'line_total': float(item.line_total) if item.line_total else 0.0, 'room_id': item.room_id, 'service_id': item.service_id} for item in invoice.items], 'created_at': invoice.created_at.isoformat() if invoice.created_at else None, 'updated_at': invoice.updated_at.isoformat() if invoice.updated_at else None}

View File

@@ -1,7 +1,3 @@
"""
Multi-Factor Authentication (MFA) Service
Handles TOTP-based MFA functionality
"""
import pyotp
import qrcode
import secrets
@@ -13,287 +9,119 @@ from typing import List, Optional, Dict, Tuple
from sqlalchemy.orm import Session
from ..models.user import User
import logging
logger = logging.getLogger(__name__)
class MFAService:
"""Service for managing Multi-Factor Authentication"""
@staticmethod
def generate_secret() -> str:
"""Generate a new TOTP secret"""
return pyotp.random_base32()
@staticmethod
def generate_qr_code(secret: str, email: str, app_name: str = "Hotel Booking") -> str:
"""
Generate QR code data URL for TOTP setup
Args:
secret: TOTP secret key
email: User's email address
app_name: Application name for the authenticator app
Returns:
Base64 encoded QR code image data URL
"""
# Create provisioning URI for authenticator apps
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(
name=email,
issuer_name=app_name
)
# Generate QR code
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_L,
box_size=10,
border=4,
)
def generate_qr_code(secret: str, email: str, app_name: str='Hotel Booking') -> str:
totp_uri = pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=app_name)
qr = qrcode.QRCode(version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4)
qr.add_data(totp_uri)
qr.make(fit=True)
# Create image
img = qr.make_image(fill_color="black", back_color="white")
# Convert to base64 data URL
img = qr.make_image(fill_color='black', back_color='white')
buffer = io.BytesIO()
img.save(buffer, format='PNG')
img_data = base64.b64encode(buffer.getvalue()).decode()
return f"data:image/png;base64,{img_data}"
return f'data:image/png;base64,{img_data}'
@staticmethod
def generate_backup_codes(count: int = 10) -> List[str]:
"""
Generate backup codes for MFA recovery
Args:
count: Number of backup codes to generate (default: 10)
Returns:
List of backup codes (8-character alphanumeric)
"""
def generate_backup_codes(count: int=10) -> List[str]:
codes = []
for _ in range(count):
# Generate 8-character alphanumeric code
code = secrets.token_urlsafe(6).upper()[:8]
codes.append(code)
return codes
@staticmethod
def hash_backup_code(code: str) -> str:
"""
Hash a backup code for storage (SHA-256)
Args:
code: Plain backup code
Returns:
Hashed backup code
"""
return hashlib.sha256(code.encode()).hexdigest()
@staticmethod
def verify_backup_code(code: str, hashed_codes: List[str]) -> bool:
"""
Verify if a backup code matches any hashed code
Args:
code: Plain backup code to verify
hashed_codes: List of hashed backup codes
Returns:
True if code matches, False otherwise
"""
code_hash = MFAService.hash_backup_code(code)
return code_hash in hashed_codes
@staticmethod
def verify_totp(token: str, secret: str) -> bool:
"""
Verify a TOTP token
Args:
token: 6-digit TOTP token from authenticator app
secret: User's TOTP secret
Returns:
True if token is valid, False otherwise
"""
try:
totp = pyotp.TOTP(secret)
# Allow tokens from current and previous/next time window for clock skew
return totp.verify(token, valid_window=1)
except Exception as e:
logger.error(f"Error verifying TOTP: {str(e)}")
logger.error(f'Error verifying TOTP: {str(e)}')
return False
@staticmethod
def enable_mfa(
db: Session,
user_id: int,
secret: str,
verification_token: str
) -> Tuple[bool, List[str]]:
"""
Enable MFA for a user after verifying the token
Args:
db: Database session
user_id: User ID
secret: TOTP secret
verification_token: Token from authenticator app to verify
Returns:
Tuple of (success, backup_codes)
"""
def enable_mfa(db: Session, user_id: int, secret: str, verification_token: str) -> Tuple[bool, List[str]]:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
# Verify the token before enabling
raise ValueError('User not found')
if not MFAService.verify_totp(verification_token, secret):
raise ValueError("Invalid verification token")
# Generate backup codes
raise ValueError('Invalid verification token')
backup_codes = MFAService.generate_backup_codes()
hashed_codes = [MFAService.hash_backup_code(code) for code in backup_codes]
# Update user
user.mfa_enabled = True
user.mfa_secret = secret
user.mfa_backup_codes = json.dumps(hashed_codes)
db.commit()
# Return plain backup codes (only shown once)
return True, backup_codes
return (True, backup_codes)
@staticmethod
def disable_mfa(db: Session, user_id: int) -> bool:
"""
Disable MFA for a user
Args:
db: Database session
user_id: User ID
Returns:
True if successful
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
raise ValueError('User not found')
user.mfa_enabled = False
user.mfa_secret = None
user.mfa_backup_codes = None
db.commit()
return True
@staticmethod
def verify_mfa(
db: Session,
user_id: int,
token: str,
is_backup_code: bool = False
) -> bool:
"""
Verify MFA token or backup code for a user
Args:
db: Database session
user_id: User ID
token: TOTP token or backup code
is_backup_code: Whether the token is a backup code
Returns:
True if verification successful, False otherwise
"""
def verify_mfa(db: Session, user_id: int, token: str, is_backup_code: bool=False) -> bool:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
raise ValueError('User not found')
if not user.mfa_enabled or not user.mfa_secret:
raise ValueError("MFA is not enabled for this user")
raise ValueError('MFA is not enabled for this user')
if is_backup_code:
# Verify backup code
if not user.mfa_backup_codes:
return False
hashed_codes = json.loads(user.mfa_backup_codes)
if not MFAService.verify_backup_code(token, hashed_codes):
return False
# Remove used backup code
code_hash = MFAService.hash_backup_code(token)
hashed_codes.remove(code_hash)
user.mfa_backup_codes = json.dumps(hashed_codes) if hashed_codes else None
db.commit()
return True
else:
# Verify TOTP token
return MFAService.verify_totp(token, user.mfa_secret)
@staticmethod
def regenerate_backup_codes(db: Session, user_id: int) -> List[str]:
"""
Regenerate backup codes for a user
Args:
db: Database session
user_id: User ID
Returns:
List of new backup codes (plain, shown once)
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
raise ValueError('User not found')
if not user.mfa_enabled:
raise ValueError("MFA is not enabled for this user")
# Generate new backup codes
raise ValueError('MFA is not enabled for this user')
backup_codes = MFAService.generate_backup_codes()
hashed_codes = [MFAService.hash_backup_code(code) for code in backup_codes]
user.mfa_backup_codes = json.dumps(hashed_codes)
db.commit()
# Return plain backup codes (only shown once)
return backup_codes
@staticmethod
def get_mfa_status(db: Session, user_id: int) -> Dict:
"""
Get MFA status for a user
Args:
db: Database session
user_id: User ID
Returns:
Dictionary with MFA status information
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise ValueError("User not found")
raise ValueError('User not found')
backup_codes_count = 0
if user.mfa_backup_codes:
backup_codes_count = len(json.loads(user.mfa_backup_codes))
return {
"mfa_enabled": user.mfa_enabled,
"backup_codes_count": backup_codes_count
}
# Create singleton instance
mfa_service = MFAService()
return {'mfa_enabled': user.mfa_enabled, 'backup_codes_count': backup_codes_count}
mfa_service = MFAService()

View File

@@ -1,6 +1,3 @@
"""
PayPal payment service for processing PayPal payments
"""
import logging
from paypalcheckoutsdk.core import PayPalHttpClient, SandboxEnvironment, LiveEnvironment
from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, OrdersCaptureRequest
@@ -13,434 +10,208 @@ from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session
from datetime import datetime
import json
logger = logging.getLogger(__name__)
def get_paypal_client_id(db: Session) -> Optional[str]:
"""Get PayPal client ID from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_id"
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_client_id').first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.PAYPAL_CLIENT_ID if settings.PAYPAL_CLIENT_ID else None
def get_paypal_client_secret(db: Session) -> Optional[str]:
"""Get PayPal client secret from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_client_secret"
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_client_secret').first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.PAYPAL_CLIENT_SECRET if settings.PAYPAL_CLIENT_SECRET else None
def get_paypal_mode(db: Session) -> str:
"""Get PayPal mode from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "paypal_mode"
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == 'paypal_mode').first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.PAYPAL_MODE if settings.PAYPAL_MODE else "sandbox"
return settings.PAYPAL_MODE if settings.PAYPAL_MODE else 'sandbox'
def get_paypal_client(db: Optional[Session] = None) -> PayPalHttpClient:
"""
Get PayPal HTTP client
Args:
db: Optional database session to get credentials from database
Returns:
PayPalHttpClient instance
"""
def get_paypal_client(db: Optional[Session]=None) -> PayPalHttpClient:
client_id = None
client_secret = None
mode = "sandbox"
mode = 'sandbox'
if db:
client_id = get_paypal_client_id(db)
client_secret = get_paypal_client_secret(db)
mode = get_paypal_mode(db)
if not client_id:
client_id = settings.PAYPAL_CLIENT_ID
if not client_secret:
client_secret = settings.PAYPAL_CLIENT_SECRET
if not mode:
mode = settings.PAYPAL_MODE or "sandbox"
mode = settings.PAYPAL_MODE or 'sandbox'
if not client_id or not client_secret:
raise ValueError("PayPal credentials are not configured")
# Create environment based on mode
if mode.lower() == "live":
raise ValueError('PayPal credentials are not configured')
if mode.lower() == 'live':
environment = LiveEnvironment(client_id=client_id, client_secret=client_secret)
else:
environment = SandboxEnvironment(client_id=client_id, client_secret=client_secret)
return PayPalHttpClient(environment)
class PayPalService:
"""Service for handling PayPal payments"""
@staticmethod
def create_order(
amount: float,
currency: str = "USD",
metadata: Optional[Dict[str, Any]] = None,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Create a PayPal order
Args:
amount: Payment amount in currency units
currency: Currency code (default: USD)
metadata: Additional metadata to attach to the order
db: Optional database session to get credentials from database
Returns:
Order object with approval URL and order ID
"""
def create_order(amount: float, currency: str='USD', metadata: Optional[Dict[str, Any]]=None, db: Optional[Session]=None) -> Dict[str, Any]:
client = get_paypal_client(db)
# Validate amount
if amount <= 0:
raise ValueError("Amount must be greater than 0")
raise ValueError('Amount must be greater than 0')
if amount > 100000:
raise ValueError(f"Amount ${amount:,.2f} exceeds PayPal's maximum of $100,000")
# Create order request
request = OrdersCreateRequest()
request.prefer("return=representation")
# Build order body
order_data = {
"intent": "CAPTURE",
"purchase_units": [
{
"amount": {
"currency_code": currency.upper(),
"value": f"{amount:.2f}"
},
"description": metadata.get("description", "Hotel Booking Payment") if metadata else "Hotel Booking Payment",
"custom_id": metadata.get("booking_id") if metadata else None,
}
],
"application_context": {
"brand_name": "Hotel Booking",
"landing_page": "BILLING",
"user_action": "PAY_NOW",
"return_url": metadata.get("return_url") if metadata else None,
"cancel_url": metadata.get("cancel_url") if metadata else None,
}
}
# Add metadata if provided
request.prefer('return=representation')
order_data = {'intent': 'CAPTURE', 'purchase_units': [{'amount': {'currency_code': currency.upper(), 'value': f'{amount:.2f}'}, 'description': metadata.get('description', 'Hotel Booking Payment') if metadata else 'Hotel Booking Payment', 'custom_id': metadata.get('booking_id') if metadata else None}], 'application_context': {'brand_name': 'Hotel Booking', 'landing_page': 'BILLING', 'user_action': 'PAY_NOW', 'return_url': metadata.get('return_url') if metadata else None, 'cancel_url': metadata.get('cancel_url') if metadata else None}}
if metadata:
order_data["purchase_units"][0]["invoice_id"] = metadata.get("booking_number")
order_data['purchase_units'][0]['invoice_id'] = metadata.get('booking_number')
request.request_body(order_data)
try:
response = client.execute(request)
order = response.result
# Extract approval URL
approval_url = None
for link in order.links:
if link.rel == "approve":
if link.rel == 'approve':
approval_url = link.href
break
return {
"id": order.id,
"status": order.status,
"approval_url": approval_url,
"amount": amount,
"currency": currency.upper(),
}
return {'id': order.id, 'status': order.status, 'approval_url': approval_url, 'amount': amount, 'currency': currency.upper()}
except Exception as e:
error_msg = str(e)
# Try to extract more details from PayPal error
if hasattr(e, 'message'):
error_msg = e.message
elif hasattr(e, 'details') and e.details:
error_msg = json.dumps(e.details)
raise ValueError(f"PayPal error: {error_msg}")
raise ValueError(f'PayPal error: {error_msg}')
@staticmethod
def get_order(
order_id: str,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Retrieve an order by ID
Args:
order_id: PayPal order ID
db: Optional database session to get credentials from database
Returns:
Order object
"""
def get_order(order_id: str, db: Optional[Session]=None) -> Dict[str, Any]:
client = get_paypal_client(db)
request = OrdersGetRequest(order_id)
try:
response = client.execute(request)
order = response.result
# Extract amount from purchase units
amount = 0.0
currency = "USD"
currency = 'USD'
if order.purchase_units and len(order.purchase_units) > 0:
amount_str = order.purchase_units[0].amount.value
currency = order.purchase_units[0].amount.currency_code
amount = float(amount_str)
return {
"id": order.id,
"status": order.status,
"amount": amount,
"currency": currency,
"create_time": order.create_time,
"update_time": order.update_time,
}
return {'id': order.id, 'status': order.status, 'amount': amount, 'currency': currency, 'create_time': order.create_time, 'update_time': order.update_time}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
raise ValueError(f"PayPal error: {error_msg}")
raise ValueError(f'PayPal error: {error_msg}')
@staticmethod
def capture_order(
order_id: str,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Capture a PayPal order
Args:
order_id: PayPal order ID
db: Optional database session to get credentials from database
Returns:
Capture details
"""
def capture_order(order_id: str, db: Optional[Session]=None) -> Dict[str, Any]:
client = get_paypal_client(db)
request = OrdersCaptureRequest(order_id)
request.prefer("return=representation")
request.prefer('return=representation')
try:
response = client.execute(request)
order = response.result
# Extract capture details
capture_id = None
amount = 0.0
currency = "USD"
currency = 'USD'
status = order.status
if order.purchase_units and len(order.purchase_units) > 0:
payments = order.purchase_units[0].payments
if payments and payments.captures and len(payments.captures) > 0:
if payments and payments.captures and (len(payments.captures) > 0):
capture = payments.captures[0]
capture_id = capture.id
amount_str = capture.amount.value
currency = capture.amount.currency_code
amount = float(amount_str)
status = capture.status
return {
"order_id": order.id,
"capture_id": capture_id,
"status": status,
"amount": amount,
"currency": currency,
}
return {'order_id': order.id, 'capture_id': capture_id, 'status': status, 'amount': amount, 'currency': currency}
except Exception as e:
error_msg = str(e)
if hasattr(e, 'message'):
error_msg = e.message
raise ValueError(f"PayPal error: {error_msg}")
raise ValueError(f'PayPal error: {error_msg}')
@staticmethod
async def confirm_payment(
order_id: str,
db: Session,
booking_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Confirm a payment and update database records
Args:
order_id: PayPal order ID
db: Database session
booking_id: Optional booking ID for metadata lookup
Returns:
Payment record dictionary
"""
async def confirm_payment(order_id: str, db: Session, booking_id: Optional[int]=None) -> Dict[str, Any]:
try:
# First capture the order
capture_data = PayPalService.capture_order(order_id, db)
# Get order details to extract booking_id from metadata if not provided
if not booking_id:
order_data = PayPalService.get_order(order_id, db)
# Try to get booking_id from custom_id in purchase_units
# Note: We'll need to store booking_id in the order metadata when creating
# For now, we'll require booking_id to be passed
if not booking_id:
raise ValueError("Booking ID is required")
raise ValueError('Booking ID is required')
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
# Check capture status
capture_status = capture_data.get("status")
if capture_status not in ["COMPLETED", "PENDING"]:
raise ValueError(f"Payment capture not in a valid state. Status: {capture_status}")
# Find existing payment or create new one
# First try to find by transaction_id (for already captured payments)
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.transaction_id == order_id,
Payment.payment_method == PaymentMethod.paypal
).first()
# If not found, try to find pending PayPal payment for this booking
raise ValueError('Booking not found')
capture_status = capture_data.get('status')
if capture_status not in ['COMPLETED', 'PENDING']:
raise ValueError(f'Payment capture not in a valid state. Status: {capture_status}')
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == order_id, Payment.payment_method == PaymentMethod.paypal).first()
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_method == PaymentMethod.paypal,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
# If still not found, try to find pending deposit payment (for cash bookings with deposit)
# This allows updating the payment_method from the default to paypal
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_method == PaymentMethod.paypal, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
amount = capture_data["amount"]
capture_id = capture_data.get("capture_id")
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
amount = capture_data['amount']
capture_id = capture_data.get('capture_id')
if payment:
# Update existing payment
if capture_status == "COMPLETED":
if capture_status == 'COMPLETED':
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
# If pending, keep as pending
payment.amount = amount
payment.payment_method = PaymentMethod.paypal # Update payment method to PayPal
payment.payment_method = PaymentMethod.paypal
if capture_id:
payment.transaction_id = f"{order_id}|{capture_id}"
payment.transaction_id = f'{order_id}|{capture_id}'
else:
# Create new payment record
payment_type = PaymentType.full
if booking.requires_deposit and not booking.deposit_paid:
if booking.requires_deposit and (not booking.deposit_paid):
payment_type = PaymentType.deposit
payment_status_enum = PaymentStatus.completed if capture_status == "COMPLETED" else PaymentStatus.pending
payment_date = datetime.utcnow() if capture_status == "COMPLETED" else None
transaction_id = f"{order_id}|{capture_id}" if capture_id else order_id
payment = Payment(
booking_id=booking_id,
amount=amount,
payment_method=PaymentMethod.paypal,
payment_type=payment_type,
payment_status=payment_status_enum,
transaction_id=transaction_id,
payment_date=payment_date,
notes=f"PayPal payment - Order: {order_id}, Capture: {capture_id} (Status: {capture_status})",
)
payment_status_enum = PaymentStatus.completed if capture_status == 'COMPLETED' else PaymentStatus.pending
payment_date = datetime.utcnow() if capture_status == 'COMPLETED' else None
transaction_id = f'{order_id}|{capture_id}' if capture_id else order_id
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.paypal, payment_type=payment_type, payment_status=payment_status_enum, transaction_id=transaction_id, payment_date=payment_date, notes=f'PayPal payment - Order: {order_id}, Capture: {capture_id} (Status: {capture_status})')
db.add(payment)
# Commit payment first
db.commit()
db.refresh(payment)
# Update booking status only if payment is completed
if payment.payment_status == PaymentStatus.completed:
db.refresh(booking)
# Calculate total paid from all completed payments (now includes current payment)
# This needs to be calculated before the if/elif blocks
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
# Update invoice status based on payment
total_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
from ..models.invoice import Invoice, InvoiceStatus
# Find invoices for this booking and update their status
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
for invoice in invoices:
# Update invoice amount_paid and balance_due
invoice.amount_paid = total_paid
invoice.balance_due = float(invoice.total_amount) - total_paid
# Update invoice status
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.amount_paid > 0:
invoice.status = InvoiceStatus.sent
booking_was_confirmed = False
should_send_email = False
if payment.payment_type == PaymentType.deposit:
booking.deposit_paid = True
# Restore cancelled bookings or confirm pending bookings
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but deposit was just paid
should_send_email = True
elif payment.payment_type == PaymentType.full:
# Confirm booking and restore cancelled bookings when payment succeeds
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but full payment was just completed
should_send_email = True
# Send booking confirmation email if booking was just confirmed or payment completed
if should_send_email:
try:
from ..utils.mailer import send_email
@@ -450,143 +221,74 @@ class PayPalService:
from sqlalchemy.orm import selectinload
import os
from ..config.settings import settings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbols = {'USD': '$', 'EUR': '', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '', 'INR': '', 'CHF': 'CHF', 'NZD': 'NZ$'}
currency_symbol = currency_symbols.get(currency, currency)
# Load booking with room details for email
booking_with_room = db.query(Booking).options(
selectinload(Booking.room).selectinload(Room.room_type)
).filter(Booking.id == booking_id).first()
booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking_id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else "Room"
# Calculate amount paid and remaining due
room_type_name = room.room_type.name if room and room.room_type else 'Room'
amount_paid = total_paid
payment_type_str = payment.payment_type.value if payment.payment_type else None
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
num_guests=booking.num_guests,
total_price=float(booking.total_price),
requires_deposit=False, # Payment completed, no deposit message needed
deposit_amount=None,
amount_paid=amount_paid,
payment_type=payment_type_str,
client_url=client_url,
currency_symbol=currency_symbol
)
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=False, deposit_amount=None, amount_paid=amount_paid, payment_type=payment_type_str, client_url=client_url, currency_symbol=currency_symbol)
if booking.user:
await send_email(
to=booking.user.email,
subject=f"Booking Confirmed - {booking.booking_number}",
html=email_html
)
logger.info(f"Booking confirmation email sent to {booking.user.email}")
await send_email(to=booking.user.email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
logger.info(f'Booking confirmation email sent to {booking.user.email}')
except Exception as email_error:
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
logger.error(f'Failed to send booking confirmation email: {str(email_error)}')
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
logger.error(f'Failed to send invoice email: {str(email_error)}')
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..models.invoice import InvoiceStatus
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
logger.error(f'Failed to send invoice email: {str(email_error)}')
db.commit()
db.refresh(booking)
# Safely get enum values
def get_enum_value(enum_obj):
if enum_obj is None:
return None
if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)):
return enum_obj.value
return enum_obj
return {
"id": payment.id,
"booking_id": payment.booking_id,
"amount": float(payment.amount) if payment.amount else 0.0,
"payment_method": get_enum_value(payment.payment_method),
"payment_type": get_enum_value(payment.payment_type),
"payment_status": get_enum_value(payment.payment_status),
"transaction_id": payment.transaction_id,
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
}
return {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': get_enum_value(payment.payment_method), 'payment_type': get_enum_value(payment.payment_type), 'payment_status': get_enum_value(payment.payment_status), 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None}
except ValueError as e:
db.rollback()
raise
except Exception as e:
import traceback
error_details = traceback.format_exc()
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
print(f"Error in confirm_payment: {error_msg}")
print(f"Traceback: {error_details}")
error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}'
print(f'Error in confirm_payment: {error_msg}')
print(f'Traceback: {error_details}')
db.rollback()
raise ValueError(f"Error confirming payment: {error_msg}")
raise ValueError(f'Error confirming payment: {error_msg}')

View File

@@ -1,27 +1,16 @@
from sqlalchemy.orm import Session
from ..models.cookie_policy import CookiePolicy
from ..models.cookie_integration_config import CookieIntegrationConfig
from ..models.user import User
from ..schemas.admin_privacy import (
CookieIntegrationSettings,
CookiePolicySettings,
PublicPrivacyConfig,
)
from ..schemas.admin_privacy import CookieIntegrationSettings, CookiePolicySettings, PublicPrivacyConfig
class PrivacyAdminService:
"""
Service layer for admin-controlled cookie policy and integrations.
"""
# Policy
@staticmethod
def get_or_create_policy(db: Session) -> CookiePolicy:
policy = db.query(CookiePolicy).first()
if policy:
return policy
policy = CookiePolicy()
db.add(policy)
db.commit()
@@ -31,16 +20,10 @@ class PrivacyAdminService:
@staticmethod
def get_policy_settings(db: Session) -> CookiePolicySettings:
policy = PrivacyAdminService.get_or_create_policy(db)
return CookiePolicySettings(
analytics_enabled=policy.analytics_enabled,
marketing_enabled=policy.marketing_enabled,
preferences_enabled=policy.preferences_enabled,
)
return CookiePolicySettings(analytics_enabled=policy.analytics_enabled, marketing_enabled=policy.marketing_enabled, preferences_enabled=policy.preferences_enabled)
@staticmethod
def update_policy(
db: Session, settings: CookiePolicySettings, updated_by: User | None
) -> CookiePolicy:
def update_policy(db: Session, settings: CookiePolicySettings, updated_by: User | None) -> CookiePolicy:
policy = PrivacyAdminService.get_or_create_policy(db)
policy.analytics_enabled = settings.analytics_enabled
policy.marketing_enabled = settings.marketing_enabled
@@ -52,7 +35,6 @@ class PrivacyAdminService:
db.refresh(policy)
return policy
# Integrations
@staticmethod
def get_or_create_integrations(db: Session) -> CookieIntegrationConfig:
config = db.query(CookieIntegrationConfig).first()
@@ -67,15 +49,10 @@ class PrivacyAdminService:
@staticmethod
def get_integration_settings(db: Session) -> CookieIntegrationSettings:
cfg = PrivacyAdminService.get_or_create_integrations(db)
return CookieIntegrationSettings(
ga_measurement_id=cfg.ga_measurement_id,
fb_pixel_id=cfg.fb_pixel_id,
)
return CookieIntegrationSettings(ga_measurement_id=cfg.ga_measurement_id, fb_pixel_id=cfg.fb_pixel_id)
@staticmethod
def update_integrations(
db: Session, settings: CookieIntegrationSettings, updated_by: User | None
) -> CookieIntegrationConfig:
def update_integrations(db: Session, settings: CookieIntegrationSettings, updated_by: User | None) -> CookieIntegrationConfig:
cfg = PrivacyAdminService.get_or_create_integrations(db)
cfg.ga_measurement_id = settings.ga_measurement_id
cfg.fb_pixel_id = settings.fb_pixel_id
@@ -91,8 +68,4 @@ class PrivacyAdminService:
policy = PrivacyAdminService.get_policy_settings(db)
integrations = PrivacyAdminService.get_integration_settings(db)
return PublicPrivacyConfig(policy=policy, integrations=integrations)
privacy_admin_service = PrivacyAdminService()
privacy_admin_service = PrivacyAdminService()

View File

@@ -3,17 +3,13 @@ from sqlalchemy import func, and_, or_
from typing import Optional, List, Dict
from datetime import datetime
import os
from ..models.room import Room, RoomStatus
from ..models.room_type import RoomType
from ..models.review import Review, ReviewStatus
def normalize_images(images, base_url: str) -> List[str]:
"""Normalize image paths to absolute URLs"""
if not images:
return []
imgs = images
if isinstance(images, str):
try:
@@ -21,10 +17,8 @@ def normalize_images(images, base_url: str) -> List[str]:
imgs = json.loads(images)
except:
imgs = [s.strip() for s in images.split(',') if s.strip()]
if not isinstance(imgs, list):
return []
result = []
for img in imgs:
if not img:
@@ -32,372 +26,39 @@ def normalize_images(images, base_url: str) -> List[str]:
if img.startswith('http://') or img.startswith('https://'):
result.append(img)
else:
path_part = img if img.startswith('/') else f"/{img}"
result.append(f"{base_url}{path_part}")
path_part = img if img.startswith('/') else f'/{img}'
result.append(f'{base_url}{path_part}')
return result
def get_base_url(request) -> str:
"""Get base URL for image normalization"""
# Try to get from environment first
server_url = os.getenv("SERVER_URL")
server_url = os.getenv('SERVER_URL')
if server_url:
return server_url.rstrip('/')
# Get from request host header
host = request.headers.get('host', 'localhost:8000')
# Ensure we use the backend port if host doesn't have a port
if ':' not in host:
host = f"{host}:8000"
# Use http or https based on scheme
host = f'{host}:8000'
scheme = request.url.scheme if hasattr(request.url, 'scheme') else 'http'
return f"{scheme}://{host}"
return f'{scheme}://{host}'
async def get_rooms_with_ratings(
db: Session,
rooms: List[Room],
base_url: str
) -> List[Dict]:
"""Get rooms with calculated ratings"""
async def get_rooms_with_ratings(db: Session, rooms: List[Room], base_url: str) -> List[Dict]:
result = []
for room in rooms:
# Get review stats
review_stats = db.query(
func.avg(Review.rating).label('average_rating'),
func.count(Review.id).label('total_reviews')
).filter(
and_(
Review.room_id == room.id,
Review.status == ReviewStatus.approved
)
).first()
room_dict = {
"id": room.id,
"room_type_id": room.room_type_id,
"room_number": room.room_number,
"floor": room.floor,
"status": room.status.value if isinstance(room.status, RoomStatus) else room.status,
"price": float(room.price) if room.price else 0.0,
"featured": room.featured,
"description": room.description,
"capacity": room.capacity,
"room_size": room.room_size,
"view": room.view,
"amenities": room.amenities,
"created_at": room.created_at.isoformat() if room.created_at else None,
"updated_at": room.updated_at.isoformat() if room.updated_at else None,
"average_rating": round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None,
"total_reviews": review_stats.total_reviews or 0 if review_stats else 0,
}
# Normalize images
review_stats = db.query(func.avg(Review.rating).label('average_rating'), func.count(Review.id).label('total_reviews')).filter(and_(Review.room_id == room.id, Review.status == ReviewStatus.approved)).first()
room_dict = {'id': room.id, 'room_type_id': room.room_type_id, 'room_number': room.room_number, 'floor': room.floor, 'status': room.status.value if isinstance(room.status, RoomStatus) else room.status, 'price': float(room.price) if room.price else 0.0, 'featured': room.featured, 'description': room.description, 'capacity': room.capacity, 'room_size': room.room_size, 'view': room.view, 'amenities': room.amenities, 'created_at': room.created_at.isoformat() if room.created_at else None, 'updated_at': room.updated_at.isoformat() if room.updated_at else None, 'average_rating': round(float(review_stats.average_rating or 0), 1) if review_stats and review_stats.average_rating else None, 'total_reviews': review_stats.total_reviews or 0 if review_stats else 0}
try:
room_dict["images"] = normalize_images(room.images, base_url)
room_dict['images'] = normalize_images(room.images, base_url)
except:
room_dict["images"] = []
# Add room type info
room_dict['images'] = []
if room.room_type:
room_dict["room_type"] = {
"id": room.room_type.id,
"name": room.room_type.name,
"description": room.room_type.description,
"base_price": float(room.room_type.base_price) if room.room_type.base_price else 0.0,
"capacity": room.room_type.capacity,
"amenities": room.room_type.amenities,
"images": [] # RoomType doesn't have images column in DB
}
room_dict['room_type'] = {'id': room.room_type.id, 'name': room.room_type.name, 'description': room.room_type.description, 'base_price': float(room.room_type.base_price) if room.room_type.base_price else 0.0, 'capacity': room.room_type.capacity, 'amenities': room.room_type.amenities, 'images': []}
result.append(room_dict)
return result
def get_predefined_amenities() -> List[str]:
"""Get comprehensive list of predefined hotel room amenities"""
return [
# Basic Amenities
"Free WiFi",
"WiFi",
"High-Speed Internet",
"WiFi in Room",
# Entertainment
"Flat-Screen TV",
"TV",
"Cable TV",
"Satellite TV",
"Smart TV",
"Netflix",
"Streaming Services",
"DVD Player",
"Stereo System",
"Radio",
"iPod Dock",
# Climate Control
"Air Conditioning",
"AC",
"Heating",
"Climate Control",
"Ceiling Fan",
"Air Purifier",
# Bathroom Features
"Private Bathroom",
"Ensuite Bathroom",
"Bathtub",
"Jacuzzi Bathtub",
"Hot Tub",
"Shower",
"Rain Shower",
"Walk-in Shower",
"Bidet",
"Hair Dryer",
"Hairdryer",
"Bathrobes",
"Slippers",
"Toiletries",
"Premium Toiletries",
"Towels",
# Food & Beverage
"Mini Bar",
"Minibar",
"Refrigerator",
"Fridge",
"Microwave",
"Coffee Maker",
"Electric Kettle",
"Tea Making Facilities",
"Coffee Machine",
"Nespresso Machine",
"Kitchenette",
"Dining Table",
"Room Service",
"Breakfast Included",
"Breakfast",
"Complimentary Water",
"Bottled Water",
# Furniture & Space
"Desk",
"Writing Desk",
"Office Desk",
"Work Desk",
"Sofa",
"Sitting Area",
"Lounge Area",
"Dining Area",
"Separate Living Area",
"Wardrobe",
"Closet",
"Dresser",
"Mirror",
"Full-Length Mirror",
"Seating Area",
# Bed & Sleep
"King Size Bed",
"Queen Size Bed",
"Double Bed",
"Twin Beds",
"Single Bed",
"Extra Bedding",
"Pillow Menu",
"Premium Bedding",
"Blackout Curtains",
"Soundproofing",
# Safety & Security
"Safe",
"In-Room Safe",
"Safety Deposit Box",
"Smoke Detector",
"Fire Extinguisher",
"Security System",
"Key Card Access",
"Door Lock",
"Pepper Spray",
# Technology
"USB Charging Ports",
"USB Ports",
"USB Outlets",
"Power Outlets",
"Charging Station",
"Laptop Safe",
"HDMI Port",
"Phone",
"Desk Phone",
"Wake-Up Service",
"Alarm Clock",
"Digital Clock",
# View & Outdoor
"Balcony",
"Private Balcony",
"Terrace",
"Patio",
"City View",
"Ocean View",
"Sea View",
"Mountain View",
"Garden View",
"Pool View",
"Park View",
"Window",
"Large Windows",
"Floor-to-Ceiling Windows",
# Services
"24-Hour Front Desk",
"24 Hour Front Desk",
"24/7 Front Desk",
"Concierge Service",
"Butler Service",
"Housekeeping",
"Daily Housekeeping",
"Turndown Service",
"Laundry Service",
"Dry Cleaning",
"Ironing Service",
"Luggage Storage",
"Bell Service",
"Valet Parking",
"Parking",
"Free Parking",
"Airport Shuttle",
"Shuttle Service",
"Car Rental",
"Taxi Service",
# Fitness & Wellness
"Gym Access",
"Fitness Center",
"Fitness Room",
"Spa Access",
"Spa",
"Sauna",
"Steam Room",
"Hot Tub",
"Massage Service",
"Beauty Services",
# Recreation
"Swimming Pool",
"Pool",
"Indoor Pool",
"Outdoor Pool",
"Infinity Pool",
"Pool Access",
"Golf Course",
"Tennis Court",
"Beach Access",
"Water Sports",
# Business & Work
"Business Center",
"Meeting Room",
"Conference Room",
"Fax Service",
"Photocopying",
"Printing Service",
"Secretarial Services",
# Accessibility
"Wheelchair Accessible",
"Accessible Room",
"Elevator Access",
"Ramp Access",
"Accessible Bathroom",
"Lowered Sink",
"Grab Bars",
"Hearing Accessible",
"Visual Alarm",
# Family & Pets
"Family Room",
"Kids Welcome",
"Baby Crib",
"Extra Bed",
"Crib",
"Childcare Services",
"Pets Allowed",
"Pet Friendly",
# Additional Features
"Smoking Room",
"Non-Smoking Room",
"No Smoking",
"Interconnecting Rooms",
"Adjoining Rooms",
"Suite",
"Separate Bedroom",
"Kitchen",
"Full Kitchen",
"Dishwasher",
"Oven",
"Stove",
"Washing Machine",
"Dryer",
"Iron",
"Ironing Board",
"Clothes Rack",
"Umbrella",
"Shoe Shine Service",
# Luxury Features
"Fireplace",
"Jacuzzi",
"Steam Shower",
"Spa Bath",
"Bidet Toilet",
"Smart Home System",
"Lighting Control",
"Curtain Control",
"Automated Systems",
"Personalized Service",
"VIP Treatment",
"Butler",
"Private Entrance",
"Private Elevator",
"Panic Button",
# Entertainment & Media
"Blu-ray Player",
"Gaming Console",
"PlayStation",
"Xbox",
"Sound System",
"Surround Sound",
"Music System",
# Special Features
"Library",
"Reading Room",
"Study Room",
"Private Pool",
"Private Garden",
"Yard",
"Courtyard",
"Outdoor Furniture",
"BBQ Facilities",
"Picnic Area",
]
return ['Free WiFi', 'WiFi', 'High-Speed Internet', 'WiFi in Room', 'Flat-Screen TV', 'TV', 'Cable TV', 'Satellite TV', 'Smart TV', 'Netflix', 'Streaming Services', 'DVD Player', 'Stereo System', 'Radio', 'iPod Dock', 'Air Conditioning', 'AC', 'Heating', 'Climate Control', 'Ceiling Fan', 'Air Purifier', 'Private Bathroom', 'Ensuite Bathroom', 'Bathtub', 'Jacuzzi Bathtub', 'Hot Tub', 'Shower', 'Rain Shower', 'Walk-in Shower', 'Bidet', 'Hair Dryer', 'Hairdryer', 'Bathrobes', 'Slippers', 'Toiletries', 'Premium Toiletries', 'Towels', 'Mini Bar', 'Minibar', 'Refrigerator', 'Fridge', 'Microwave', 'Coffee Maker', 'Electric Kettle', 'Tea Making Facilities', 'Coffee Machine', 'Nespresso Machine', 'Kitchenette', 'Dining Table', 'Room Service', 'Breakfast Included', 'Breakfast', 'Complimentary Water', 'Bottled Water', 'Desk', 'Writing Desk', 'Office Desk', 'Work Desk', 'Sofa', 'Sitting Area', 'Lounge Area', 'Dining Area', 'Separate Living Area', 'Wardrobe', 'Closet', 'Dresser', 'Mirror', 'Full-Length Mirror', 'Seating Area', 'King Size Bed', 'Queen Size Bed', 'Double Bed', 'Twin Beds', 'Single Bed', 'Extra Bedding', 'Pillow Menu', 'Premium Bedding', 'Blackout Curtains', 'Soundproofing', 'Safe', 'In-Room Safe', 'Safety Deposit Box', 'Smoke Detector', 'Fire Extinguisher', 'Security System', 'Key Card Access', 'Door Lock', 'Pepper Spray', 'USB Charging Ports', 'USB Ports', 'USB Outlets', 'Power Outlets', 'Charging Station', 'Laptop Safe', 'HDMI Port', 'Phone', 'Desk Phone', 'Wake-Up Service', 'Alarm Clock', 'Digital Clock', 'Balcony', 'Private Balcony', 'Terrace', 'Patio', 'City View', 'Ocean View', 'Sea View', 'Mountain View', 'Garden View', 'Pool View', 'Park View', 'Window', 'Large Windows', 'Floor-to-Ceiling Windows', '24-Hour Front Desk', '24 Hour Front Desk', '24/7 Front Desk', 'Concierge Service', 'Butler Service', 'Housekeeping', 'Daily Housekeeping', 'Turndown Service', 'Laundry Service', 'Dry Cleaning', 'Ironing Service', 'Luggage Storage', 'Bell Service', 'Valet Parking', 'Parking', 'Free Parking', 'Airport Shuttle', 'Shuttle Service', 'Car Rental', 'Taxi Service', 'Gym Access', 'Fitness Center', 'Fitness Room', 'Spa Access', 'Spa', 'Sauna', 'Steam Room', 'Hot Tub', 'Massage Service', 'Beauty Services', 'Swimming Pool', 'Pool', 'Indoor Pool', 'Outdoor Pool', 'Infinity Pool', 'Pool Access', 'Golf Course', 'Tennis Court', 'Beach Access', 'Water Sports', 'Business Center', 'Meeting Room', 'Conference Room', 'Fax Service', 'Photocopying', 'Printing Service', 'Secretarial Services', 'Wheelchair Accessible', 'Accessible Room', 'Elevator Access', 'Ramp Access', 'Accessible Bathroom', 'Lowered Sink', 'Grab Bars', 'Hearing Accessible', 'Visual Alarm', 'Family Room', 'Kids Welcome', 'Baby Crib', 'Extra Bed', 'Crib', 'Childcare Services', 'Pets Allowed', 'Pet Friendly', 'Smoking Room', 'Non-Smoking Room', 'No Smoking', 'Interconnecting Rooms', 'Adjoining Rooms', 'Suite', 'Separate Bedroom', 'Kitchen', 'Full Kitchen', 'Dishwasher', 'Oven', 'Stove', 'Washing Machine', 'Dryer', 'Iron', 'Ironing Board', 'Clothes Rack', 'Umbrella', 'Shoe Shine Service', 'Fireplace', 'Jacuzzi', 'Steam Shower', 'Spa Bath', 'Bidet Toilet', 'Smart Home System', 'Lighting Control', 'Curtain Control', 'Automated Systems', 'Personalized Service', 'VIP Treatment', 'Butler', 'Private Entrance', 'Private Elevator', 'Panic Button', 'Blu-ray Player', 'Gaming Console', 'PlayStation', 'Xbox', 'Sound System', 'Surround Sound', 'Music System', 'Library', 'Reading Room', 'Study Room', 'Private Pool', 'Private Garden', 'Yard', 'Courtyard', 'Outdoor Furniture', 'BBQ Facilities', 'Picnic Area']
async def get_amenities_list(db: Session) -> List[str]:
"""Get all unique amenities from room types and rooms, plus predefined amenities"""
# Start with predefined comprehensive list
all_amenities = set(get_predefined_amenities())
# Get from room types
room_types = db.query(RoomType.amenities).all()
for rt in room_types:
if rt.amenities:
@@ -413,8 +74,6 @@ async def get_amenities_list(db: Session) -> List[str]:
all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()])
except:
all_amenities.update([s.strip() for s in rt.amenities.split(',') if s.strip()])
# Get from rooms
rooms = db.query(Room.amenities).all()
for r in rooms:
if r.amenities:
@@ -430,7 +89,4 @@ async def get_amenities_list(db: Session) -> List[str]:
all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()])
except:
all_amenities.update([s.strip() for s in r.amenities.split(',') if s.strip()])
# Return unique, sorted values
return sorted(list(all_amenities))
return sorted(list(all_amenities))

View File

@@ -1,6 +1,3 @@
"""
Stripe payment service for processing card payments
"""
import logging
import stripe
from typing import Optional, Dict, Any
@@ -10,333 +7,152 @@ from ..models.booking import Booking, BookingStatus
from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session
from datetime import datetime
logger = logging.getLogger(__name__)
def get_stripe_secret_key(db: Session) -> Optional[str]:
"""Get Stripe secret key from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_secret_key"
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_secret_key').first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.STRIPE_SECRET_KEY if settings.STRIPE_SECRET_KEY else None
def get_stripe_publishable_key(db: Session) -> Optional[str]:
"""Get Stripe publishable key from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_publishable_key"
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_publishable_key').first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.STRIPE_PUBLISHABLE_KEY if settings.STRIPE_PUBLISHABLE_KEY else None
def get_stripe_webhook_secret(db: Session) -> Optional[str]:
"""Get Stripe webhook secret from database or environment variable"""
try:
setting = db.query(SystemSettings).filter(
SystemSettings.key == "stripe_webhook_secret"
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == 'stripe_webhook_secret').first()
if setting and setting.value:
return setting.value
except Exception:
pass
# Fallback to environment variable
return settings.STRIPE_WEBHOOK_SECRET if settings.STRIPE_WEBHOOK_SECRET else None
class StripeService:
"""Service for handling Stripe payments"""
@staticmethod
def create_payment_intent(
amount: float,
currency: str = "usd",
metadata: Optional[Dict[str, Any]] = None,
customer_id: Optional[str] = None,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Create a Stripe Payment Intent
Args:
amount: Payment amount in smallest currency unit (cents for USD)
currency: Currency code (default: usd)
metadata: Additional metadata to attach to the payment intent
customer_id: Optional Stripe customer ID
db: Optional database session to get keys from database
Returns:
Payment intent object
"""
# Get secret key from database or environment
def create_payment_intent(amount: float, currency: str='usd', metadata: Optional[Dict[str, Any]]=None, customer_id: Optional[str]=None, db: Optional[Session]=None) -> Dict[str, Any]:
secret_key = None
if db:
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise ValueError("Stripe secret key is not configured")
# Set the API key for this request
raise ValueError('Stripe secret key is not configured')
stripe.api_key = secret_key
# Validate amount is reasonable (Stripe max is $999,999.99)
if amount <= 0:
raise ValueError("Amount must be greater than 0")
raise ValueError('Amount must be greater than 0')
if amount > 999999.99:
raise ValueError(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99")
# Convert amount to cents (smallest currency unit)
# Amount should be in dollars, so multiply by 100 to get cents
amount_in_cents = int(round(amount * 100))
# Double-check the cents amount doesn't exceed Stripe's limit
if amount_in_cents > 99999999: # $999,999.99 in cents
if amount_in_cents > 99999999:
raise ValueError(f"Amount ${amount:,.2f} (${amount_in_cents} cents) exceeds Stripe's maximum")
intent_params = {
"amount": amount_in_cents,
"currency": currency,
"automatic_payment_methods": {
"enabled": True,
},
"metadata": metadata or {},
}
intent_params = {'amount': amount_in_cents, 'currency': currency, 'automatic_payment_methods': {'enabled': True}, 'metadata': metadata or {}}
if customer_id:
intent_params["customer"] = customer_id
intent_params['customer'] = customer_id
try:
intent = stripe.PaymentIntent.create(**intent_params)
return {
"client_secret": intent.client_secret,
"id": intent.id,
"status": intent.status,
"amount": intent.amount,
"currency": intent.currency,
}
return {'client_secret': intent.client_secret, 'id': intent.id, 'status': intent.status, 'amount': intent.amount, 'currency': intent.currency}
except stripe.StripeError as e:
raise ValueError(f"Stripe error: {str(e)}")
raise ValueError(f'Stripe error: {str(e)}')
@staticmethod
def retrieve_payment_intent(
payment_intent_id: str,
db: Optional[Session] = None
) -> Dict[str, Any]:
"""
Retrieve a payment intent by ID
Args:
payment_intent_id: Stripe payment intent ID
db: Optional database session to get keys from database
Returns:
Payment intent object
"""
# Get secret key from database or environment
def retrieve_payment_intent(payment_intent_id: str, db: Optional[Session]=None) -> Dict[str, Any]:
secret_key = None
if db:
secret_key = get_stripe_secret_key(db)
if not secret_key:
secret_key = settings.STRIPE_SECRET_KEY
if not secret_key:
raise ValueError("Stripe secret key is not configured")
# Set the API key for this request
raise ValueError('Stripe secret key is not configured')
stripe.api_key = secret_key
try:
intent = stripe.PaymentIntent.retrieve(payment_intent_id)
# Safely access charges - they may not exist on all payment intents
charges = []
if hasattr(intent, 'charges') and intent.charges:
charges_data = getattr(intent.charges, 'data', [])
charges = [
{
"id": charge.id,
"paid": charge.paid,
"status": charge.status,
}
for charge in charges_data
]
return {
"id": intent.id,
"status": intent.status,
"amount": intent.amount / 100, # Convert from cents
"currency": intent.currency,
"metadata": intent.metadata,
"charges": charges,
}
charges = [{'id': charge.id, 'paid': charge.paid, 'status': charge.status} for charge in charges_data]
return {'id': intent.id, 'status': intent.status, 'amount': intent.amount / 100, 'currency': intent.currency, 'metadata': intent.metadata, 'charges': charges}
except stripe.StripeError as e:
raise ValueError(f"Stripe error: {str(e)}")
raise ValueError(f'Stripe error: {str(e)}')
@staticmethod
async def confirm_payment(
payment_intent_id: str,
db: Session,
booking_id: Optional[int] = None
) -> Dict[str, Any]:
"""
Confirm a payment and update database records
Args:
payment_intent_id: Stripe payment intent ID
db: Database session
booking_id: Optional booking ID for metadata lookup
Returns:
Payment record dictionary
"""
async def confirm_payment(payment_intent_id: str, db: Session, booking_id: Optional[int]=None) -> Dict[str, Any]:
try:
intent_data = StripeService.retrieve_payment_intent(payment_intent_id, db)
# Find or get booking_id from metadata
if not booking_id and intent_data.get("metadata"):
booking_id = intent_data["metadata"].get("booking_id")
if not booking_id and intent_data.get('metadata'):
booking_id = intent_data['metadata'].get('booking_id')
if booking_id:
booking_id = int(booking_id)
if not booking_id:
raise ValueError("Booking ID is required")
raise ValueError('Booking ID is required')
booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking:
raise ValueError("Booking not found")
# Check payment intent status
payment_status = intent_data.get("status")
print(f"Payment intent status: {payment_status}")
# Accept succeeded or processing status (processing means payment is being processed)
if payment_status not in ["succeeded", "processing"]:
raise ValueError(f"Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.")
# Find existing payment or create new one
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.transaction_id == payment_intent_id,
Payment.payment_method == PaymentMethod.stripe
).first()
# If not found, try to find pending deposit payment (for cash bookings with deposit)
# This allows updating the payment_method from the default to stripe
raise ValueError('Booking not found')
payment_status = intent_data.get('status')
print(f'Payment intent status: {payment_status}')
if payment_status not in ['succeeded', 'processing']:
raise ValueError(f'Payment intent not in a valid state. Status: {payment_status}. Payment may still be processing or may have failed.')
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == payment_intent_id, Payment.payment_method == PaymentMethod.stripe).first()
if not payment:
payment = db.query(Payment).filter(
Payment.booking_id == booking_id,
Payment.payment_type == PaymentType.deposit,
Payment.payment_status == PaymentStatus.pending
).order_by(Payment.created_at.desc()).first()
amount = intent_data["amount"]
payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.payment_type == PaymentType.deposit, Payment.payment_status == PaymentStatus.pending).order_by(Payment.created_at.desc()).first()
amount = intent_data['amount']
if payment:
# Update existing payment
# Only mark as completed if payment intent succeeded
if payment_status == "succeeded":
if payment_status == 'succeeded':
payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow()
# If processing, keep as pending (will be updated by webhook)
payment.amount = amount
payment.payment_method = PaymentMethod.stripe # Update payment method to Stripe
payment.payment_method = PaymentMethod.stripe
else:
# Create new payment record
payment_type = PaymentType.full
if booking.requires_deposit and not booking.deposit_paid:
if booking.requires_deposit and (not booking.deposit_paid):
payment_type = PaymentType.deposit
# Only mark as completed if payment intent succeeded
payment_status_enum = PaymentStatus.completed if payment_status == "succeeded" else PaymentStatus.pending
payment_date = datetime.utcnow() if payment_status == "succeeded" else None
payment = Payment(
booking_id=booking_id,
amount=amount,
payment_method=PaymentMethod.stripe,
payment_type=payment_type,
payment_status=payment_status_enum,
transaction_id=payment_intent_id,
payment_date=payment_date,
notes=f"Stripe payment - Intent: {payment_intent_id} (Status: {payment_status})",
)
payment_status_enum = PaymentStatus.completed if payment_status == 'succeeded' else PaymentStatus.pending
payment_date = datetime.utcnow() if payment_status == 'succeeded' else None
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod.stripe, payment_type=payment_type, payment_status=payment_status_enum, transaction_id=payment_intent_id, payment_date=payment_date, notes=f'Stripe payment - Intent: {payment_intent_id} (Status: {payment_status})')
db.add(payment)
# Commit payment first to ensure it's saved
db.commit()
db.refresh(payment)
# Update booking status only if payment is completed
if payment.payment_status == PaymentStatus.completed:
# Refresh booking to get updated payments relationship
db.refresh(booking)
# Calculate total paid from all completed payments (now includes current payment)
# This needs to be calculated before the if/elif blocks
total_paid = sum(
float(p.amount) for p in booking.payments
if p.payment_status == PaymentStatus.completed
)
# Update invoice status based on payment
total_paid = sum((float(p.amount) for p in booking.payments if p.payment_status == PaymentStatus.completed))
from ..models.invoice import Invoice, InvoiceStatus
from ..services.invoice_service import InvoiceService
# Find invoices for this booking and update their status
invoices = db.query(Invoice).filter(Invoice.booking_id == booking_id).all()
for invoice in invoices:
# Update invoice amount_paid and balance_due
invoice.amount_paid = total_paid
invoice.balance_due = float(invoice.total_amount) - total_paid
# Update invoice status
if invoice.balance_due <= 0:
invoice.status = InvoiceStatus.paid
invoice.paid_date = datetime.utcnow()
elif invoice.amount_paid > 0:
invoice.status = InvoiceStatus.sent
booking_was_confirmed = False
should_send_email = False
if payment.payment_type == PaymentType.deposit:
# Mark deposit as paid and confirm booking
booking.deposit_paid = True
# Restore cancelled bookings or confirm pending bookings
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but deposit was just paid
should_send_email = True
elif payment.payment_type == PaymentType.full:
# Confirm booking if:
# 1. Total paid (all payments) covers the booking price, OR
# 2. This single payment covers the entire booking amount
# Also restore cancelled bookings when payment succeeds
if total_paid >= float(booking.total_price) or float(payment.amount) >= float(booking.total_price):
if booking.status in [BookingStatus.pending, BookingStatus.cancelled]:
booking.status = BookingStatus.confirmed
booking_was_confirmed = True
should_send_email = True
elif booking.status == BookingStatus.confirmed:
# Booking already confirmed, but full payment was just completed
should_send_email = True
# Send booking confirmation email if booking was just confirmed or payment completed
if should_send_email:
try:
from ..utils.mailer import send_email
@@ -346,208 +162,109 @@ class StripeService:
from sqlalchemy.orm import selectinload
import os
from ..config.settings import settings
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
# Get platform currency for email
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == "platform_currency").first()
currency = currency_setting.value if currency_setting and currency_setting.value else "USD"
# Get currency symbol
currency_symbols = {
"USD": "$", "EUR": "", "GBP": "£", "JPY": "¥", "CNY": "¥",
"KRW": "", "SGD": "S$", "THB": "฿", "AUD": "A$", "CAD": "C$",
"VND": "", "INR": "", "CHF": "CHF", "NZD": "NZ$"
}
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
currency_setting = db.query(SystemSettings).filter(SystemSettings.key == 'platform_currency').first()
currency = currency_setting.value if currency_setting and currency_setting.value else 'USD'
currency_symbols = {'USD': '$', 'EUR': '', 'GBP': '£', 'JPY': '¥', 'CNY': '¥', 'KRW': '', 'SGD': 'S$', 'THB': '฿', 'AUD': 'A$', 'CAD': 'C$', 'VND': '', 'INR': '', 'CHF': 'CHF', 'NZD': 'NZ$'}
currency_symbol = currency_symbols.get(currency, currency)
# Load booking with room details for email
booking_with_room = db.query(Booking).options(
selectinload(Booking.room).selectinload(Room.room_type)
).filter(Booking.id == booking_id).first()
booking_with_room = db.query(Booking).options(selectinload(Booking.room).selectinload(Room.room_type)).filter(Booking.id == booking_id).first()
room = booking_with_room.room if booking_with_room else None
room_type_name = room.room_type.name if room and room.room_type else "Room"
# Calculate amount paid and remaining due
room_type_name = room.room_type.name if room and room.room_type else 'Room'
amount_paid = total_paid
payment_type_str = payment.payment_type.value if payment.payment_type else None
email_html = booking_confirmation_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
room_number=room.room_number if room else "N/A",
room_type=room_type_name,
check_in=booking.check_in_date.strftime("%B %d, %Y") if booking.check_in_date else "N/A",
check_out=booking.check_out_date.strftime("%B %d, %Y") if booking.check_out_date else "N/A",
num_guests=booking.num_guests,
total_price=float(booking.total_price),
requires_deposit=False, # Payment completed, no deposit message needed
deposit_amount=None,
amount_paid=amount_paid,
payment_type=payment_type_str,
client_url=client_url,
currency_symbol=currency_symbol
)
email_html = booking_confirmation_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', room_number=room.room_number if room else 'N/A', room_type=room_type_name, check_in=booking.check_in_date.strftime('%B %d, %Y') if booking.check_in_date else 'N/A', check_out=booking.check_out_date.strftime('%B %d, %Y') if booking.check_out_date else 'N/A', num_guests=booking.num_guests, total_price=float(booking.total_price), requires_deposit=False, deposit_amount=None, amount_paid=amount_paid, payment_type=payment_type_str, client_url=client_url, currency_symbol=currency_symbol)
if booking.user:
await send_email(
to=booking.user.email,
subject=f"Booking Confirmed - {booking.booking_number}",
html=email_html
)
logger.info(f"Booking confirmation email sent to {booking.user.email}")
await send_email(to=booking.user.email, subject=f'Booking Confirmed - {booking.booking_number}', html=email_html)
logger.info(f'Booking confirmation email sent to {booking.user.email}')
except Exception as email_error:
logger.error(f"Failed to send booking confirmation email: {str(email_error)}")
# Send invoice email if payment is completed and invoice is now paid
logger.error(f'Failed to send booking confirmation email: {str(email_error)}')
from ..utils.mailer import send_email
from ..services.invoice_service import InvoiceService
from ..routes.booking_routes import _generate_invoice_email_html
# Load user for email
from ..models.user import User
user = db.query(User).filter(User.id == booking.user_id).first()
for invoice in invoices:
if invoice.status == InvoiceStatus.paid and invoice.balance_due <= 0:
try:
invoice_dict = InvoiceService.invoice_to_dict(invoice)
invoice_html = _generate_invoice_email_html(invoice_dict, is_proforma=invoice.is_proforma)
invoice_type = "Proforma Invoice" if invoice.is_proforma else "Invoice"
invoice_type = 'Proforma Invoice' if invoice.is_proforma else 'Invoice'
if user:
await send_email(
to=user.email,
subject=f"{invoice_type} {invoice.invoice_number} - Payment Confirmed",
html=invoice_html
)
logger.info(f"{invoice_type} {invoice.invoice_number} sent to {user.email}")
await send_email(to=user.email, subject=f'{invoice_type} {invoice.invoice_number} - Payment Confirmed', html=invoice_html)
logger.info(f'{invoice_type} {invoice.invoice_number} sent to {user.email}')
except Exception as email_error:
logger.error(f"Failed to send invoice email: {str(email_error)}")
# Commit booking and invoice status updates
logger.error(f'Failed to send invoice email: {str(email_error)}')
db.commit()
db.refresh(booking)
# Safely get enum values
def get_enum_value(enum_obj):
"""Safely extract value from enum or return as-is"""
if enum_obj is None:
return None
if isinstance(enum_obj, (PaymentMethod, PaymentType, PaymentStatus)):
return enum_obj.value
return enum_obj
try:
return {
"id": payment.id,
"booking_id": payment.booking_id,
"amount": float(payment.amount) if payment.amount else 0.0,
"payment_method": get_enum_value(payment.payment_method),
"payment_type": get_enum_value(payment.payment_type),
"payment_status": get_enum_value(payment.payment_status),
"transaction_id": payment.transaction_id,
"payment_date": payment.payment_date.isoformat() if payment.payment_date else None,
}
return {'id': payment.id, 'booking_id': payment.booking_id, 'amount': float(payment.amount) if payment.amount else 0.0, 'payment_method': get_enum_value(payment.payment_method), 'payment_type': get_enum_value(payment.payment_type), 'payment_status': get_enum_value(payment.payment_status), 'transaction_id': payment.transaction_id, 'payment_date': payment.payment_date.isoformat() if payment.payment_date else None}
except AttributeError as ae:
print(f"AttributeError accessing payment fields: {ae}")
print(f"Payment object: {payment}")
print(f"Payment payment_method: {payment.payment_method if hasattr(payment, 'payment_method') else 'missing'}")
print(f"Payment payment_type: {payment.payment_type if hasattr(payment, 'payment_type') else 'missing'}")
print(f"Payment payment_status: {payment.payment_status if hasattr(payment, 'payment_status') else 'missing'}")
print(f'AttributeError accessing payment fields: {ae}')
print(f'Payment object: {payment}')
print(f'Payment payment_method: {(payment.payment_method if hasattr(payment, 'payment_method') else 'missing')}')
print(f'Payment payment_type: {(payment.payment_type if hasattr(payment, 'payment_type') else 'missing')}')
print(f'Payment payment_status: {(payment.payment_status if hasattr(payment, 'payment_status') else 'missing')}')
raise
except ValueError as e:
# Re-raise ValueError as-is (these are expected errors)
db.rollback()
raise
except Exception as e:
import traceback
error_details = traceback.format_exc()
error_msg = str(e) if str(e) else f"{type(e).__name__}: {repr(e)}"
print(f"Error in confirm_payment: {error_msg}")
print(f"Traceback: {error_details}")
error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}'
print(f'Error in confirm_payment: {error_msg}')
print(f'Traceback: {error_details}')
db.rollback()
raise ValueError(f"Error confirming payment: {error_msg}")
raise ValueError(f'Error confirming payment: {error_msg}')
@staticmethod
async def handle_webhook(
payload: bytes,
signature: str,
db: Session
) -> Dict[str, Any]:
"""
Handle Stripe webhook events
Args:
payload: Raw webhook payload
signature: Stripe signature header
db: Database session
Returns:
Webhook event data
"""
async def handle_webhook(payload: bytes, signature: str, db: Session) -> Dict[str, Any]:
webhook_secret = get_stripe_webhook_secret(db)
if not webhook_secret:
webhook_secret = settings.STRIPE_WEBHOOK_SECRET
if not webhook_secret:
raise ValueError("Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.")
raise ValueError('Stripe webhook secret is not configured. Please configure it in Admin Panel (Settings > Stripe Settings) or set STRIPE_WEBHOOK_SECRET environment variable.')
try:
event = stripe.Webhook.construct_event(
payload, signature, webhook_secret
)
event = stripe.Webhook.construct_event(payload, signature, webhook_secret)
except ValueError as e:
raise ValueError(f"Invalid payload: {str(e)}")
raise ValueError(f'Invalid payload: {str(e)}')
except stripe.SignatureVerificationError as e:
raise ValueError(f"Invalid signature: {str(e)}")
# Handle the event
if event["type"] == "payment_intent.succeeded":
payment_intent = event["data"]["object"]
payment_intent_id = payment_intent["id"]
metadata = payment_intent.get("metadata", {})
booking_id = metadata.get("booking_id")
raise ValueError(f'Invalid signature: {str(e)}')
if event['type'] == 'payment_intent.succeeded':
payment_intent = event['data']['object']
payment_intent_id = payment_intent['id']
metadata = payment_intent.get('metadata', {})
booking_id = metadata.get('booking_id')
if booking_id:
try:
await StripeService.confirm_payment(
payment_intent_id=payment_intent_id,
db=db,
booking_id=int(booking_id)
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Error processing webhook for booking {booking_id}: {str(e)}")
elif event["type"] == "payment_intent.payment_failed":
payment_intent = event["data"]["object"]
payment_intent_id = payment_intent["id"]
metadata = payment_intent.get("metadata", {})
booking_id = metadata.get("booking_id")
try:
await StripeService.confirm_payment(payment_intent_id=payment_intent_id, db=db, booking_id=int(booking_id))
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error processing webhook for booking {booking_id}: {str(e)}')
elif event['type'] == 'payment_intent.payment_failed':
payment_intent = event['data']['object']
payment_intent_id = payment_intent['id']
metadata = payment_intent.get('metadata', {})
booking_id = metadata.get('booking_id')
if booking_id:
# Update payment status to failed
payment = db.query(Payment).filter(
Payment.transaction_id == payment_intent_id,
Payment.booking_id == int(booking_id)
).first()
payment = db.query(Payment).filter(Payment.transaction_id == payment_intent_id, Payment.booking_id == int(booking_id)).first()
if payment:
payment.payment_status = PaymentStatus.failed
db.commit()
# Auto-cancel booking when payment fails
booking = db.query(Booking).filter(Booking.id == int(booking_id)).first()
if booking and booking.status != BookingStatus.cancelled:
booking.status = BookingStatus.cancelled
db.commit()
db.refresh(booking)
# Send cancellation email (non-blocking)
try:
if booking.user:
from ..utils.mailer import send_email
@@ -555,30 +272,12 @@ class StripeService:
from ..models.system_settings import SystemSettings
from ..config.settings import settings
import os
# Get client URL from settings
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == "client_url").first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else (settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173"))
email_html = booking_status_changed_email_template(
booking_number=booking.booking_number,
guest_name=booking.user.full_name if booking.user else "Guest",
status="cancelled",
client_url=client_url
)
await send_email(
to=booking.user.email,
subject=f"Booking Cancelled - {booking.booking_number}",
html=email_html
)
client_url_setting = db.query(SystemSettings).filter(SystemSettings.key == 'client_url').first()
client_url = client_url_setting.value if client_url_setting and client_url_setting.value else settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
email_html = booking_status_changed_email_template(booking_number=booking.booking_number, guest_name=booking.user.full_name if booking.user else 'Guest', status='cancelled', client_url=client_url)
await send_email(to=booking.user.email, subject=f'Booking Cancelled - {booking.booking_number}', html=email_html)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.error(f"Failed to send cancellation email: {e}")
return {
"status": "success",
"event_type": event["type"],
"event_id": event["id"],
}
logger.error(f'Failed to send cancellation email: {e}')
return {'status': 'success', 'event_type': event['type'], 'event_id': event['id']}

View File

@@ -1,14 +1,9 @@
"""
Email templates for various notifications
"""
from datetime import datetime
from typing import Optional
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
def _get_company_settings():
"""Get company settings from database"""
try:
db = SessionLocal()
try:
@@ -44,9 +39,7 @@ def _get_company_settings():
"company_address": None,
}
def get_base_template(content: str, title: str = "Hotel Booking", client_url: str = "http://localhost:5173") -> str:
"""Luxury HTML email template with premium company branding"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
company_tagline = company_settings.get("company_tagline") or "Excellence Redefined"
@@ -55,12 +48,12 @@ def get_base_template(content: str, title: str = "Hotel Booking", client_url: st
company_email = company_settings.get("company_email")
company_address = company_settings.get("company_address")
# Build logo HTML if logo exists
logo_html = ""
if company_logo_url:
# Convert relative URL to absolute if needed
if not company_logo_url.startswith('http'):
# Try to construct full URL
server_url = client_url.replace('://localhost:5173', '').replace('://localhost:3000', '')
if not server_url.startswith('http'):
server_url = f"http://{server_url}" if ':' not in server_url.split('//')[-1] else server_url
@@ -68,187 +61,45 @@ def get_base_template(content: str, title: str = "Hotel Booking", client_url: st
else:
full_logo_url = company_logo_url
logo_html = f'''
<div style="text-align: center; padding: 15px 0;">
<img src="{full_logo_url}" alt="{company_name}" style="max-height: 80px; max-width: 280px; margin: 0 auto; display: block; filter: drop-shadow(0 4px 6px rgba(0,0,0,0.1));" />
{f'<p style="color: #D4AF37; margin: 8px 0 0 0; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; font-weight: 300;">{company_tagline}</p>' if company_tagline else ''}
</div>
'''
logo_html = f
else:
logo_html = f'''
<div style="text-align: center; padding: 15px 0;">
<h1 style="color: #ffffff; margin: 0; font-size: 32px; font-weight: 600; letter-spacing: 1px;">{company_name}</h1>
{f'<p style="color: #D4AF37; margin: 8px 0 0 0; font-size: 11px; letter-spacing: 2px; text-transform: uppercase; font-weight: 300;">{company_tagline}</p>' if company_tagline else ''}
</div>
'''
logo_html = f
# Build footer contact info
footer_contact = ""
if company_phone or company_email or company_address:
footer_contact = '''
<div style="margin-top: 25px; padding-top: 25px; border-top: 1px solid rgba(212, 175, 55, 0.2);">
<table role="presentation" style="width: 100%; max-width: 500px; margin: 0 auto;">
'''
footer_contact =
if company_phone:
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px;">📞 {company_phone}</p>
</td>
</tr>
'''
footer_contact += f
if company_email:
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px;">✉️ <a href="mailto:{company_email}" style="color: #D4AF37; text-decoration: none;">{company_email}</a></p>
</td>
</tr>
'''
footer_contact += f
if company_address:
# Replace newlines with <br> for address
formatted_address = company_address.replace('\n', '<br>')
footer_contact += f'''
<tr>
<td style="padding: 5px 0; text-align: center;">
<p style="margin: 0; color: #999999; font-size: 13px; line-height: 1.6;">📍 {formatted_address}</p>
</td>
</tr>
'''
footer_contact += '''
</table>
</div>
'''
return f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&family=Inter:wght@300;400;500;600&display=swap');
</style>
</head>
<body style="margin: 0; padding: 0; font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); background-color: #1a1a1a;">
<table role="presentation" style="width: 100%; border-collapse: collapse; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);">
<tr>
<td style="padding: 40px 20px; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%); background-color: #1a1a1a; border-bottom: 2px solid rgba(212, 175, 55, 0.3);">
{logo_html}
</td>
</tr>
<tr>
<td style="padding: 0;">
<table role="presentation" style="width: 100%; max-width: 650px; margin: 0 auto; background-color: #ffffff; box-shadow: 0 10px 40px rgba(0,0,0,0.3);">
<tr>
<td style="padding: 50px 40px; background: linear-gradient(to bottom, #ffffff 0%, #fafafa 100%);">
{content}
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 40px 20px; text-align: center; background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); background-color: #1a1a1a; color: #999999; font-size: 12px;">
<p style="margin: 0 0 15px 0; color: #666666; font-size: 12px;">This is an automated email. Please do not reply.</p>
<p style="margin: 0 0 20px 0; color: #D4AF37; font-size: 13px; font-weight: 500;"{datetime.now().year} {company_name}. All rights reserved.</p>
{footer_contact}
</td>
</tr>
</table>
</body>
</html>
"""
formatted_address = company_address.replace('\n', '<br>')
footer_contact += f
footer_contact +=
return f
def welcome_email_template(name: str, email: str, client_url: str) -> str:
"""Welcome email template for new registrations"""
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
content = f"""
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">✨</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center; letter-spacing: -0.5px;">Welcome, {name}!</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 25px;">We are delighted to welcome you to <strong style="color: #1a1a1a;">{company_name}</strong>.</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 30px;">Your account has been successfully created with email: <strong style="color: #D4AF37;">{email}</strong></p>
<div style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-left: 4px solid #D4AF37; padding: 25px; border-radius: 10px; margin: 30px 0; box-shadow: 0 4px 15px rgba(212, 175, 55, 0.1);">
<p style="margin: 0 0 15px 0; color: #1a1a1a; font-weight: 600; font-size: 16px;">🎁 What you can do:</p>
<ul style="margin: 0; padding-left: 20px; color: #555555; line-height: 2;">
<li style="margin-bottom: 8px;">Search and book our exquisite hotel rooms</li>
<li style="margin-bottom: 8px;">Manage your bookings with ease</li>
<li style="margin-bottom: 8px;">Update your personal information anytime</li>
<li>Enjoy exclusive member benefits and offers</li>
</ul>
</div>
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/login" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4); transition: all 0.3s ease;">
Access Your Account
</a>
</div>
"""
content = f
return get_base_template(content, f"Welcome to {company_name}", client_url)
def password_reset_email_template(reset_url: str) -> str:
"""Password reset email template"""
content = f"""
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">🔐</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; text-align: center;">Password Reset Request</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 20px;">A password reset request has been received for your account.</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 30px;">Click the button below to reset your password. <strong style="color: #D4AF37;">This link will expire in 1 hour.</strong></p>
<div style="text-align: center; margin: 35px 0;">
<a href="{reset_url}" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);">
Reset Password
</a>
</div>
<div style="background-color: #fff9e6; border: 1px solid #ffe0b2; padding: 15px; border-radius: 8px; margin-top: 30px;">
<p style="margin: 0; color: #856404; font-size: 13px; text-align: center; line-height: 1.6;">⚠️ If you did not request this password reset, please ignore this email and your password will remain unchanged.</p>
</div>
"""
content = f
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Password Reset - {company_name}", reset_url.split('/reset-password')[0] if '/reset-password' in reset_url else "http://localhost:5173")
def password_changed_email_template(email: str) -> str:
"""Password changed confirmation email template"""
content = f"""
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">✓</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 15px; font-family: 'Playfair Display', serif; font-size: 28px; font-weight: 700; text-align: center;">Password Changed Successfully</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 25px;">The password for account <strong style="color: #1a1a1a;">{email}</strong> has been changed successfully.</p>
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 20px; border-radius: 10px; margin: 30px 0; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.1);">
<p style="margin: 0; color: #065F46; font-size: 14px; line-height: 1.6; text-align: center;">🔒 If you did not make this change, please contact our support team immediately to secure your account.</p>
</div>
"""
content = f
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Password Changed - {company_name}", "http://localhost:5173")
def booking_confirmation_email_template(
booking_number: str,
guest_name: str,
@@ -268,125 +119,27 @@ def booking_confirmation_email_template(
client_url: str = "http://localhost:5173",
currency_symbol: str = "$"
) -> str:
"""Booking confirmation email template"""
deposit_info = ""
if requires_deposit and deposit_amount and amount_paid is None:
deposit_info = f"""
<div style="background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border-left: 4px solid #F59E0B; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(245, 158, 11, 0.2);">
<p style="margin: 0 0 10px 0; font-weight: 700; color: #92400E; font-size: 16px;">⚠️ Deposit Required</p>
<p style="margin: 0 0 8px 0; color: #78350F; font-size: 15px; line-height: 1.6;">Please pay a deposit of <strong style="color: #92400E; font-size: 18px;">{currency_symbol}{deposit_amount:.2f}</strong> to confirm your booking.</p>
<p style="margin: 0; color: #78350F; font-size: 14px;">Your booking will be confirmed once the deposit is received.</p>
</div>
"""
deposit_info = f
# Payment breakdown section (shown when payment is completed)
payment_breakdown = ""
if amount_paid is not None:
remaining_due = total_price - amount_paid
payment_type_label = "Deposit Payment" if payment_type == "deposit" else "Full Payment"
payment_breakdown = f"""
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 25px; margin: 30px 0; border-radius: 10px; box-shadow: 0 4px 15px rgba(16, 185, 129, 0.2);">
<h3 style="margin-top: 0; margin-bottom: 20px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 20px; font-weight: 600;">Payment Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Payment Type:</td>
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
</tr>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Amount Paid:</td>
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 18px;">{currency_symbol}{amount_paid:.2f}</td>
</tr>
<tr>
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
<td style="padding: 10px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
</tr>
"""
payment_breakdown = f
if remaining_due > 0:
payment_breakdown += f"""
<tr style="background-color: rgba(245, 158, 11, 0.1); border-top: 2px solid #F59E0B;">
<td style="padding: 10px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
<td style="padding: 10px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
</tr>
"""
payment_breakdown += f
else:
payment_breakdown += f"""
<tr style="background-color: rgba(16, 185, 129, 0.1); border-top: 2px solid #10B981;">
<td style="padding: 10px 0; color: #065F46; font-size: 14px; font-weight: 600;">Status:</td>
<td style="padding: 10px 0; font-weight: 700; color: #059669; font-size: 16px;">✅ Fully Paid</td>
</tr>
"""
payment_breakdown += """
</table>
</div>
"""
payment_breakdown += f
payment_breakdown +=
content = f"""
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">🏨</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Booking Confirmation</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">Thank you for choosing us! We have received your reservation request and are delighted to welcome you.</p>
<div style="background: linear-gradient(135deg, #fafafa 0%, #f5f5f5 100%); padding: 30px; border-radius: 12px; margin: 30px 0; border: 1px solid #e5e5e5; box-shadow: 0 4px 20px rgba(0,0,0,0.05);">
<h3 style="margin-top: 0; margin-bottom: 25px; color: #1a1a1a; font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; text-align: center; border-bottom: 2px solid #D4AF37; padding-bottom: 15px;">Booking Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr style="background-color: rgba(212, 175, 55, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Room:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{room_type} - Room {room_number}</td>
</tr>
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-in:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{check_in}</td>
</tr>
<tr style="background-color: rgba(212, 175, 55, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Check-out:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{check_out}</td>
</tr>
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Guests:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{num_guests} guest{'s' if num_guests > 1 else ''}</td>
</tr>
{f'''
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Subtotal:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{original_price:.2f}</td>
</tr>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 12px 0; color: #065F46; font-size: 14px; font-weight: 500;">Promotion Discount{f' ({promotion_code})' if promotion_code else ''}:</td>
<td style="padding: 12px 0; font-weight: 700; color: #059669; font-size: 15px;">-{currency_symbol}{discount_amount:.2f}</td>
</tr>
''' if original_price and discount_amount and discount_amount > 0 else ''}
<tr style="background: linear-gradient(135deg, #fef9e7 0%, #fdf6e3 100%); border-top: 2px solid #D4AF37; border-bottom: 2px solid #D4AF37;">
<td style="padding: 15px 0; color: #1a1a1a; font-size: 16px; font-weight: 600;">Total Price:</td>
<td style="padding: 15px 0; font-weight: 700; color: #D4AF37; font-size: 22px; font-family: 'Playfair Display', serif;">{currency_symbol}{total_price:.2f}</td>
</tr>
</table>
</div>
{payment_breakdown}
{deposit_info}
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, #D4AF37 0%, #C9A227 100%); color: #1a1a1a; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(212, 175, 55, 0.4);">
View Booking Details
</a>
</div>
"""
content = f
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Booking Confirmation - {company_name}", client_url)
def payment_confirmation_email_template(
booking_number: str,
guest_name: str,
@@ -398,139 +151,44 @@ def payment_confirmation_email_template(
client_url: str = "http://localhost:5173",
currency_symbol: str = "$"
) -> str:
"""Payment confirmation email template"""
transaction_info = ""
if transaction_id:
transaction_info = f"""
<tr style="background-color: rgba(16, 185, 129, 0.05);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Transaction ID:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-family: 'Courier New', monospace; font-size: 13px; letter-spacing: 0.5px;">{transaction_id}</td>
</tr>
"""
transaction_info = f
payment_type_info = ""
if payment_type:
payment_type_label = "Deposit Payment (20%)" if payment_type == "deposit" else "Full Payment"
payment_type_info = f"""
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Type:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_type_label}</td>
</tr>
"""
payment_type_info = f
total_price_info = ""
remaining_due_info = ""
if total_price is not None:
total_price_info = f"""
<tr>
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Total Booking Price:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{currency_symbol}{total_price:.2f}</td>
</tr>
"""
total_price_info = f
if payment_type == "deposit" and total_price > amount:
remaining_due = total_price - amount
remaining_due_info = f"""
<tr style="background-color: rgba(245, 158, 11, 0.1);">
<td style="padding: 12px 0; color: #92400E; font-size: 14px; font-weight: 600;">Remaining Due:</td>
<td style="padding: 12px 0; font-weight: 700; color: #B45309; font-size: 18px;">{currency_symbol}{remaining_due:.2f}</td>
</tr>
"""
remaining_due_info = f
content = f"""
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, #10B981 0%, #059669 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">💳</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Payment Received</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">We have successfully received your payment for booking <strong style="color: #10B981;">{booking_number}</strong>.</p>
<div style="background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); border-left: 4px solid #10B981; padding: 30px; border-radius: 12px; margin: 30px 0; box-shadow: 0 4px 20px rgba(16, 185, 129, 0.15);">
<h3 style="margin-top: 0; margin-bottom: 25px; color: #065F46; font-family: 'Playfair Display', serif; font-size: 24px; font-weight: 600; text-align: center; border-bottom: 2px solid #10B981; padding-bottom: 15px;">Payment Details</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr style="background-color: rgba(16, 185, 129, 0.1);">
<td style="padding: 12px 0; color: #888888; font-size: 14px; font-weight: 500;">Payment Method:</td>
<td style="padding: 12px 0; color: #1a1a1a; font-size: 15px; font-weight: 600;">{payment_method}</td>
</tr>
{transaction_info}
{payment_type_info}
{total_price_info}
<tr style="background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%); border-top: 2px solid #10B981; border-bottom: 2px solid #10B981;">
<td style="padding: 15px 0; color: #065F46; font-size: 16px; font-weight: 600;">Amount Paid:</td>
<td style="padding: 15px 0; font-weight: 700; color: #059669; font-size: 24px; font-family: 'Playfair Display', serif;">{currency_symbol}{amount:.2f}</td>
</tr>
{remaining_due_info}
</table>
</div>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin: 35px 0 25px 0;">✨ Your booking is now confirmed. We look forward to hosting you!</p>
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, #10B981 0%, #059669 100%); color: #ffffff; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px rgba(16, 185, 129, 0.4);">
View Booking
</a>
</div>
"""
content = f
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Payment Confirmation - {company_name}", client_url)
def booking_status_changed_email_template(
booking_number: str,
guest_name: str,
status: str,
client_url: str = "http://localhost:5173"
) -> str:
"""Booking status change email template"""
status_colors = {
"confirmed": ("#10B981", "Confirmed", "", "#ecfdf5", "#d1fae5"),
"cancelled": ("#EF4444", "Cancelled", "", "#fef2f2", "#fee2e2"),
"checked_in": ("#3B82F6", "Checked In", "🔑", "#eff6ff", "#dbeafe"),
"checked_out": ("#8B5CF6", "Checked Out", "🏃", "#f5f3ff", "#e9d5ff"),
"confirmed": ("
"cancelled": ("
"checked_in": ("
"checked_out": ("
}
color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("#6B7280", status.title(), "📋", "#f3f4f6", "#e5e7eb"))
color, status_text, icon, bg_start, bg_end = status_colors.get(status.lower(), ("
content = f"""
<div style="text-align: center; margin-bottom: 30px;">
<div style="display: inline-block; background: linear-gradient(135deg, {color} 0%, {color}dd 100%); padding: 3px; border-radius: 50%; margin-bottom: 20px;">
<div style="background-color: #ffffff; border-radius: 50%; padding: 15px;">
<span style="font-size: 32px;">{icon}</span>
</div>
</div>
</div>
<h2 style="color: #1a1a1a; margin-top: 0; margin-bottom: 20px; font-family: 'Playfair Display', serif; font-size: 32px; font-weight: 700; text-align: center;">Booking Status Updated</h2>
<p style="color: #666666; font-size: 16px; line-height: 1.7; text-align: center; margin-bottom: 10px;">Dear <strong style="color: #1a1a1a;">{guest_name}</strong>,</p>
<p style="color: #666666; font-size: 15px; line-height: 1.7; text-align: center; margin-bottom: 35px;">Your booking status has been updated.</p>
<div style="background: linear-gradient(135deg, {bg_start} 0%, {bg_end} 100%); border-left: 4px solid {color}; padding: 30px; border-radius: 12px; margin: 30px 0; box-shadow: 0 4px 20px {color}20;">
<h3 style="margin-top: 0; margin-bottom: 25px; color: {color}; font-family: 'Playfair Display', serif; font-size: 22px; font-weight: 600; text-align: center; border-bottom: 2px solid {color}; padding-bottom: 15px;">Status Information</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 12px 0; color: #888888; width: 45%; font-size: 14px; font-weight: 500;">Booking Number:</td>
<td style="padding: 12px 0; font-weight: 700; color: #1a1a1a; font-size: 15px; letter-spacing: 0.5px;">{booking_number}</td>
</tr>
<tr style="background: linear-gradient(135deg, {bg_end} 0%, {bg_start} 100%); border-top: 2px solid {color}; border-bottom: 2px solid {color};">
<td style="padding: 15px 0; color: {color}; font-size: 16px; font-weight: 600;">New Status:</td>
<td style="padding: 15px 0; font-weight: 700; color: {color}; font-size: 22px; font-family: 'Playfair Display', serif;">{status_text}</td>
</tr>
</table>
</div>
<div style="text-align: center; margin-top: 40px;">
<a href="{client_url}/bookings/{booking_number}" style="background: linear-gradient(135deg, {color} 0%, {color}dd 100%); color: #ffffff; padding: 16px 40px; text-decoration: none; border-radius: 8px; display: inline-block; font-weight: 600; font-size: 15px; letter-spacing: 0.5px; box-shadow: 0 6px 20px {color}40;">
View Booking
</a>
</div>
"""
content = f
company_settings = _get_company_settings()
company_name = company_settings.get("company_name") or "Hotel Booking"
return get_base_template(content, f"Booking {status_text} - {company_name}", client_url)

View File

@@ -6,148 +6,86 @@ import logging
from ..config.settings import settings
from ..config.database import SessionLocal
from ..models.system_settings import SystemSettings
logger = logging.getLogger(__name__)
def _get_smtp_settings_from_db():
"""
Get SMTP settings from system_settings table.
Returns dict with settings or None if not available.
"""
try:
db = SessionLocal()
try:
smtp_settings = {}
setting_keys = [
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_password",
"smtp_from_email",
"smtp_from_name",
"smtp_use_tls",
]
setting_keys = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_from_email', 'smtp_from_name', 'smtp_use_tls']
for key in setting_keys:
setting = db.query(SystemSettings).filter(
SystemSettings.key == key
).first()
setting = db.query(SystemSettings).filter(SystemSettings.key == key).first()
if setting and setting.value:
smtp_settings[key] = setting.value
# Only return if we have at least host, user, and password
if smtp_settings.get("smtp_host") and smtp_settings.get("smtp_user") and smtp_settings.get("smtp_password"):
if smtp_settings.get('smtp_host') and smtp_settings.get('smtp_user') and smtp_settings.get('smtp_password'):
return smtp_settings
return None
finally:
db.close()
except Exception as e:
logger.debug(f"Could not fetch SMTP settings from database: {str(e)}")
logger.debug(f'Could not fetch SMTP settings from database: {str(e)}')
return None
async def send_email(to: str, subject: str, html: str = None, text: str = None):
"""
Send email using SMTP
Uses system_settings first, then falls back to config/settings.py and environment variables
"""
async def send_email(to: str, subject: str, html: str=None, text: str=None):
try:
# Try to get SMTP settings from database first
db_smtp_settings = _get_smtp_settings_from_db()
if db_smtp_settings:
# Use settings from database
mail_host = db_smtp_settings.get("smtp_host")
mail_user = db_smtp_settings.get("smtp_user")
mail_pass = db_smtp_settings.get("smtp_password")
mail_port = int(db_smtp_settings.get("smtp_port", "587"))
mail_use_tls = db_smtp_settings.get("smtp_use_tls", "true").lower() == "true"
from_address = db_smtp_settings.get("smtp_from_email")
from_name = db_smtp_settings.get("smtp_from_name", "Hotel Booking")
logger.info("Using SMTP settings from system_settings database")
mail_host = db_smtp_settings.get('smtp_host')
mail_user = db_smtp_settings.get('smtp_user')
mail_pass = db_smtp_settings.get('smtp_password')
mail_port = int(db_smtp_settings.get('smtp_port', '587'))
mail_use_tls = db_smtp_settings.get('smtp_use_tls', 'true').lower() == 'true'
from_address = db_smtp_settings.get('smtp_from_email')
from_name = db_smtp_settings.get('smtp_from_name', 'Hotel Booking')
logger.info('Using SMTP settings from system_settings database')
else:
# Fallback to config/settings.py and env vars
mail_host = settings.SMTP_HOST or os.getenv("MAIL_HOST")
mail_user = settings.SMTP_USER or os.getenv("MAIL_USER")
mail_pass = settings.SMTP_PASSWORD or os.getenv("MAIL_PASS")
mail_port = settings.SMTP_PORT or int(os.getenv("MAIL_PORT", "587"))
mail_secure = os.getenv("MAIL_SECURE", "false").lower() == "true"
mail_use_tls = mail_secure # For backward compatibility
client_url = settings.CLIENT_URL or os.getenv("CLIENT_URL", "http://localhost:5173")
# Get from address - prefer settings, then env, then generate from client_url
from_address = settings.SMTP_FROM_EMAIL or os.getenv("MAIL_FROM")
mail_host = settings.SMTP_HOST or os.getenv('MAIL_HOST')
mail_user = settings.SMTP_USER or os.getenv('MAIL_USER')
mail_pass = settings.SMTP_PASSWORD or os.getenv('MAIL_PASS')
mail_port = settings.SMTP_PORT or int(os.getenv('MAIL_PORT', '587'))
mail_secure = os.getenv('MAIL_SECURE', 'false').lower() == 'true'
mail_use_tls = mail_secure
client_url = settings.CLIENT_URL or os.getenv('CLIENT_URL', 'http://localhost:5173')
from_address = settings.SMTP_FROM_EMAIL or os.getenv('MAIL_FROM')
if not from_address:
# Generate from client_url if not set
domain = client_url.replace('https://', '').replace('http://', '').split('/')[0]
from_address = f"no-reply@{domain}"
# Use from name if available
from_name = settings.SMTP_FROM_NAME or "Hotel Booking"
logger.info("Using SMTP settings from config/environment variables")
from_header = f"{from_name} <{from_address}>"
from_address = f'no-reply@{domain}'
from_name = settings.SMTP_FROM_NAME or 'Hotel Booking'
logger.info('Using SMTP settings from config/environment variables')
from_header = f'{from_name} <{from_address}>'
if not (mail_host and mail_user and mail_pass):
error_msg = "SMTP mailer not configured. Set SMTP_HOST, SMTP_USER and SMTP_PASSWORD in .env file."
error_msg = 'SMTP mailer not configured. Set SMTP_HOST, SMTP_USER and SMTP_PASSWORD in .env file.'
logger.error(error_msg)
raise ValueError(error_msg)
# Create message
message = MIMEMultipart("alternative")
message["From"] = from_header
message["To"] = to
message["Subject"] = subject
message = MIMEMultipart('alternative')
message['From'] = from_header
message['To'] = to
message['Subject'] = subject
if text:
message.attach(MIMEText(text, "plain"))
message.attach(MIMEText(text, 'plain'))
if html:
message.attach(MIMEText(html, "html"))
# If no content provided, add a default text
if not text and not html:
message.attach(MIMEText("", "plain"))
# Determine TLS/SSL settings
# For port 465: use SSL/TLS (use_tls=True, start_tls=False)
# For port 587: use STARTTLS (use_tls=False, start_tls=True)
# For port 25: plain (usually not used for authenticated sending)
message.attach(MIMEText(html, 'html'))
if not text and (not html):
message.attach(MIMEText('', 'plain'))
if mail_port == 465 or mail_use_tls:
# SSL/TLS connection (port 465)
use_tls = True
start_tls = False
elif mail_port == 587:
# STARTTLS connection (port 587)
use_tls = False
start_tls = True
else:
# Plain connection (port 25 or other)
use_tls = False
start_tls = False
logger.info(f"Attempting to send email to {to} via {mail_host}:{mail_port} (use_tls: {use_tls}, start_tls: {start_tls})")
# Send email using SMTP client
smtp_client = aiosmtplib.SMTP(
hostname=mail_host,
port=mail_port,
use_tls=use_tls,
start_tls=start_tls,
username=mail_user,
password=mail_pass,
)
logger.info(f'Attempting to send email to {to} via {mail_host}:{mail_port} (use_tls: {use_tls}, start_tls: {start_tls})')
smtp_client = aiosmtplib.SMTP(hostname=mail_host, port=mail_port, use_tls=use_tls, start_tls=start_tls, username=mail_user, password=mail_pass)
try:
await smtp_client.connect()
# Authentication happens automatically if username/password are provided in constructor
await smtp_client.send_message(message)
logger.info(f"Email sent successfully to {to}")
logger.info(f'Email sent successfully to {to}')
finally:
await smtp_client.quit()
except Exception as e:
error_msg = f"Failed to send email to {to}: {type(e).__name__}: {str(e)}"
error_msg = f'Failed to send email to {to}: {type(e).__name__}: {str(e)}'
logger.error(error_msg, exc_info=True)
raise
raise

View File

@@ -1,22 +1,11 @@
"""
VNPay integration removed
This file is intentionally left as a stub to indicate the VNPay
payment gateway has been removed from the project.
"""
def create_payment_url(*args, **kwargs):
raise NotImplementedError("VNPay integration has been removed")
raise NotImplementedError('VNPay integration has been removed')
def verify_return(*args, **kwargs):
raise NotImplementedError("VNPay integration has been removed")
raise NotImplementedError('VNPay integration has been removed')
def sort_object(obj):
return {}
def create_signature(*args, **kwargs):
return ""
return ''