This commit is contained in:
Iliyan Angelov
2025-11-28 02:40:05 +02:00
parent 627959f52b
commit 312f85530c
246 changed files with 23535 additions and 3428 deletions

View File

@@ -0,0 +1,29 @@
"""add_account_lockout_fields_to_users
Revision ID: fff4b67466b3
Revises: add_rate_plan_id_001
Create Date: 2025-11-28 02:26:24.431037
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'fff4b67466b3'
down_revision = 'add_rate_plan_id_001'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add account lockout fields to users table
op.add_column('users', sa.Column('failed_login_attempts', sa.Integer(), nullable=False, server_default='0'))
op.add_column('users', sa.Column('locked_until', sa.DateTime(), nullable=True))
def downgrade() -> None:
# Remove account lockout fields from users table
op.drop_column('users', 'locked_until')
op.drop_column('users', 'failed_login_attempts')

View File

@@ -22,6 +22,7 @@ pyotp==2.9.0
qrcode[pil]==7.4.2 qrcode[pil]==7.4.2
httpx==0.25.2 httpx==0.25.2
cryptography>=41.0.7 cryptography>=41.0.7
bleach==6.1.0
# Testing dependencies # Testing dependencies
pytest==7.4.3 pytest==7.4.3

View File

@@ -20,12 +20,16 @@ class Settings(BaseSettings):
JWT_SECRET: str = Field(default='dev-secret-key-change-in-production-12345', description='JWT secret key') 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_ALGORITHM: str = Field(default='HS256', description='JWT algorithm')
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes') JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30, description='JWT access token expiration in minutes')
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7, description='JWT refresh token expiration in days') JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=3, description='JWT refresh token expiration in days (reduced from 7 for better security)')
MAX_LOGIN_ATTEMPTS: int = Field(default=5, description='Maximum failed login attempts before account lockout')
ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(default=30, description='Account lockout duration in minutes after max failed attempts')
ENCRYPTION_KEY: str = Field(default='', description='Base64-encoded encryption key for data encryption at rest') ENCRYPTION_KEY: str = Field(default='', description='Base64-encoded encryption key for data encryption at rest')
CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL') CLIENT_URL: str = Field(default='http://localhost:5173', description='Frontend client URL')
CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins') CORS_ORIGINS: List[str] = Field(default_factory=lambda: ['http://localhost:5173', 'http://localhost:3000', 'http://127.0.0.1:5173'], description='Allowed CORS origins')
RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting') RATE_LIMIT_ENABLED: bool = Field(default=True, description='Enable rate limiting')
RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP') RATE_LIMIT_PER_MINUTE: int = Field(default=60, description='Requests per minute per IP')
CSRF_PROTECTION_ENABLED: bool = Field(default=True, description='Enable CSRF protection')
HSTS_PRELOAD_ENABLED: bool = Field(default=False, description='Enable HSTS preload directive (requires domain submission to hstspreload.org)')
LOG_LEVEL: str = Field(default='INFO', description='Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL') 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_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_MAX_BYTES: int = Field(default=10485760, description='Max log file size (10MB)')
@@ -38,6 +42,7 @@ class Settings(BaseSettings):
SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name') SMTP_FROM_NAME: str = Field(default='Hotel Booking', description='From name')
UPLOAD_DIR: str = Field(default='uploads', description='Upload directory') UPLOAD_DIR: str = Field(default='uploads', description='Upload directory')
MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)') MAX_UPLOAD_SIZE: int = Field(default=5242880, description='Max upload size in bytes (5MB)')
MAX_REQUEST_BODY_SIZE: int = Field(default=10485760, description='Max request body size in bytes (10MB)')
ALLOWED_EXTENSIONS: List[str] = Field(default_factory=lambda: ['jpg', 'jpeg', 'png', 'gif', 'webp'], description='Allowed file extensions') 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_ENABLED: bool = Field(default=False, description='Enable Redis caching')
REDIS_HOST: str = Field(default='localhost', description='Redis host') REDIS_HOST: str = Field(default='localhost', description='Redis host')
@@ -76,4 +81,49 @@ class Settings(BaseSettings):
if self.REDIS_PASSWORD: if self.REDIS_PASSWORD:
return f'redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}' 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}' return f'redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}'
IP_WHITELIST_ENABLED: bool = Field(default=False, description='Enable IP whitelisting for admin endpoints')
ADMIN_IP_WHITELIST: List[str] = Field(default_factory=list, description='List of allowed IP addresses/CIDR ranges for admin endpoints')
def validate_encryption_key(self) -> None:
"""
Validate encryption key is properly configured.
Raises ValueError if key is missing or invalid in production.
"""
if not self.ENCRYPTION_KEY:
if self.is_production:
raise ValueError(
'CRITICAL: ENCRYPTION_KEY is not configured in production. '
'Please set ENCRYPTION_KEY environment variable to a base64-encoded 32-byte key.'
)
else:
# In development, warn but don't fail
import logging
logger = logging.getLogger(__name__)
logger.warning(
'ENCRYPTION_KEY is not configured. Encryption operations may fail. '
'Please set ENCRYPTION_KEY environment variable.'
)
return
# Validate base64 encoding and key length (32 bytes = 44 base64 chars)
try:
import base64
decoded = base64.b64decode(self.ENCRYPTION_KEY)
if len(decoded) != 32:
raise ValueError(
f'ENCRYPTION_KEY must be a base64-encoded 32-byte key. '
f'Received {len(decoded)} bytes after decoding.'
)
except Exception as e:
if self.is_production:
raise ValueError(
f'Invalid ENCRYPTION_KEY format: {str(e)}. '
'Must be a valid base64-encoded 32-byte key.'
)
else:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Invalid ENCRYPTION_KEY format: {str(e)}')
settings = Settings() settings = Settings()

View File

@@ -14,6 +14,7 @@ import sys
import secrets import secrets
import os import os
import re import re
import logging
from .config.settings import settings from .config.settings import settings
from .config.logging_config import setup_logging, get_logger from .config.logging_config import setup_logging, get_logger
from .config.database import engine, Base, get_db from .config.database import engine, Base, get_db
@@ -26,6 +27,9 @@ from .middleware.request_id import RequestIDMiddleware
from .middleware.security import SecurityHeadersMiddleware from .middleware.security import SecurityHeadersMiddleware
from .middleware.timeout import TimeoutMiddleware from .middleware.timeout import TimeoutMiddleware
from .middleware.cookie_consent import CookieConsentMiddleware from .middleware.cookie_consent import CookieConsentMiddleware
from .middleware.csrf import CSRFProtectionMiddleware
from .middleware.request_size_limit import RequestSizeLimitMiddleware
from .middleware.admin_ip_whitelist import AdminIPWhitelistMiddleware
if settings.is_development: if settings.is_development:
logger.info('Creating database tables (development mode)') logger.info('Creating database tables (development mode)')
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
@@ -48,6 +52,14 @@ app.add_middleware(CookieConsentMiddleware)
if settings.REQUEST_TIMEOUT > 0: if settings.REQUEST_TIMEOUT > 0:
app.add_middleware(TimeoutMiddleware) app.add_middleware(TimeoutMiddleware)
app.add_middleware(SecurityHeadersMiddleware) app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(RequestSizeLimitMiddleware, max_size=settings.MAX_REQUEST_BODY_SIZE)
logger.info(f'Request size limiting enabled: {settings.MAX_REQUEST_BODY_SIZE // 1024 // 1024}MB max body size')
if settings.CSRF_PROTECTION_ENABLED:
app.add_middleware(CSRFProtectionMiddleware)
logger.info('CSRF protection enabled')
if settings.IP_WHITELIST_ENABLED:
app.add_middleware(AdminIPWhitelistMiddleware)
logger.info(f'Admin IP whitelisting enabled with {len(settings.ADMIN_IP_WHITELIST)} IP(s)/CIDR range(s)')
if settings.RATE_LIMIT_ENABLED: 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.state.limiter = limiter
@@ -57,8 +69,17 @@ if settings.is_development:
app.add_middleware(CORSMiddleware, allow_origin_regex='http://(localhost|127\\.0\\.0\\.1)(:\\d+)?', allow_credentials=True, allow_methods=['*'], allow_headers=['*']) 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)') logger.info('CORS configured for development (allowing localhost)')
else: else:
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS, allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*']) # Validate CORS_ORIGINS in production
logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origins') if not settings.CORS_ORIGINS or len(settings.CORS_ORIGINS) == 0:
logger.warning('CORS_ORIGINS is empty in production. This may block legitimate requests.')
logger.warning('Please set CORS_ORIGINS environment variable with allowed origins.')
else:
# Log CORS configuration for security audit
logger.info(f'CORS configured for production with {len(settings.CORS_ORIGINS)} allowed origin(s)')
if logger.isEnabledFor(logging.DEBUG):
logger.debug(f'Allowed CORS origins: {", ".join(settings.CORS_ORIGINS)}')
app.add_middleware(CORSMiddleware, allow_origins=settings.CORS_ORIGINS or [], allow_credentials=True, allow_methods=['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allow_headers=['*'])
uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR uploads_dir = Path(__file__).parent.parent / settings.UPLOAD_DIR
uploads_dir.mkdir(exist_ok=True) uploads_dir.mkdir(exist_ok=True)
app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads') app.mount('/uploads', StaticFiles(directory=str(uploads_dir)), name='uploads')
@@ -93,93 +114,85 @@ async def health_check(db: Session=Depends(get_db)):
@app.get('/metrics', tags=['monitoring']) @app.get('/metrics', tags=['monitoring'])
async def metrics(): async def metrics():
return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()} return {'status': 'success', 'service': settings.APP_NAME, 'version': settings.APP_VERSION, 'environment': settings.ENVIRONMENT, 'timestamp': datetime.utcnow().isoformat()}
app.include_router(auth_routes.router, prefix='/api') # Import all route modules
app.include_router(auth_routes.router, prefix=settings.API_V1_PREFIX) from .routes import (
from .routes import room_routes, booking_routes, payment_routes, invoice_routes, banner_routes, favorite_routes, service_routes, service_booking_routes, promotion_routes, report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes, system_settings_routes, contact_routes, page_content_routes, home_routes, about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes, terms_routes, refunds_routes, cancellation_routes, accessibility_routes, faq_routes, loyalty_routes, guest_profile_routes, analytics_routes, workflow_routes, task_routes, notification_routes, group_booking_routes, advanced_room_routes, rate_plan_routes, package_routes, security_routes, email_campaign_routes room_routes, booking_routes, payment_routes, invoice_routes, banner_routes,
app.include_router(room_routes.router, prefix='/api') favorite_routes, service_routes, service_booking_routes, promotion_routes,
app.include_router(booking_routes.router, prefix='/api') report_routes, review_routes, user_routes, audit_routes, admin_privacy_routes,
app.include_router(group_booking_routes.router, prefix='/api') system_settings_routes, contact_routes, page_content_routes, home_routes,
app.include_router(payment_routes.router, prefix='/api') about_routes, contact_content_routes, footer_routes, chat_routes, privacy_routes,
app.include_router(invoice_routes.router, prefix='/api') terms_routes, refunds_routes, cancellation_routes, accessibility_routes,
app.include_router(banner_routes.router, prefix='/api') faq_routes, loyalty_routes, guest_profile_routes, analytics_routes,
app.include_router(favorite_routes.router, prefix='/api') workflow_routes, task_routes, notification_routes, group_booking_routes,
app.include_router(service_routes.router, prefix='/api') advanced_room_routes, rate_plan_routes, package_routes, security_routes,
app.include_router(service_booking_routes.router, prefix='/api') email_campaign_routes
app.include_router(promotion_routes.router, prefix='/api') )
app.include_router(report_routes.router, prefix='/api')
app.include_router(review_routes.router, prefix='/api') # Register all routes with /api prefix (removed duplicate registrations)
app.include_router(user_routes.router, prefix='/api') # Using /api prefix as standard, API versioning can be handled via headers if needed
app.include_router(audit_routes.router, prefix='/api') api_prefix = '/api'
app.include_router(admin_privacy_routes.router, prefix='/api') app.include_router(auth_routes.router, prefix=api_prefix)
app.include_router(system_settings_routes.router, prefix='/api') app.include_router(room_routes.router, prefix=api_prefix)
app.include_router(contact_routes.router, prefix='/api') app.include_router(booking_routes.router, prefix=api_prefix)
app.include_router(home_routes.router, prefix='/api') app.include_router(group_booking_routes.router, prefix=api_prefix)
app.include_router(about_routes.router, prefix='/api') app.include_router(payment_routes.router, prefix=api_prefix)
app.include_router(contact_content_routes.router, prefix='/api') app.include_router(invoice_routes.router, prefix=api_prefix)
app.include_router(footer_routes.router, prefix='/api') app.include_router(banner_routes.router, prefix=api_prefix)
app.include_router(privacy_routes.router, prefix='/api') app.include_router(favorite_routes.router, prefix=api_prefix)
app.include_router(terms_routes.router, prefix='/api') app.include_router(service_routes.router, prefix=api_prefix)
app.include_router(refunds_routes.router, prefix='/api') app.include_router(service_booking_routes.router, prefix=api_prefix)
app.include_router(cancellation_routes.router, prefix='/api') app.include_router(promotion_routes.router, prefix=api_prefix)
app.include_router(accessibility_routes.router, prefix='/api') app.include_router(report_routes.router, prefix=api_prefix)
app.include_router(faq_routes.router, prefix='/api') app.include_router(review_routes.router, prefix=api_prefix)
app.include_router(chat_routes.router, prefix='/api') app.include_router(user_routes.router, prefix=api_prefix)
app.include_router(loyalty_routes.router, prefix='/api') app.include_router(audit_routes.router, prefix=api_prefix)
app.include_router(guest_profile_routes.router, prefix='/api') app.include_router(admin_privacy_routes.router, prefix=api_prefix)
app.include_router(analytics_routes.router, prefix='/api') app.include_router(system_settings_routes.router, prefix=api_prefix)
app.include_router(workflow_routes.router, prefix='/api') app.include_router(contact_routes.router, prefix=api_prefix)
app.include_router(task_routes.router, prefix='/api') app.include_router(home_routes.router, prefix=api_prefix)
app.include_router(notification_routes.router, prefix='/api') app.include_router(about_routes.router, prefix=api_prefix)
app.include_router(advanced_room_routes.router, prefix='/api') app.include_router(contact_content_routes.router, prefix=api_prefix)
app.include_router(rate_plan_routes.router, prefix='/api') app.include_router(footer_routes.router, prefix=api_prefix)
app.include_router(package_routes.router, prefix='/api') app.include_router(privacy_routes.router, prefix=api_prefix)
app.include_router(security_routes.router, prefix='/api') app.include_router(terms_routes.router, prefix=api_prefix)
app.include_router(email_campaign_routes.router, prefix='/api') app.include_router(refunds_routes.router, prefix=api_prefix)
app.include_router(room_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(cancellation_routes.router, prefix=api_prefix)
app.include_router(booking_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(accessibility_routes.router, prefix=api_prefix)
app.include_router(payment_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(faq_routes.router, prefix=api_prefix)
app.include_router(invoice_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(chat_routes.router, prefix=api_prefix)
app.include_router(banner_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(loyalty_routes.router, prefix=api_prefix)
app.include_router(favorite_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(guest_profile_routes.router, prefix=api_prefix)
app.include_router(service_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(analytics_routes.router, prefix=api_prefix)
app.include_router(service_booking_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(workflow_routes.router, prefix=api_prefix)
app.include_router(promotion_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(task_routes.router, prefix=api_prefix)
app.include_router(report_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(notification_routes.router, prefix=api_prefix)
app.include_router(review_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(advanced_room_routes.router, prefix=api_prefix)
app.include_router(user_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(rate_plan_routes.router, prefix=api_prefix)
app.include_router(audit_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(package_routes.router, prefix=api_prefix)
app.include_router(admin_privacy_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(security_routes.router, prefix=api_prefix)
app.include_router(system_settings_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(email_campaign_routes.router, prefix=api_prefix)
app.include_router(contact_routes.router, prefix=settings.API_V1_PREFIX) app.include_router(page_content_routes.router, prefix=api_prefix)
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(privacy_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(terms_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(refunds_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(cancellation_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(accessibility_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(faq_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(chat_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(loyalty_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(guest_profile_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(analytics_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(workflow_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(task_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(notification_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(advanced_room_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(rate_plan_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(package_routes.router, prefix=settings.API_V1_PREFIX)
app.include_router(page_content_routes.router, prefix='/api')
app.include_router(page_content_routes.router, prefix=settings.API_V1_PREFIX)
logger.info('All routes registered successfully') logger.info('All routes registered successfully')
def ensure_jwt_secret(): def ensure_jwt_secret():
"""Generate and save JWT secret if it's using the default value.""" """Generate and save JWT secret if it's using the default value.
In production, fail fast if default secret is used for security.
In development, auto-generate a secure secret if needed.
"""
default_secret = 'dev-secret-key-change-in-production-12345' default_secret = 'dev-secret-key-change-in-production-12345'
current_secret = settings.JWT_SECRET current_secret = settings.JWT_SECRET
# Security check: Fail fast in production if using default secret
if settings.is_production and (not current_secret or current_secret == default_secret):
error_msg = (
'CRITICAL SECURITY ERROR: JWT_SECRET is using default value in production! '
'Please set a secure JWT_SECRET in your environment variables.'
)
logger.error(error_msg)
raise ValueError(error_msg)
# Development mode: Auto-generate if needed
if not current_secret or current_secret == default_secret: if not current_secret or current_secret == default_secret:
new_secret = secrets.token_urlsafe(64) new_secret = secrets.token_urlsafe(64)
@@ -219,6 +232,14 @@ def ensure_jwt_secret():
async def startup_event(): async def startup_event():
ensure_jwt_secret() ensure_jwt_secret()
# Validate encryption key configuration
try:
settings.validate_encryption_key()
except ValueError as e:
logger.error(str(e))
if settings.is_production:
raise # Fail fast in production
logger.info(f'{settings.APP_NAME} started successfully') logger.info(f'{settings.APP_NAME} started successfully')
logger.info(f'Environment: {settings.ENVIRONMENT}') logger.info(f'Environment: {settings.ENVIRONMENT}')
logger.info(f'Debug mode: {settings.DEBUG}') logger.info(f'Debug mode: {settings.DEBUG}')

View File

@@ -0,0 +1,114 @@
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from typing import List
import ipaddress
from ..config.settings import settings
from ..config.logging_config import get_logger
logger = get_logger(__name__)
class AdminIPWhitelistMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce IP whitelisting for admin endpoints.
Only applies to routes starting with /api/admin/ or containing 'admin' in path.
"""
def __init__(self, app, enabled: bool = None, whitelist: List[str] = None):
super().__init__(app)
self.enabled = enabled if enabled is not None else settings.IP_WHITELIST_ENABLED
self.whitelist = whitelist if whitelist is not None else settings.ADMIN_IP_WHITELIST
# Pre-compile IP networks for faster lookup
self._compiled_networks = []
if self.enabled and self.whitelist:
for ip_or_cidr in self.whitelist:
try:
if '/' in ip_or_cidr:
# CIDR notation
network = ipaddress.ip_network(ip_or_cidr, strict=False)
self._compiled_networks.append(network)
else:
# Single IP address
ip = ipaddress.ip_address(ip_or_cidr)
# Convert to /32 network for consistent handling
self._compiled_networks.append(ipaddress.ip_network(f'{ip}/32', strict=False))
except (ValueError, ipaddress.AddressValueError) as e:
logger.warning(f'Invalid IP/CIDR in admin whitelist: {ip_or_cidr} - {str(e)}')
if self.enabled:
logger.info(f'Admin IP whitelisting enabled with {len(self._compiled_networks)} allowed IP(s)/CIDR range(s)')
def _is_admin_route(self, path: str) -> bool:
"""Check if the path is an admin route"""
return '/admin/' in path.lower() or path.lower().startswith('/api/admin')
def _get_client_ip(self, request: Request) -> str:
"""Extract client IP address from request"""
# Check for forwarded IP (when behind proxy/load balancer)
forwarded_for = request.headers.get("X-Forwarded-For")
if forwarded_for:
# X-Forwarded-For can contain multiple IPs, take the first one (original client)
return forwarded_for.split(",")[0].strip()
real_ip = request.headers.get("X-Real-IP")
if real_ip:
return real_ip.strip()
# Fallback to direct client IP
if request.client:
return request.client.host
return None
def _is_ip_allowed(self, ip_address: str) -> bool:
"""Check if IP address is in whitelist"""
if not self._compiled_networks:
# Empty whitelist means deny all (security-first approach)
return False
try:
client_ip = ipaddress.ip_address(ip_address)
for network in self._compiled_networks:
if client_ip in network:
return True
return False
except (ValueError, ipaddress.AddressValueError):
logger.warning(f'Invalid IP address format: {ip_address}')
return False
async def dispatch(self, request: Request, call_next):
# Skip if not enabled
if not self.enabled:
return await call_next(request)
# Only apply to admin routes
if not self._is_admin_route(request.url.path):
return await call_next(request)
# Skip IP check for health checks and public endpoints
if request.url.path in ['/health', '/api/health', '/metrics']:
return await call_next(request)
client_ip = self._get_client_ip(request)
if not client_ip:
logger.warning("Could not determine client IP address for admin route")
# Deny by default if IP cannot be determined (security-first)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"status": "error", "message": "Access denied: Unable to verify IP address"}
)
# Check whitelist
if not self._is_ip_allowed(client_ip):
logger.warning(
f"Admin route access denied for IP: {client_ip} from path: {request.url.path}"
)
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"status": "error", "message": "Access denied. IP address not whitelisted."}
)
# IP is whitelisted, continue
return await call_next(request)

View File

@@ -10,17 +10,48 @@ from ..models.user import User
from ..models.role import Role from ..models.role import Role
security = HTTPBearer() security = HTTPBearer()
def get_jwt_secret() -> str:
"""
Get JWT secret securely, fail if not configured.
Never use hardcoded fallback secrets.
"""
default_secret = 'dev-secret-key-change-in-production-12345'
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', None)
# Fail fast if secret is not configured or using default value
if not jwt_secret or jwt_secret == default_secret:
if settings.is_production:
raise ValueError(
'CRITICAL: JWT_SECRET is not properly configured in production. '
'Please set JWT_SECRET environment variable to a secure random string.'
)
# In development, warn but allow (startup validation should catch this)
import warnings
warnings.warn(
f'JWT_SECRET not configured. Using settings value but this is insecure. '
f'Set JWT_SECRET environment variable.',
UserWarning
)
jwt_secret = getattr(settings, 'JWT_SECRET', None)
if not jwt_secret:
raise ValueError('JWT_SECRET must be configured')
return jwt_secret
def get_current_user(credentials: HTTPAuthorizationCredentials=Depends(security), db: Session=Depends(get_db)) -> User: def get_current_user(credentials: HTTPAuthorizationCredentials=Depends(security), db: Session=Depends(get_db)) -> User:
token = credentials.credentials 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: try:
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') jwt_secret = get_jwt_secret()
payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
user_id: int = payload.get('userId') user_id: int = payload.get('userId')
if user_id is None: if user_id is None:
raise credentials_exception raise credentials_exception
except JWTError: except JWTError:
raise credentials_exception raise credentials_exception
except ValueError as e:
# JWT secret configuration error - should not happen in production
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Server configuration error')
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
if user is None: if user is None:
raise credentials_exception raise credentials_exception
@@ -43,17 +74,17 @@ def get_current_user_optional(credentials: Optional[HTTPAuthorizationCredentials
return None return None
token = credentials.credentials token = credentials.credentials
try: try:
jwt_secret = getattr(settings, 'JWT_SECRET', None) or os.getenv('JWT_SECRET', 'dev-secret-key-change-in-production-12345') jwt_secret = get_jwt_secret()
payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
user_id: int = payload.get('userId') user_id: int = payload.get('userId')
if user_id is None: if user_id is None:
return None return None
except JWTError: except (JWTError, ValueError):
return None return None
user = db.query(User).filter(User.id == user_id).first() user = db.query(User).filter(User.id == user_id).first()
return user return user
def verify_token(token: str) -> dict: 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') jwt_secret = get_jwt_secret()
payload = jwt.decode(token, jwt_secret, algorithms=['HS256']) payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
return payload return payload

View File

@@ -0,0 +1,158 @@
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import Response
from typing import Optional
import secrets
import hmac
import hashlib
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
# Safe HTTP methods that don't require CSRF protection
SAFE_METHODS = {'GET', 'HEAD', 'OPTIONS'}
class CSRFProtectionMiddleware(BaseHTTPMiddleware):
"""
CSRF Protection Middleware
Validates CSRF tokens for state-changing requests (POST, PUT, DELETE, PATCH).
Uses Double Submit Cookie pattern for stateless CSRF protection.
"""
CSRF_TOKEN_COOKIE_NAME = 'XSRF-TOKEN'
CSRF_TOKEN_HEADER_NAME = 'X-XSRF-TOKEN'
CSRF_SECRET_LENGTH = 32
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Skip CSRF protection for certain endpoints that don't need it
# (e.g., public APIs, webhooks with their own validation)
is_exempt = self._is_exempt_path(path)
# Get or generate CSRF token (always generate for all requests to ensure cookie is set)
csrf_token = request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME)
if not csrf_token:
csrf_token = self._generate_token()
# Skip CSRF validation for safe methods (GET, HEAD, OPTIONS) and exempt paths
if request.method in SAFE_METHODS or is_exempt:
response = await call_next(request)
else:
# For state-changing requests, validate the token
if request.method in {'POST', 'PUT', 'DELETE', 'PATCH'}:
header_token = request.headers.get(self.CSRF_TOKEN_HEADER_NAME)
if not header_token:
logger.warning(f"CSRF token missing in header for {request.method} {path}")
# Create error response with CSRF cookie set so frontend can retry
from fastapi.responses import JSONResponse
error_response = JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"status": "error", "message": "CSRF token missing. Please include X-XSRF-TOKEN header."}
)
# Set cookie even on error so client can get the token and retry
if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME):
error_response.set_cookie(
key=self.CSRF_TOKEN_COOKIE_NAME,
value=csrf_token,
httponly=False,
secure=settings.is_production,
samesite='lax', # Changed to 'lax' for better cross-origin support
max_age=86400 * 7,
path='/'
)
return error_response
# Validate token using constant-time comparison
if not self._verify_token(csrf_token, header_token):
logger.warning(f"CSRF token validation failed for {request.method} {path}")
# Create error response with CSRF cookie set so frontend can retry
from fastapi.responses import JSONResponse
error_response = JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"status": "error", "message": "Invalid CSRF token. Please refresh the page and try again."}
)
# Set cookie even on error so client can get the token and retry
if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME):
error_response.set_cookie(
key=self.CSRF_TOKEN_COOKIE_NAME,
value=csrf_token,
httponly=False,
secure=settings.is_production,
samesite='lax', # Changed to 'lax' for better cross-origin support
max_age=86400 * 7,
path='/'
)
return error_response
# Process request
response = await call_next(request)
# Always set CSRF token cookie if not present (ensures client always has it)
# This allows frontend to read it from cookies for subsequent requests
if not request.cookies.get(self.CSRF_TOKEN_COOKIE_NAME):
# Set secure cookie with SameSite protection
response.set_cookie(
key=self.CSRF_TOKEN_COOKIE_NAME,
value=csrf_token,
httponly=False, # Must be accessible to JavaScript for header submission
secure=settings.is_production, # HTTPS only in production
samesite='lax', # Changed to 'lax' for better cross-origin support
max_age=86400 * 7, # 7 days
path='/'
)
return response
def _is_exempt_path(self, path: str) -> bool:
"""
Check if path is exempt from CSRF protection.
Exempt paths:
- Authentication endpoints (login, register, logout, refresh token)
- Webhook endpoints (they have their own signature validation)
- Health check endpoints
- Static file endpoints
"""
exempt_patterns = [
'/api/auth/', # All authentication endpoints
'/api/webhooks/',
'/api/stripe/webhook',
'/api/payments/stripe/webhook',
'/api/paypal/webhook',
'/health',
'/api/health',
'/static/',
'/docs',
'/redoc',
'/openapi.json'
]
return any(path.startswith(pattern) for pattern in exempt_patterns)
def _generate_token(self) -> str:
"""Generate a secure random CSRF token."""
return secrets.token_urlsafe(self.CSRF_SECRET_LENGTH)
def _verify_token(self, cookie_token: str, header_token: str) -> bool:
"""
Verify CSRF token using constant-time comparison.
Uses the Double Submit Cookie pattern - the token in the cookie
must match the token in the header.
"""
if not cookie_token or not header_token:
return False
# Constant-time comparison to prevent timing attacks
return hmac.compare_digest(cookie_token, header_token)
def get_csrf_token(request: Request) -> Optional[str]:
"""Helper function to get CSRF token from request cookies."""
return request.cookies.get(CSRFProtectionMiddleware.CSRF_TOKEN_COOKIE_NAME)

View File

@@ -4,6 +4,30 @@ from fastapi.exceptions import RequestValidationError
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from jose.exceptions import JWTError from jose.exceptions import JWTError
import traceback import traceback
import os
from ..utils.response_helpers import error_response
from ..config.settings import settings
def _add_cors_headers(response: JSONResponse, request: Request) -> JSONResponse:
"""Add CORS headers to response for cross-origin requests."""
origin = request.headers.get('Origin')
if origin:
# Check if origin is allowed (development or production)
if settings.is_development:
# Allow localhost origins in development
if origin.startswith('http://localhost') or origin.startswith('http://127.0.0.1'):
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = '*'
else:
# In production, check against CORS_ORIGINS
if origin in settings.CORS_ORIGINS:
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, PATCH, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = '*'
return response
async def validation_exception_handler(request: Request, exc: RequestValidationError): async def validation_exception_handler(request: Request, exc: RequestValidationError):
errors = [] errors = []
@@ -11,25 +35,69 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
field = '.'.join((str(loc) for loc in error['loc'] if loc != 'body')) field = '.'.join((str(loc) for loc in error['loc'] if loc != 'body'))
errors.append({'field': field, 'message': error['msg']}) errors.append({'field': field, 'message': error['msg']})
first_error = errors[0]['message'] if errors else 'Validation error' 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}) request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
response_content = error_response(
message=first_error,
errors=errors,
request_id=request_id
)
response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content)
# Add CORS headers to error responses
return _add_cors_headers(response, request)
async def integrity_error_handler(request: Request, exc: IntegrityError): async def integrity_error_handler(request: Request, exc: IntegrityError):
error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc) error_msg = str(exc.orig) if hasattr(exc, 'orig') else str(exc)
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
if 'Duplicate entry' in error_msg or 'UNIQUE constraint' in error_msg: 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'}]}) response_content = error_response(
return JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content={'status': 'error', 'message': 'Database integrity error'}) message='Duplicate entry',
errors=[{'message': 'This record already exists'}],
request_id=request_id
)
response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content)
else:
response_content = error_response(
message='Database integrity error',
request_id=request_id
)
response = JSONResponse(status_code=status.HTTP_400_BAD_REQUEST, content=response_content)
# Add CORS headers to error responses
return _add_cors_headers(response, request)
async def jwt_error_handler(request: Request, exc: JWTError): async def jwt_error_handler(request: Request, exc: JWTError):
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={'status': 'error', 'message': 'Invalid token'}) request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
response_content = error_response(
message='Invalid token',
request_id=request_id
)
response = JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content=response_content)
# Add CORS headers to error responses
return _add_cors_headers(response, request)
async def http_exception_handler(request: Request, exc: HTTPException): async def http_exception_handler(request: Request, exc: HTTPException):
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
if isinstance(exc.detail, dict): if isinstance(exc.detail, dict):
return JSONResponse(status_code=exc.status_code, content=exc.detail) response_content = exc.detail.copy()
return JSONResponse(status_code=exc.status_code, content={'status': 'error', 'message': str(exc.detail) if exc.detail else 'An error occurred'}) if request_id and 'request_id' not in response_content:
response_content['request_id'] = request_id
# Ensure it has standard error response format
if 'status' not in response_content:
response_content['status'] = 'error'
if 'success' not in response_content:
response_content['success'] = False
response = JSONResponse(status_code=exc.status_code, content=response_content)
else:
response_content = error_response(
message=str(exc.detail) if exc.detail else 'An error occurred',
request_id=request_id
)
response = JSONResponse(status_code=exc.status_code, content=response_content)
# Add CORS headers to error responses
return _add_cors_headers(response, request)
async def general_exception_handler(request: Request, exc: Exception): async def general_exception_handler(request: Request, exc: Exception):
from ..config.logging_config import get_logger from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__) logger = get_logger(__name__)
request_id = getattr(request.state, 'request_id', None) 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) 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)
@@ -38,14 +106,30 @@ async def general_exception_handler(request: Request, exc: Exception):
if hasattr(exc, 'detail'): if hasattr(exc, 'detail'):
detail = exc.detail detail = exc.detail
if isinstance(detail, dict): if isinstance(detail, dict):
return JSONResponse(status_code=status_code, content=detail) response = JSONResponse(status_code=status_code, content=detail)
return _add_cors_headers(response, request)
message = str(detail) if detail else 'An error occurred' message = str(detail) if detail else 'An error occurred'
else: else:
message = str(exc) if str(exc) else 'Internal server error' message = str(exc) if str(exc) else 'Internal server error'
else: else:
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
message = str(exc) if str(exc) else 'Internal server error' message = str(exc) if str(exc) else 'Internal server error'
response_content = {'status': 'error', 'message': message} response_content = error_response(
message=message,
request_id=request_id
)
# NEVER include stack traces in production responses
# Always log stack traces server-side only for debugging
if settings.is_development: if settings.is_development:
# Only include stack traces in development mode
# Double-check environment to prevent accidental exposure
env_check = os.getenv('ENVIRONMENT', 'development').lower()
if env_check == 'development':
response_content['stack'] = traceback.format_exc() response_content['stack'] = traceback.format_exc()
return JSONResponse(status_code=status_code, content=response_content) else:
# Log warning if development flag is set but environment says otherwise
logger.warning(f'is_development=True but ENVIRONMENT={env_check}. Not including stack trace in response.')
# Stack traces are always logged server-side via exc_info=True above
response = JSONResponse(status_code=status_code, content=response_content)
# Add CORS headers to error responses
return _add_cors_headers(response, request)

View File

@@ -0,0 +1,53 @@
from fastapi import Request, HTTPException, status
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from ..config.logging_config import get_logger
from ..config.settings import settings
logger = get_logger(__name__)
class RequestSizeLimitMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce maximum request body size limits.
Prevents DoS attacks by rejecting requests that exceed the configured
maximum body size before they are processed.
"""
def __init__(self, app, max_size: int = None):
super().__init__(app)
self.max_size = max_size or settings.MAX_REQUEST_BODY_SIZE
async def dispatch(self, request: Request, call_next):
# Skip size check for methods that don't have bodies
if request.method in ['GET', 'HEAD', 'OPTIONS', 'DELETE']:
return await call_next(request)
# Check Content-Length header if available
content_length = request.headers.get('content-length')
if content_length:
try:
size = int(content_length)
if size > self.max_size:
logger.warning(
f"Request body size {size} bytes exceeds maximum {self.max_size} bytes "
f"from {request.client.host if request.client else 'unknown'}"
)
return JSONResponse(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
content={
'status': 'error',
'message': f'Request body too large. Maximum size: {self.max_size // 1024 // 1024}MB'
}
)
except (ValueError, TypeError):
# Invalid content-length header, let it pass and let FastAPI handle it
pass
# For streaming requests without Content-Length, we need to check the body
# This is handled by limiting the body read size
response = await call_next(request)
return response

View File

@@ -12,9 +12,29 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
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 = {'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') security_headers.setdefault('Cross-Origin-Resource-Policy', 'cross-origin')
if settings.is_production: 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'" # Enhanced CSP with additional directives
if settings.is_production: # Note: unsafe-inline and unsafe-eval are kept for React/Vite compatibility
security_headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' # Consider moving to nonces/hashes in future for stricter policy
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' https:; "
"base-uri 'self'; "
"form-action 'self'; "
"frame-ancestors 'none'; "
"object-src 'none'; "
"upgrade-insecure-requests"
)
# HSTS with preload directive (only add preload if domain is ready for it)
# Preload requires manual submission to hstspreload.org
# Include preload directive only if explicitly enabled
hsts_directive = 'max-age=31536000; includeSubDomains'
if getattr(settings, 'HSTS_PRELOAD_ENABLED', False):
hsts_directive += '; preload'
security_headers['Strict-Transport-Security'] = hsts_directive
for header, value in security_headers.items(): for header, value in security_headers.items():
response.headers[header] = value response.headers[header] = value
return response return response

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, Numeric, Boolean, Text, Enum, ForeignKey, Index
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
@@ -15,10 +15,10 @@ class Booking(Base):
__tablename__ = 'bookings' __tablename__ = 'bookings'
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
booking_number = Column(String(50), unique=True, nullable=False, index=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, index=True)
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
check_in_date = Column(DateTime, nullable=False) check_in_date = Column(DateTime, nullable=False, index=True)
check_out_date = Column(DateTime, nullable=False) check_out_date = Column(DateTime, nullable=False, index=True)
num_guests = Column(Integer, nullable=False, default=1) num_guests = Column(Integer, nullable=False, default=1)
total_price = Column(Numeric(10, 2), nullable=False) total_price = Column(Numeric(10, 2), nullable=False)
original_price = Column(Numeric(10, 2), nullable=True) original_price = Column(Numeric(10, 2), nullable=True)
@@ -40,3 +40,9 @@ class Booking(Base):
group_booking = relationship('GroupBooking', back_populates='individual_bookings') group_booking = relationship('GroupBooking', back_populates='individual_bookings')
rate_plan_id = Column(Integer, ForeignKey('rate_plans.id'), nullable=True) rate_plan_id = Column(Integer, ForeignKey('rate_plans.id'), nullable=True)
rate_plan = relationship('RatePlan', back_populates='bookings') rate_plan = relationship('RatePlan', back_populates='bookings')
# Composite index for date range queries (availability checks)
__table_args__ = (
Index('idx_booking_dates', 'check_in_date', 'check_out_date'),
Index('idx_booking_user_dates', 'user_id', 'check_in_date', 'check_out_date'),
)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Boolean from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Boolean, Index
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
@@ -15,8 +15,8 @@ class Invoice(Base):
__tablename__ = 'invoices' __tablename__ = 'invoices'
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
invoice_number = Column(String(50), unique=True, nullable=False, index=True) invoice_number = Column(String(50), unique=True, nullable=False, index=True)
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False) booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=False, index=True)
user_id = Column(Integer, ForeignKey('users.id'), nullable=False) user_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
issue_date = Column(DateTime, default=datetime.utcnow, nullable=False) issue_date = Column(DateTime, default=datetime.utcnow, nullable=False)
due_date = Column(DateTime, nullable=False) due_date = Column(DateTime, nullable=False)
paid_date = Column(DateTime, nullable=True) paid_date = Column(DateTime, nullable=True)
@@ -53,6 +53,13 @@ class Invoice(Base):
updated_by = relationship('User', foreign_keys=[updated_by_id]) updated_by = relationship('User', foreign_keys=[updated_by_id])
items = relationship('InvoiceItem', back_populates='invoice', cascade='all, delete-orphan') items = relationship('InvoiceItem', back_populates='invoice', cascade='all, delete-orphan')
# Index for invoice status and date queries
__table_args__ = (
Index('idx_invoice_status', 'status'),
Index('idx_invoice_user_status', 'user_id', 'status'),
Index('idx_invoice_due_date', 'due_date'),
)
class InvoiceItem(Base): class InvoiceItem(Base):
__tablename__ = 'invoice_items' __tablename__ = 'invoice_items'
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey from sqlalchemy import Column, Integer, String, DateTime, Numeric, Text, Enum, ForeignKey, Index
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
@@ -28,7 +28,7 @@ class PaymentStatus(str, enum.Enum):
class Payment(Base): class Payment(Base):
__tablename__ = 'payments' __tablename__ = 'payments'
id = Column(Integer, primary_key=True, index=True, autoincrement=True) 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, index=True)
amount = Column(Numeric(10, 2), nullable=False) amount = Column(Numeric(10, 2), nullable=False)
payment_method = Column(Enum(PaymentMethod), nullable=False) payment_method = Column(Enum(PaymentMethod), nullable=False)
payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full) payment_type = Column(Enum(PaymentType), nullable=False, default=PaymentType.full)
@@ -42,3 +42,9 @@ class Payment(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
booking = relationship('Booking', back_populates='payments') booking = relationship('Booking', back_populates='payments')
related_payment = relationship('Payment', remote_side=[id], backref='related_payments') related_payment = relationship('Payment', remote_side=[id], backref='related_payments')
# Index for payment status queries
__table_args__ = (
Index('idx_payment_status', 'payment_status'),
Index('idx_payment_booking_status', 'booking_id', 'payment_status'),
)

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, Text, Enum, ForeignKey, DateTime from sqlalchemy import Column, Integer, Text, Enum, ForeignKey, DateTime, Index
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
import enum import enum
@@ -12,8 +12,8 @@ class ReviewStatus(str, enum.Enum):
class Review(Base): class Review(Base):
__tablename__ = 'reviews' __tablename__ = 'reviews'
id = Column(Integer, primary_key=True, index=True, autoincrement=True) 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, index=True)
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False) room_id = Column(Integer, ForeignKey('rooms.id'), nullable=False, index=True)
rating = Column(Integer, nullable=False) rating = Column(Integer, nullable=False)
comment = Column(Text, nullable=False) comment = Column(Text, nullable=False)
status = Column(Enum(ReviewStatus), nullable=False, default=ReviewStatus.pending) status = Column(Enum(ReviewStatus), nullable=False, default=ReviewStatus.pending)
@@ -21,3 +21,9 @@ class Review(Base):
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship('User', back_populates='reviews') user = relationship('User', back_populates='reviews')
room = relationship('Room', back_populates='reviews') room = relationship('Room', back_populates='reviews')
# Index for review status and room queries
__table_args__ = (
Index('idx_review_status', 'status'),
Index('idx_review_room_status', 'room_id', 'status'),
)

View File

@@ -19,6 +19,10 @@ class User(Base):
mfa_secret = Column(String(255), nullable=True) mfa_secret = Column(String(255), nullable=True)
mfa_backup_codes = Column(Text, nullable=True) mfa_backup_codes = Column(Text, nullable=True)
# Account lockout fields
failed_login_attempts = Column(Integer, nullable=False, default=0)
locked_until = Column(DateTime, nullable=True)
# Guest Profile & CRM fields # Guest Profile & CRM fields
is_vip = Column(Boolean, nullable=False, default=False) is_vip = Column(Boolean, nullable=False, default=False)
lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest lifetime_value = Column(Numeric(10, 2), nullable=True, default=0) # Total revenue from guest

View File

@@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func, desc
from typing import List, Optional from typing import List, Optional
from datetime import datetime, timedelta from datetime import datetime, timedelta
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.role import Role from ..models.role import Role
@@ -17,6 +18,7 @@ from ..services.room_assignment_service import RoomAssignmentService
from pydantic import BaseModel from pydantic import BaseModel
from typing import Dict, Any from typing import Dict, Any
logger = get_logger(__name__)
router = APIRouter(prefix='/advanced-rooms', tags=['advanced-room-management']) router = APIRouter(prefix='/advanced-rooms', tags=['advanced-room-management'])
@@ -468,9 +470,9 @@ async def create_housekeeping_task(
try: try:
await manager.staff_connections[assigned_to].send_json(notification_data) await manager.staff_connections[assigned_to].send_json(notification_data)
except Exception as e: except Exception as e:
print(f'Error sending housekeeping task notification to staff {assigned_to}: {e}') logger.error(f'Error sending housekeeping task notification to staff {assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': assigned_to})
except Exception as e: except Exception as e:
print(f'Error setting up housekeeping task notification: {e}') logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
return { return {
'status': 'success', 'status': 'success',
@@ -577,9 +579,9 @@ async def update_housekeeping_task(
try: try:
await manager.staff_connections[task.assigned_to].send_json(notification_data) await manager.staff_connections[task.assigned_to].send_json(notification_data)
except Exception as e: except Exception as e:
print(f'Error sending housekeeping task notification to staff {task.assigned_to}: {e}') logger.error(f'Error sending housekeeping task notification to staff {task.assigned_to}: {str(e)}', exc_info=True, extra={'staff_id': task.assigned_to})
except Exception as e: except Exception as e:
print(f'Error setting up housekeeping task notification: {e}') logger.error(f'Error setting up housekeeping task notification: {str(e)}', exc_info=True)
return { return {
'status': 'success', 'status': 'success',

View File

@@ -10,8 +10,29 @@ 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 ..middleware.auth import get_current_user
from ..models.user import User from ..models.user import User
from ..services.audit_service import audit_service
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
router = APIRouter(prefix='/auth', tags=['auth']) router = APIRouter(prefix='/auth', tags=['auth'])
# Stricter rate limits for authentication endpoints
AUTH_RATE_LIMIT = "5/minute" # 5 attempts per minute per IP
PASSWORD_RESET_LIMIT = "3/hour" # 3 password reset requests per hour per IP
LOGIN_RATE_LIMIT = "10/minute" # 10 login attempts per minute per IP
def get_limiter(request: Request) -> Limiter:
"""Get limiter instance from app state."""
return request.app.state.limiter if hasattr(request.app.state, 'limiter') else None
def apply_rate_limit(func, limit_value: str):
"""Helper to apply rate limiting decorator if limiter is available."""
def decorator(*args, **kwargs):
# This will be applied at runtime when route is called
return func(*args, **kwargs)
return decorator
def get_base_url(request: Request) -> str: def get_base_url(request: Request) -> str:
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')}'
@@ -25,27 +46,133 @@ def normalize_image_url(image_url: str, base_url: str) -> str:
return f'{base_url}/{image_url}' return f'{base_url}/{image_url}'
@router.post('/register', status_code=status.HTTP_201_CREATED) @router.post('/register', status_code=status.HTTP_201_CREATED)
async def register(request: RegisterRequest, response: Response, db: Session=Depends(get_db)): async def register(
request: Request,
register_request: RegisterRequest,
response: Response,
db: Session=Depends(get_db)
):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try: try:
result = await auth_service.register(db=db, name=request.name, email=request.email, password=request.password, phone=request.phone) result = await auth_service.register(db=db, name=register_request.name, email=register_request.email, password=register_request.password, phone=register_request.phone)
response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=7 * 24 * 60 * 60, path='/') from ..config.settings import settings
# Use secure cookies in production (HTTPS required)
response.set_cookie(
key='refreshToken',
value=result['refreshToken'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite='strict',
max_age=7 * 24 * 60 * 60,
path='/'
)
# Log successful registration
await audit_service.log_action(
db=db,
action='user_registered',
resource_type='user',
user_id=result['user']['id'],
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': register_request.email, 'name': register_request.name},
status='success'
)
return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}} return {'status': 'success', 'message': 'Registration successful', 'data': {'token': result['token'], 'user': result['user']}}
except ValueError as e: except ValueError as e:
error_message = str(e) error_message = str(e)
# Log failed registration attempt
await audit_service.log_action(
db=db,
action='user_registration_failed',
resource_type='user',
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': register_request.email, 'name': register_request.name},
status='failed',
error_message=error_message
)
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') @router.post('/login')
async def login(request: LoginRequest, response: Response, db: Session=Depends(get_db)): async def login(
request: Request,
login_request: LoginRequest,
response: Response,
db: Session=Depends(get_db)
):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try: try:
result = await auth_service.login(db=db, email=request.email, password=request.password, remember_me=request.rememberMe or False, mfa_token=request.mfaToken) result = await auth_service.login(db=db, email=login_request.email, password=login_request.password, remember_me=login_request.rememberMe or False, mfa_token=login_request.mfaToken)
if result.get('requires_mfa'): if result.get('requires_mfa'):
# Log MFA required
user = db.query(User).filter(User.email == login_request.email.lower().strip()).first()
if user:
await audit_service.log_action(
db=db,
action='login_mfa_required',
resource_type='authentication',
user_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email},
status='success'
)
return {'status': 'success', 'requires_mfa': True, 'user_id': result['user_id']} 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 from ..config.settings import settings
response.set_cookie(key='refreshToken', value=result['refreshToken'], httponly=True, secure=False, samesite='strict', max_age=max_age, path='/') max_age = 7 * 24 * 60 * 60 if login_request.rememberMe else 1 * 24 * 60 * 60
# Use secure cookies in production (HTTPS required)
response.set_cookie(
key='refreshToken',
value=result['refreshToken'],
httponly=True,
secure=settings.is_production, # Secure flag enabled in production
samesite='strict',
max_age=max_age,
path='/'
)
# Log successful login
await audit_service.log_action(
db=db,
action='login_success',
resource_type='authentication',
user_id=result['user']['id'],
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email, 'remember_me': login_request.rememberMe},
status='success'
)
return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}} return {'status': 'success', 'data': {'token': result['token'], 'user': result['user']}}
except ValueError as e: except ValueError as e:
error_message = str(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 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
# Log failed login attempt
await audit_service.log_action(
db=db,
action='login_failed',
resource_type='authentication',
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': login_request.email},
status='failed',
error_message=error_message
)
return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message}) return JSONResponse(status_code=status_code, content={'status': 'error', 'message': error_message})
@router.post('/refresh-token', response_model=TokenResponse) @router.post('/refresh-token', response_model=TokenResponse)
@@ -59,10 +186,34 @@ async def refresh_token(refreshToken: str=Cookie(None), db: Session=Depends(get_
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) @router.post('/logout', response_model=MessageResponse)
async def logout(response: Response, refreshToken: str=Cookie(None), db: Session=Depends(get_db)): async def logout(
request: Request,
response: Response,
refreshToken: str=Cookie(None),
current_user: User=Depends(get_current_user),
db: Session=Depends(get_db)
):
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
if refreshToken: if refreshToken:
await auth_service.logout(db, refreshToken) await auth_service.logout(db, refreshToken)
response.delete_cookie(key='refreshToken', path='/') response.delete_cookie(key='refreshToken', path='/')
# Log logout
await audit_service.log_action(
db=db,
action='logout',
resource_type='authentication',
user_id=current_user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'email': current_user.email},
status='success'
)
return {'status': 'success', 'message': 'Logout successful'} return {'status': 'success', 'message': 'Logout successful'}
@router.get('/profile') @router.get('/profile')
@@ -164,11 +315,12 @@ async def regenerate_backup_codes(current_user: User=Depends(get_current_user),
@router.post('/avatar/upload') @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)): async def upload_avatar(request: Request, image: UploadFile=File(...), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
if not image.content_type or not image.content_type.startswith('image/'): # Use comprehensive file validation (magic bytes + size)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File must be an image') from ..utils.file_validation import validate_uploaded_image
content = await image.read() max_avatar_size = 2 * 1024 * 1024 # 2MB for 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') # Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_avatar_size)
upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars' upload_dir = Path(__file__).parent.parent.parent / 'uploads' / 'avatars'
upload_dir.mkdir(parents=True, exist_ok=True) upload_dir.mkdir(parents=True, exist_ok=True)
if current_user.avatar: if current_user.avatar:

View File

@@ -135,10 +135,15 @@ async def upload_banner_image(request: Request, image: UploadFile=File(...), cur
ext = Path(image.filename).suffix or '.jpg' ext = Path(image.filename).suffix or '.jpg'
filename = f'banner-{uuid.uuid4()}{ext}' filename = f'banner-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename file_path = upload_dir / filename
# Use comprehensive file validation (magic bytes + size)
from ..config.settings import settings
from ..utils.file_validation import validate_uploaded_image
max_size = settings.MAX_UPLOAD_SIZE
# Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_size)
async with aiofiles.open(file_path, 'wb') as f: 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')
await f.write(content) await f.write(content)
image_url = f'/uploads/banners/{filename}' image_url = f'/uploads/banners/{filename}'
base_url = get_base_url(request) base_url = get_base_url(request)

View File

@@ -24,6 +24,7 @@ from ..utils.email_templates import booking_confirmation_email_template, booking
from ..services.loyalty_service import LoyaltyService from ..services.loyalty_service import LoyaltyService
from ..utils.currency_helpers import get_currency_symbol from ..utils.currency_helpers import get_currency_symbol
from ..utils.response_helpers import success_response from ..utils.response_helpers import success_response
from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest
router = APIRouter(prefix='/bookings', tags=['bookings']) router = APIRouter(prefix='/bookings', tags=['bookings'])
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str: def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
@@ -159,47 +160,45 @@ async def get_my_bookings(request: Request, current_user: User=Depends(get_curre
booking_dict['payments'] = [] booking_dict['payments'] = []
result.append(booking_dict) result.append(booking_dict)
return success_response(data={'bookings': result}) return success_response(data={'bookings': result})
except HTTPException:
raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) db.rollback()
import logging
logger = logging.getLogger(__name__)
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
logger.error(f'Error in get_my_bookings: {str(e)}', extra={'request_id': request_id, 'user_id': current_user.id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching bookings')
@router.post('/') @router.post('/')
async def create_booking(booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_booking(booking_data: CreateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
"""Create a new booking with validated input using Pydantic schema."""
role = db.query(Role).filter(Role.id == current_user.role_id).first() role = db.query(Role).filter(Role.id == current_user.role_id).first()
if role and role.name in ['admin', 'staff', 'accountant']: if role and role.name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot create bookings') raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot create bookings')
try: try:
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
if not isinstance(booking_data, dict): logger.info(f'Received booking request from user {current_user.id}: {booking_data.dict()}')
logger.error(f'Invalid booking_data type: {type(booking_data)}, value: {booking_data}')
raise HTTPException(status_code=400, detail='Invalid request body. Expected JSON object.') # Extract validated data from Pydantic model
logger.info(f'Received booking request from user {current_user.id}: {booking_data}') room_id = booking_data.room_id
room_id = booking_data.get('room_id') check_in_date = booking_data.check_in_date
check_in_date = booking_data.get('check_in_date') check_out_date = booking_data.check_out_date
check_out_date = booking_data.get('check_out_date') total_price = booking_data.total_price
total_price = booking_data.get('total_price') guest_count = booking_data.guest_count
guest_count = booking_data.get('guest_count', 1) notes = booking_data.notes
notes = booking_data.get('notes') payment_method = booking_data.payment_method
payment_method = booking_data.get('payment_method', 'cash') promotion_code = booking_data.promotion_code
promotion_code = booking_data.get('promotion_code') referral_code = booking_data.referral_code
referral_code = booking_data.get('referral_code') services = booking_data.services or []
invoice_info = booking_data.get('invoice_info', {}) invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {}
missing_fields = []
if not room_id:
missing_fields.append('room_id')
if not check_in_date:
missing_fields.append('check_in_date')
if not check_out_date:
missing_fields.append('check_out_date')
if total_price is None:
missing_fields.append('total_price')
if missing_fields:
error_msg = f'Missing required booking fields: {', '.join(missing_fields)}'
logger.error(error_msg)
raise HTTPException(status_code=400, detail=error_msg)
room = db.query(Room).filter(Room.id == room_id).first() room = db.query(Room).filter(Room.id == room_id).first()
if not room: if not room:
raise HTTPException(status_code=404, detail='Room not found') raise HTTPException(status_code=404, detail='Room not found')
# Parse dates (schema validation already ensures format is valid)
if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date: if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date:
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00')) check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
else: else:
@@ -208,6 +207,8 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00')) check_out = datetime.fromisoformat(check_out_date.replace('Z', '+00:00'))
else: else:
check_out = datetime.strptime(check_out_date, '%Y-%m-%d') check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
# Date validation already done in schema, but keeping as safety check
if check_in >= check_out: if check_in >= check_out:
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date') raise HTTPException(status_code=400, detail='Check-out date must be after check-in date')
overlapping = db.query(Booking).filter(and_(Booking.room_id == room_id, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first() overlapping = db.query(Booking).filter(and_(Booking.room_id == room_id, Booking.status != BookingStatus.cancelled, Booking.check_in_date < check_out, Booking.check_out_date > check_in)).first()
@@ -247,15 +248,13 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
number_of_nights = 1 # Minimum 1 night number_of_nights = 1 # Minimum 1 night
room_total = room_price * number_of_nights room_total = room_price * number_of_nights
# Calculate services total if any # Calculate services total if any (using Pydantic model)
services = booking_data.get('services', [])
services_total = 0.0 services_total = 0.0
if services: if services:
from ..models.service import Service from ..models.service import Service
for service_item in services: for service_item in services:
service_id = service_item.get('service_id') service_id = service_item.service_id
quantity = service_item.get('quantity', 1) quantity = service_item.quantity
if service_id:
service = db.query(Service).filter(Service.id == service_id).first() service = db.query(Service).filter(Service.id == service_id).first()
if service and service.is_active: if service and service.is_active:
services_total += float(service.price) * quantity services_total += float(service.price) * quantity
@@ -358,14 +357,12 @@ async def create_booking(booking_data: dict, current_user: User=Depends(get_curr
db.add(deposit_payment) db.add(deposit_payment)
db.flush() db.flush()
logger.info(f'Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%') logger.info(f'Deposit payment created: ID={deposit_payment.id}, amount={deposit_amount}, percentage={deposit_percentage}%')
services = booking_data.get('services', []) # Add service usages (services already extracted from Pydantic model)
if services: if services:
from ..models.service import Service from ..models.service import Service
for service_item in services: for service_item in services:
service_id = service_item.get('service_id') service_id = service_item.service_id
quantity = service_item.get('quantity', 1) quantity = service_item.quantity
if not service_id:
continue
service = db.query(Service).filter(Service.id == service_id).first() service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.is_active: if not service or not service.is_active:
continue continue
@@ -559,7 +556,12 @@ async def get_booking_by_id(id: int, request: Request, current_user: User=Depend
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) db.rollback()
import logging
logger = logging.getLogger(__name__)
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
logger.error(f'Error in get_booking_by_id: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching booking')
@router.patch('/{id}/cancel') @router.patch('/{id}/cancel')
async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def cancel_booking(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
@@ -645,7 +647,7 @@ async def cancel_booking(id: int, current_user: User=Depends(get_current_user),
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))]) @router.put('/{id}', dependencies=[Depends(authorize_roles('admin', 'staff'))])
async def update_booking(id: int, booking_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def update_booking(id: int, booking_data: UpdateBookingRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
booking = db.query(Booking).options( booking = db.query(Booking).options(
selectinload(Booking.payments), selectinload(Booking.payments),
@@ -654,7 +656,7 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') raise HTTPException(status_code=404, detail='Booking not found')
old_status = booking.status old_status = booking.status
status_value = booking_data.get('status') status_value = booking_data.status
room = booking.room room = booking.room
new_status = None new_status = None
if status_value: if status_value:
@@ -723,6 +725,29 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
room.status = RoomStatus.available room.status = RoomStatus.available
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail='Invalid status') raise HTTPException(status_code=400, detail='Invalid status')
# Update other fields from schema if provided
if booking_data.check_in_date is not None:
if 'T' in booking_data.check_in_date or 'Z' in booking_data.check_in_date or '+' in booking_data.check_in_date:
booking.check_in_date = datetime.fromisoformat(booking_data.check_in_date.replace('Z', '+00:00'))
else:
booking.check_in_date = datetime.strptime(booking_data.check_in_date, '%Y-%m-%d')
if booking_data.check_out_date is not None:
if 'T' in booking_data.check_out_date or 'Z' in booking_data.check_out_date or '+' in booking_data.check_out_date:
booking.check_out_date = datetime.fromisoformat(booking_data.check_out_date.replace('Z', '+00:00'))
else:
booking.check_out_date = datetime.strptime(booking_data.check_out_date, '%Y-%m-%d')
if booking_data.guest_count is not None:
booking.num_guests = booking_data.guest_count
if booking_data.notes is not None:
booking.special_requests = booking_data.notes
if booking_data.total_price is not None:
booking.total_price = booking_data.total_price
db.commit() db.commit()
# Send booking confirmation notification if status changed to confirmed # Send booking confirmation notification if status changed to confirmed
@@ -834,10 +859,11 @@ async def update_booking(id: int, booking_data: dict, current_user: User=Depends
import logging import logging
import traceback import traceback
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f'Error updating booking {id}: {str(e)}') request_id = getattr(current_user, 'request_id', None) if hasattr(current_user, 'request_id') else None
logger.error(f'Error updating booking {id}: {str(e)}', extra={'request_id': request_id, 'booking_id': id, 'user_id': current_user.id}, exc_info=True)
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=f'Failed to update booking: {str(e)}') raise HTTPException(status_code=500, detail='An error occurred while updating booking')
@router.get('/check/{booking_number}') @router.get('/check/{booking_number}')
async def check_booking_by_number(booking_number: str, db: Session=Depends(get_db)): async def check_booking_by_number(booking_number: str, db: Session=Depends(get_db)):

View File

@@ -5,10 +5,13 @@ from typing import List, Optional
from datetime import datetime from datetime import datetime
import json import json
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, get_current_user_optional from ..middleware.auth import get_current_user, get_current_user_optional
from ..models.user import User from ..models.user import User
from ..models.chat import Chat, ChatMessage, ChatStatus from ..models.chat import Chat, ChatMessage, ChatStatus
from ..models.role import Role from ..models.role import Role
logger = get_logger(__name__)
router = APIRouter(prefix='/chat', tags=['chat']) router = APIRouter(prefix='/chat', tags=['chat'])
class ConnectionManager: class ConnectionManager:
@@ -41,7 +44,7 @@ class ConnectionManager:
try: try:
await websocket.send_json(message) await websocket.send_json(message)
except Exception as e: except Exception as e:
print(f'Error sending message: {e}') logger.error(f'Error sending message: {str(e)}', exc_info=True)
async def broadcast_to_chat(self, message: dict, chat_id: int): async def broadcast_to_chat(self, message: dict, chat_id: int):
if chat_id in self.active_connections: if chat_id in self.active_connections:
@@ -50,7 +53,7 @@ class ConnectionManager:
try: try:
await connection.send_json(message) await connection.send_json(message)
except Exception as e: except Exception as e:
print(f'Error broadcasting to connection: {e}') logger.error(f'Error broadcasting to connection: {str(e)}', exc_info=True, extra={'chat_id': chat_id})
disconnected.append(connection) disconnected.append(connection)
for conn in disconnected: for conn in disconnected:
self.active_connections[chat_id].remove(conn) self.active_connections[chat_id].remove(conn)
@@ -61,7 +64,7 @@ class ConnectionManager:
try: try:
await websocket.send_json({'type': 'new_chat', 'data': chat_data}) await websocket.send_json({'type': 'new_chat', 'data': chat_data})
except Exception as e: except Exception as e:
print(f'Error notifying staff {user_id}: {e}') logger.error(f'Error notifying staff {user_id}: {str(e)}', exc_info=True, extra={'staff_id': user_id})
disconnected.append(user_id) disconnected.append(user_id)
for user_id in disconnected: for user_id in disconnected:
del self.staff_connections[user_id] del self.staff_connections[user_id]
@@ -74,7 +77,7 @@ class ConnectionManager:
try: try:
await websocket.send_json(notification_data) await websocket.send_json(notification_data)
except Exception as e: except Exception as e:
print(f'Error notifying staff {user_id}: {e}') logger.error(f'Error notifying staff {user_id}: {str(e)}', exc_info=True, extra={'staff_id': user_id, 'chat_id': chat_id})
disconnected.append(user_id) disconnected.append(user_id)
for user_id in disconnected: for user_id in disconnected:
del self.staff_connections[user_id] del self.staff_connections[user_id]
@@ -296,16 +299,14 @@ async def websocket_staff_notifications(websocket: WebSocket):
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
print(f'WebSocket token verification error: {e}') logger.error(f'WebSocket token verification error: {str(e)}', exc_info=True)
import traceback
traceback.print_exc()
await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}') await websocket.close(code=1008, reason=f'Token verification failed: {str(e)}')
return return
manager.connect_staff(current_user.id, websocket) manager.connect_staff(current_user.id, websocket)
try: try:
await websocket.send_json({'type': 'connected', 'data': {'message': 'WebSocket connected'}}) await websocket.send_json({'type': 'connected', 'data': {'message': 'WebSocket connected'}})
except Exception as e: except Exception as e:
print(f'Error sending initial message: {e}') logger.error(f'Error sending initial message: {str(e)}', exc_info=True, extra={'user_id': current_user.id})
while True: while True:
try: try:
data = await websocket.receive_text() data = await websocket.receive_text()
@@ -316,17 +317,15 @@ async def websocket_staff_notifications(websocket: WebSocket):
except json.JSONDecodeError: except json.JSONDecodeError:
await websocket.send_json({'type': 'pong', 'data': 'pong'}) await websocket.send_json({'type': 'pong', 'data': 'pong'})
except WebSocketDisconnect: except WebSocketDisconnect:
print('WebSocket disconnected normally') logger.info('WebSocket disconnected normally', extra={'user_id': current_user.id})
break break
except Exception as e: except Exception as e:
print(f'WebSocket receive error: {e}') logger.error(f'WebSocket receive error: {str(e)}', exc_info=True, extra={'user_id': current_user.id})
break break
except WebSocketDisconnect: except WebSocketDisconnect:
print('WebSocket disconnected') logger.info('WebSocket disconnected')
except Exception as e: except Exception as e:
print(f'WebSocket error: {e}') logger.error(f'WebSocket error: {str(e)}', exc_info=True)
import traceback
traceback.print_exc()
finally: finally:
if current_user: if current_user:
try: try:

View File

@@ -8,6 +8,7 @@ from ..models.user import User
from ..models.role import Role from ..models.role import Role
from ..models.system_settings import SystemSettings from ..models.system_settings import SystemSettings
from ..utils.mailer import send_email from ..utils.mailer import send_email
from ..utils.html_sanitizer import sanitize_text_for_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix='/contact', tags=['contact']) router = APIRouter(prefix='/contact', tags=['contact'])

View File

@@ -1,8 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.invoice import Invoice, InvoiceStatus from ..models.invoice import Invoice, InvoiceStatus
@@ -10,15 +11,25 @@ from ..models.booking import Booking
from ..services.invoice_service import InvoiceService from ..services.invoice_service import InvoiceService
from ..utils.role_helpers import can_access_all_invoices, can_create_invoices from ..utils.role_helpers import can_access_all_invoices, can_create_invoices
from ..utils.response_helpers import success_response from ..utils.response_helpers import success_response
from ..utils.request_helpers import get_request_id
from ..schemas.invoice import (
CreateInvoiceRequest,
UpdateInvoiceRequest,
MarkInvoicePaidRequest
)
logger = get_logger(__name__)
router = APIRouter(prefix='/invoices', tags=['invoices']) router = APIRouter(prefix='/invoices', tags=['invoices'])
@router.get('/') @router.get('/')
async def get_invoices(booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def get_invoices(request: Request, booking_id: Optional[int]=Query(None), status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
user_id = None if can_access_all_invoices(current_user, db) else current_user.id user_id = None if can_access_all_invoices(current_user, db) else current_user.id
result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit) result = InvoiceService.get_invoices(db=db, user_id=user_id, booking_id=booking_id, status=status_filter, page=page, limit=limit)
return success_response(data=result) return success_response(data=result)
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching invoices: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/{id}') @router.get('/{id}')
@@ -33,64 +44,96 @@ async def get_invoice_by_id(id: int, current_user: User=Depends(get_current_user
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching invoice by id: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/') @router.post('/')
async def create_invoice(invoice_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_invoice(request: Request, invoice_data: CreateInvoiceRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
if not can_create_invoices(current_user, db): if not can_create_invoices(current_user, db):
raise HTTPException(status_code=403, detail='Forbidden') raise HTTPException(status_code=403, detail='Forbidden')
booking_id = invoice_data.get('booking_id') booking_id = invoice_data.booking_id
if not booking_id:
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')
booking = db.query(Booking).filter(Booking.id == booking_id).first() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') raise HTTPException(status_code=404, detail='Booking not found')
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_kwargs = {
'company_name': invoice_data.company_name,
'company_address': invoice_data.company_address,
'company_phone': invoice_data.company_phone,
'company_email': invoice_data.company_email,
'company_tax_id': invoice_data.company_tax_id,
'company_logo_url': invoice_data.company_logo_url,
'customer_tax_id': invoice_data.customer_tax_id,
'notes': invoice_data.notes,
'terms_and_conditions': invoice_data.terms_and_conditions,
'payment_instructions': invoice_data.payment_instructions
}
invoice_notes = invoice_kwargs.get('notes', '') invoice_notes = invoice_kwargs.get('notes', '')
if booking.promotion_code: if booking.promotion_code:
promotion_note = f'Promotion Code: {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_notes = f'{promotion_note}\n{invoice_notes}'.strip() if invoice_notes else promotion_note
invoice_kwargs['notes'] = invoice_notes invoice_kwargs['notes'] = invoice_notes
invoice = InvoiceService.create_invoice_from_booking(booking_id=booking_id, db=db, created_by_id=current_user.id, tax_rate=invoice_data.get('tax_rate', 0.0), discount_amount=invoice_data.get('discount_amount', 0.0), due_days=invoice_data.get('due_days', 30), **invoice_kwargs) request_id = get_request_id(request)
invoice = InvoiceService.create_invoice_from_booking(
booking_id=booking_id,
db=db,
created_by_id=current_user.id,
tax_rate=invoice_data.tax_rate,
discount_amount=invoice_data.discount_amount,
due_days=invoice_data.due_days,
request_id=request_id,
**invoice_kwargs
)
return success_response(data={'invoice': invoice}, message='Invoice created successfully') return success_response(data={'invoice': invoice}, message='Invoice created successfully')
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:
db.rollback()
logger.error(f'Error creating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error creating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}') @router.put('/{id}')
async def update_invoice(id: int, invoice_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): async def update_invoice(request: Request, id: int, invoice_data: UpdateInvoiceRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try: try:
invoice = db.query(Invoice).filter(Invoice.id == id).first() invoice = db.query(Invoice).filter(Invoice.id == id).first()
if not invoice: if not invoice:
raise HTTPException(status_code=404, detail='Invoice not found') raise HTTPException(status_code=404, detail='Invoice not found')
updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, **invoice_data) request_id = get_request_id(request)
invoice_dict = invoice_data.model_dump(exclude_unset=True)
updated_invoice = InvoiceService.update_invoice(invoice_id=id, db=db, updated_by_id=current_user.id, request_id=request_id, **invoice_dict)
return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully') return success_response(data={'invoice': updated_invoice}, message='Invoice updated successfully')
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:
db.rollback()
logger.error(f'Error updating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error updating invoice: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/{id}/mark-paid') @router.post('/{id}/mark-paid')
async def mark_invoice_as_paid(id: int, payment_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): async def mark_invoice_as_paid(request: Request, id: int, payment_data: MarkInvoicePaidRequest, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
try: try:
amount = payment_data.get('amount') request_id = get_request_id(request)
updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id) amount = payment_data.amount
updated_invoice = InvoiceService.mark_invoice_as_paid(invoice_id=id, db=db, amount=amount, updated_by_id=current_user.id, request_id=request_id)
return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully') return success_response(data={'invoice': updated_invoice}, message='Invoice marked as paid successfully')
except HTTPException: except HTTPException:
raise raise
except ValueError as e: except ValueError as e:
db.rollback()
logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error marking invoice as paid: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}') @router.delete('/{id}')
@@ -121,4 +164,6 @@ async def get_invoices_by_booking(booking_id: int, current_user: User=Depends(ge
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching invoices by booking: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -12,6 +12,8 @@ from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.page_content import PageContent, PageType from ..models.page_content import PageContent, PageType
from ..schemas.page_content import PageContentUpdateRequest
from ..utils.html_sanitizer import sanitize_html
logger = get_logger(__name__) logger = get_logger(__name__)
router = APIRouter(prefix='/page-content', tags=['page-content']) router = APIRouter(prefix='/page-content', tags=['page-content'])
@@ -25,6 +27,8 @@ async def get_all_page_contents(db: Session=Depends(get_db)):
result.append(content_dict) result.append(content_dict)
return {'status': 'success', 'data': {'page_contents': result}} return {'status': 'success', 'data': {'page_contents': result}}
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching page contents: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page contents: {str(e)}') raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page contents: {str(e)}')
def get_base_url(request: Request) -> str: def get_base_url(request: Request) -> str:
@@ -58,11 +62,15 @@ async def upload_page_content_image(request: Request, image: UploadFile=File(...
ext = Path(image.filename).suffix or '.jpg' ext = Path(image.filename).suffix or '.jpg'
filename = f'page-content-{uuid.uuid4()}{ext}' filename = f'page-content-{uuid.uuid4()}{ext}'
file_path = upload_dir / filename file_path = upload_dir / filename
# Use comprehensive file validation (magic bytes + size)
from ..config.settings import settings
from ..utils.file_validation import validate_uploaded_image
max_size = settings.MAX_UPLOAD_SIZE
# Validate file completely (MIME type, size, magic bytes, integrity)
content = await validate_uploaded_image(image, max_size)
async with aiofiles.open(file_path, 'wb') as f: async with aiofiles.open(file_path, 'wb') as f:
content = await image.read()
if not content:
logger.error('Empty file uploaded')
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='File is empty')
await f.write(content) await f.write(content)
logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes') logger.info(f'File saved successfully: {file_path}, size: {len(content)} bytes')
image_url = f'/uploads/page-content/{filename}' image_url = f'/uploads/page-content/{filename}'
@@ -86,12 +94,46 @@ async def get_page_content(page_type: PageType, db: Session=Depends(get_db)):
content_dict = {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None} content_dict = {'id': content.id, 'page_type': content.page_type.value, 'title': content.title, 'subtitle': content.subtitle, 'description': content.description, 'content': content.content, 'meta_title': content.meta_title, 'meta_description': content.meta_description, 'meta_keywords': content.meta_keywords, 'og_title': content.og_title, 'og_description': content.og_description, 'og_image': content.og_image, 'canonical_url': content.canonical_url, 'contact_info': json.loads(content.contact_info) if content.contact_info else None, 'map_url': content.map_url, 'social_links': json.loads(content.social_links) if content.social_links else None, 'footer_links': json.loads(content.footer_links) if content.footer_links else None, 'badges': json.loads(content.badges) if content.badges else None, 'copyright_text': content.copyright_text, 'hero_title': content.hero_title, 'hero_subtitle': content.hero_subtitle, 'hero_image': content.hero_image, 'story_content': content.story_content, 'values': json.loads(content.values) if content.values else None, 'features': json.loads(content.features) if content.features else None, 'about_hero_image': content.about_hero_image, 'mission': content.mission, 'vision': content.vision, 'team': json.loads(content.team) if content.team else None, 'timeline': json.loads(content.timeline) if content.timeline else None, 'achievements': json.loads(content.achievements) if content.achievements else None, 'amenities_section_title': content.amenities_section_title, 'amenities_section_subtitle': content.amenities_section_subtitle, 'amenities': json.loads(content.amenities) if content.amenities else None, 'testimonials_section_title': content.testimonials_section_title, 'testimonials_section_subtitle': content.testimonials_section_subtitle, 'testimonials': json.loads(content.testimonials) if content.testimonials else None, 'gallery_section_title': content.gallery_section_title, 'gallery_section_subtitle': content.gallery_section_subtitle, 'gallery_images': json.loads(content.gallery_images) if content.gallery_images else None, 'luxury_section_title': content.luxury_section_title, 'luxury_section_subtitle': content.luxury_section_subtitle, 'luxury_section_image': content.luxury_section_image, 'luxury_features': json.loads(content.luxury_features) if content.luxury_features else None, 'luxury_gallery_section_title': content.luxury_gallery_section_title, 'luxury_gallery_section_subtitle': content.luxury_gallery_section_subtitle, 'luxury_gallery': json.loads(content.luxury_gallery) if content.luxury_gallery else None, 'luxury_testimonials_section_title': content.luxury_testimonials_section_title, 'luxury_testimonials_section_subtitle': content.luxury_testimonials_section_subtitle, 'luxury_testimonials': json.loads(content.luxury_testimonials) if content.luxury_testimonials else None, 'about_preview_title': content.about_preview_title, 'about_preview_subtitle': content.about_preview_subtitle, 'about_preview_content': content.about_preview_content, 'about_preview_image': content.about_preview_image, 'stats': json.loads(content.stats) if content.stats else None, 'luxury_services_section_title': content.luxury_services_section_title, 'luxury_services_section_subtitle': content.luxury_services_section_subtitle, 'luxury_services': json.loads(content.luxury_services) if content.luxury_services else None, 'luxury_experiences_section_title': content.luxury_experiences_section_title, 'luxury_experiences_section_subtitle': content.luxury_experiences_section_subtitle, 'luxury_experiences': json.loads(content.luxury_experiences) if content.luxury_experiences else None, 'awards_section_title': content.awards_section_title, 'awards_section_subtitle': content.awards_section_subtitle, 'awards': json.loads(content.awards) if content.awards else None, 'cta_title': content.cta_title, 'cta_subtitle': content.cta_subtitle, 'cta_button_text': content.cta_button_text, 'cta_button_link': content.cta_button_link, 'cta_image': content.cta_image, 'partners_section_title': content.partners_section_title, 'partners_section_subtitle': content.partners_section_subtitle, 'partners': json.loads(content.partners) if content.partners else None, 'is_active': content.is_active, 'created_at': content.created_at.isoformat() if content.created_at else None, 'updated_at': content.updated_at.isoformat() if content.updated_at else None}
return {'status': 'success', 'data': {'page_content': content_dict}} return {'status': 'success', 'data': {'page_content': content_dict}}
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching page content: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page content: {str(e)}') raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error fetching page content: {str(e)}')
@router.post('/{page_type}') @router.post('/{page_type}', dependencies=[Depends(authorize_roles('admin'))])
async def create_or_update_page_content(page_type: PageType, title: Optional[str]=None, subtitle: Optional[str]=None, description: Optional[str]=None, content: Optional[str]=None, meta_title: Optional[str]=None, meta_description: Optional[str]=None, meta_keywords: Optional[str]=None, og_title: Optional[str]=None, og_description: Optional[str]=None, og_image: Optional[str]=None, canonical_url: Optional[str]=None, contact_info: Optional[str]=None, map_url: Optional[str]=None, social_links: Optional[str]=None, footer_links: Optional[str]=None, badges: Optional[str]=None, hero_title: Optional[str]=None, hero_subtitle: Optional[str]=None, hero_image: Optional[str]=None, story_content: Optional[str]=None, values: Optional[str]=None, features: Optional[str]=None, about_hero_image: Optional[str]=None, mission: Optional[str]=None, vision: Optional[str]=None, team: Optional[str]=None, timeline: Optional[str]=None, achievements: Optional[str]=None, is_active: bool=True, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_or_update_page_content(
page_type: PageType,
title: Optional[str] = None,
subtitle: Optional[str] = None,
description: Optional[str] = None,
content: Optional[str] = None,
meta_title: Optional[str] = None,
meta_description: Optional[str] = None,
meta_keywords: Optional[str] = None,
og_title: Optional[str] = None,
og_description: Optional[str] = None,
og_image: Optional[str] = None,
canonical_url: Optional[str] = None,
contact_info: Optional[str] = None,
map_url: Optional[str] = None,
social_links: Optional[str] = None,
footer_links: Optional[str] = None,
badges: Optional[str] = None,
hero_title: Optional[str] = None,
hero_subtitle: Optional[str] = None,
hero_image: Optional[str] = None,
story_content: Optional[str] = None,
values: Optional[str] = None,
features: Optional[str] = None,
about_hero_image: Optional[str] = None,
mission: Optional[str] = None,
vision: Optional[str] = None,
team: Optional[str] = None,
timeline: Optional[str] = None,
achievements: Optional[str] = None,
is_active: bool = True,
current_user: User = Depends(authorize_roles('admin')),
db: Session = Depends(get_db)
):
try: try:
authorize_roles(current_user, ['admin'])
if contact_info: if contact_info:
try: try:
json.loads(contact_info) json.loads(contact_info)
@@ -125,13 +167,14 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str
existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if existing_content: if existing_content:
if title is not None: if title is not None:
existing_content.title = title existing_content.title = sanitize_html(title)
if subtitle is not None: if subtitle is not None:
existing_content.subtitle = subtitle existing_content.subtitle = sanitize_html(subtitle)
if description is not None: if description is not None:
existing_content.description = description existing_content.description = sanitize_html(description)
if content is not None: if content is not None:
existing_content.content = content # Sanitize HTML content to prevent XSS
existing_content.content = sanitize_html(content)
if meta_title is not None: if meta_title is not None:
existing_content.meta_title = meta_title existing_content.meta_title = meta_title
if meta_description is not None: if meta_description is not None:
@@ -157,29 +200,30 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str
if badges is not None: if badges is not None:
existing_content.badges = badges existing_content.badges = badges
if hero_title is not None: if hero_title is not None:
existing_content.hero_title = hero_title existing_content.hero_title = sanitize_html(hero_title)
if hero_subtitle is not None: if hero_subtitle is not None:
existing_content.hero_subtitle = hero_subtitle existing_content.hero_subtitle = sanitize_html(hero_subtitle)
if hero_image is not None: if hero_image is not None:
existing_content.hero_image = hero_image existing_content.hero_image = hero_image
if story_content is not None: if story_content is not None:
existing_content.story_content = story_content # Sanitize HTML content to prevent XSS
existing_content.story_content = sanitize_html(story_content)
if values is not None: if values is not None:
existing_content.values = values existing_content.values = sanitize_html(values)
if features is not None: if features is not None:
existing_content.features = features existing_content.features = sanitize_html(features)
if about_hero_image is not None: if about_hero_image is not None:
existing_content.about_hero_image = about_hero_image existing_content.about_hero_image = about_hero_image
if mission is not None: if mission is not None:
existing_content.mission = mission existing_content.mission = sanitize_html(mission)
if vision is not None: if vision is not None:
existing_content.vision = vision existing_content.vision = sanitize_html(vision)
if team is not None: if team is not None:
existing_content.team = team existing_content.team = sanitize_html(team)
if timeline is not None: if timeline is not None:
existing_content.timeline = timeline existing_content.timeline = sanitize_html(timeline)
if achievements is not None: if achievements is not None:
existing_content.achievements = achievements existing_content.achievements = sanitize_html(achievements)
if is_active is not None: if is_active is not None:
existing_content.is_active = is_active existing_content.is_active = is_active
existing_content.updated_at = datetime.utcnow() existing_content.updated_at = datetime.utcnow()
@@ -187,7 +231,39 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str
db.refresh(existing_content) db.refresh(existing_content)
return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None}}} return {'status': 'success', 'message': 'Page content updated successfully', 'data': {'page_content': {'id': existing_content.id, 'page_type': existing_content.page_type.value, 'title': existing_content.title, 'updated_at': existing_content.updated_at.isoformat() if existing_content.updated_at else None}}}
else: else:
new_content = PageContent(page_type=page_type, title=title, subtitle=subtitle, description=description, content=content, meta_title=meta_title, meta_description=meta_description, meta_keywords=meta_keywords, og_title=og_title, og_description=og_description, og_image=og_image, canonical_url=canonical_url, contact_info=contact_info, map_url=map_url, social_links=social_links, footer_links=footer_links, badges=badges, hero_title=hero_title, hero_subtitle=hero_subtitle, hero_image=hero_image, story_content=story_content, values=values, features=features, about_hero_image=about_hero_image, mission=mission, vision=vision, team=team, timeline=timeline, achievements=achievements, is_active=is_active) # Sanitize all HTML content fields before creating new content
new_content = PageContent(
page_type=page_type,
title=sanitize_html(title) if title else None,
subtitle=sanitize_html(subtitle) if subtitle else None,
description=sanitize_html(description) if description else None,
content=sanitize_html(content) if content else None,
meta_title=meta_title,
meta_description=meta_description,
meta_keywords=meta_keywords,
og_title=og_title,
og_description=og_description,
og_image=og_image,
canonical_url=canonical_url,
contact_info=contact_info,
map_url=map_url,
social_links=social_links,
footer_links=footer_links,
badges=badges,
hero_title=sanitize_html(hero_title) if hero_title else None,
hero_subtitle=sanitize_html(hero_subtitle) if hero_subtitle else None,
hero_image=hero_image,
story_content=sanitize_html(story_content) if story_content else None,
values=sanitize_html(values) if values else None,
features=sanitize_html(features) if features else None,
about_hero_image=about_hero_image,
mission=sanitize_html(mission) if mission else None,
vision=sanitize_html(vision) if vision else None,
team=sanitize_html(team) if team else None,
timeline=sanitize_html(timeline) if timeline else None,
achievements=sanitize_html(achievements) if achievements else None,
is_active=is_active
)
db.add(new_content) db.add(new_content)
db.commit() db.commit()
db.refresh(new_content) db.refresh(new_content)
@@ -199,22 +275,22 @@ async def create_or_update_page_content(page_type: PageType, title: Optional[str
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error saving page content: {str(e)}') raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error saving page content: {str(e)}')
@router.put('/{page_type}') @router.put('/{page_type}')
async def update_page_content(page_type: PageType, page_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def update_page_content(page_type: PageType, page_data: PageContentUpdateRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
authorize_roles(current_user, ['admin']) authorize_roles(current_user, ['admin'])
existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first() existing_content = db.query(PageContent).filter(PageContent.page_type == page_type).first()
if not existing_content: if not existing_content:
existing_content = PageContent(page_type=page_type, is_active=True) existing_content = PageContent(page_type=page_type, is_active=True)
db.add(existing_content) db.add(existing_content)
for key, value in page_data.items():
# Convert Pydantic model to dict, excluding None values
update_dict = page_data.model_dump(exclude_unset=True, exclude_none=False)
for key, value in update_dict.items():
if hasattr(existing_content, key): if hasattr(existing_content, key):
# Convert dict/list to JSON string for JSON fields
if key in ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] and value is not None: if key in ['contact_info', 'social_links', 'footer_links', 'badges', 'values', 'features', 'amenities', 'testimonials', 'gallery_images', 'stats', 'luxury_features', 'luxury_gallery', 'luxury_testimonials', 'luxury_services', 'luxury_experiences', 'awards', 'partners', 'team', 'timeline', 'achievements'] and value is not None:
if isinstance(value, str): if isinstance(value, (dict, list)):
try:
json.loads(value)
except json.JSONDecodeError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f'Invalid JSON in {key}')
elif isinstance(value, (dict, list)):
value = json.dumps(value) value = json.dumps(value)
if value is not None: if value is not None:
setattr(existing_content, key, value) setattr(existing_content, key, value)
@@ -227,4 +303,5 @@ async def update_page_content(page_type: PageType, page_data: dict, current_user
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error updating page content: {str(e)}', exc_info=True)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error updating page content: {str(e)}') raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f'Error updating page content: {str(e)}')

View File

@@ -5,6 +5,7 @@ from datetime import datetime
import os import os
from ..config.database import get_db from ..config.database import get_db
from ..config.settings import settings from ..config.settings import settings
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
@@ -18,6 +19,10 @@ from ..services.stripe_service import StripeService
from ..services.paypal_service import PayPalService from ..services.paypal_service import PayPalService
from ..services.borica_service import BoricaService from ..services.borica_service import BoricaService
from ..services.loyalty_service import LoyaltyService from ..services.loyalty_service import LoyaltyService
from ..services.audit_service import audit_service
from ..schemas.payment import CreatePaymentRequest, UpdatePaymentStatusRequest, CreateStripePaymentIntentRequest
logger = get_logger(__name__)
router = APIRouter(prefix='/payments', tags=['payments']) router = APIRouter(prefix='/payments', tags=['payments'])
async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'): async def cancel_booking_on_payment_failure(booking: Booking, db: Session, reason: str='Payment failed or canceled'):
@@ -141,7 +146,11 @@ async def get_payments_by_booking_id(booking_id: int, current_user: User=Depends
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) db.rollback()
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error in get_payments_by_booking_id: {str(e)}', extra={'booking_id': booking_id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching payments')
@router.get('/{id}') @router.get('/{id}')
async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
@@ -159,23 +168,40 @@ async def get_payment_by_id(id: int, current_user: User=Depends(get_current_user
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) db.rollback()
import logging
logger = logging.getLogger(__name__)
logger.error(f'Error in get_payment_by_id: {str(e)}', extra={'payment_id': id}, exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while fetching payment')
@router.post('/') @router.post('/')
async def create_payment(payment_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_payment(
request: Request,
payment_data: CreatePaymentRequest,
current_user: User=Depends(get_current_user),
db: Session=Depends(get_db)
):
"""Create a payment with validated input using Pydantic schema."""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try: try:
booking_id = payment_data.get('booking_id') booking_id = payment_data.booking_id
amount = float(payment_data.get('amount', 0)) amount = payment_data.amount
payment_method = payment_data.get('payment_method', 'cash') payment_method = payment_data.payment_method
payment_type = payment_data.get('payment_type', 'full') payment_type = payment_data.payment_type
mark_as_paid = payment_data.mark_as_paid
notes = payment_data.notes
booking = db.query(Booking).filter(Booking.id == booking_id).first() booking = db.query(Booking).filter(Booking.id == booking_id).first()
if not booking: if not booking:
raise HTTPException(status_code=404, detail='Booking not found') raise HTTPException(status_code=404, detail='Booking not found')
from ..utils.role_helpers import is_admin from ..utils.role_helpers import is_admin
if not is_admin(current_user, db) and booking.user_id != current_user.id: if not is_admin(current_user, db) and booking.user_id != current_user.id:
raise HTTPException(status_code=403, detail='Forbidden') raise HTTPException(status_code=403, detail='Forbidden')
payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if payment_data.get('mark_as_paid') else None, notes=payment_data.get('notes')) payment = Payment(booking_id=booking_id, amount=amount, payment_method=PaymentMethod(payment_method), payment_type=PaymentType(payment_type), payment_status=PaymentStatus.pending, payment_date=datetime.utcnow() if mark_as_paid else None, notes=notes)
if payment_data.get('mark_as_paid'): if mark_as_paid:
payment.payment_status = PaymentStatus.completed payment.payment_status = PaymentStatus.completed
payment.payment_date = datetime.utcnow() payment.payment_date = datetime.utcnow()
db.add(payment) db.add(payment)
@@ -228,20 +254,67 @@ async def create_payment(payment_data: dict, current_user: User=Depends(get_curr
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.error(f'Failed to send payment confirmation email: {e}') logger.error(f'Failed to send payment confirmation email: {e}')
# Log payment transaction
await audit_service.log_action(
db=db,
action='payment_created',
resource_type='payment',
user_id=current_user.id,
resource_id=payment.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={
'booking_id': booking_id,
'amount': float(amount),
'payment_method': payment_method,
'payment_type': payment_type,
'payment_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status),
'transaction_id': payment.transaction_id
},
status='success'
)
return success_response(data={'payment': payment}, message='Payment created successfully') return success_response(data={'payment': payment}, message='Payment created successfully')
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
# Log failed payment creation
await audit_service.log_action(
db=db,
action='payment_creation_failed',
resource_type='payment',
user_id=current_user.id if current_user else None,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={'booking_id': payment_data.booking_id, 'amount': payment_data.amount},
status='failed',
error_message=str(e)
)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))]) @router.put('/{id}/status', dependencies=[Depends(authorize_roles('admin', 'staff', 'accountant'))])
async def update_payment_status(id: int, status_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)): async def update_payment_status(
request: Request,
id: int,
status_data: UpdatePaymentStatusRequest,
current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')),
db: Session=Depends(get_db)
):
"""Update payment status with validated input using Pydantic schema."""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try: try:
payment = db.query(Payment).filter(Payment.id == id).first() payment = db.query(Payment).filter(Payment.id == id).first()
if not payment: if not payment:
raise HTTPException(status_code=404, detail='Payment not found') raise HTTPException(status_code=404, detail='Payment not found')
status_value = status_data.get('status') status_value = status_data.status
notes = status_data.notes
old_status = payment.payment_status old_status = payment.payment_status
if status_value: if status_value:
try: try:
@@ -273,13 +346,35 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}') await cancel_booking_on_payment_failure(booking, db, reason=f'Payment {new_status.value}')
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail='Invalid payment status') raise HTTPException(status_code=400, detail='Invalid payment status')
if status_data.get('transaction_id'):
payment.transaction_id = status_data['transaction_id'] # Update notes if provided
if status_data.get('mark_as_paid'): if notes:
payment.payment_status = PaymentStatus.completed existing_notes = payment.notes or ''
payment.payment_date = datetime.utcnow() payment.notes = f'{existing_notes}\n{notes}'.strip() if existing_notes else notes
db.commit() db.commit()
db.refresh(payment) db.refresh(payment)
# Log payment status update (admin action)
await audit_service.log_action(
db=db,
action='payment_status_updated',
resource_type='payment',
user_id=current_user.id,
resource_id=payment.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={
'payment_id': id,
'old_status': old_status.value if hasattr(old_status, 'value') else str(old_status),
'new_status': payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status),
'booking_id': payment.booking_id,
'amount': float(payment.amount) if payment.amount else 0.0,
'notes': notes
},
status='success'
)
if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed: if payment.payment_status == PaymentStatus.completed and old_status != PaymentStatus.completed:
# Send payment receipt notification # Send payment receipt notification
try: try:
@@ -318,7 +413,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
payment.booking.status = BookingStatus.confirmed payment.booking.status = BookingStatus.confirmed
db.commit() db.commit()
except Exception as e: except Exception as e:
print(f'Failed to send payment confirmation email: {e}') logger.error(f'Failed to send payment confirmation email: {str(e)}', exc_info=True, extra={'payment_id': payment.id if hasattr(payment, 'id') else None})
return success_response(data={'payment': payment}, message='Payment status updated successfully') return success_response(data={'payment': payment}, message='Payment status updated successfully')
except HTTPException: except HTTPException:
raise raise
@@ -327,7 +422,7 @@ async def update_payment_status(id: int, status_data: dict, current_user: User=D
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/stripe/create-intent') @router.post('/stripe/create-intent')
async def create_stripe_payment_intent(intent_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_stripe_payment_intent(intent_data: CreateStripePaymentIntentRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
from ..services.stripe_service import get_stripe_secret_key from ..services.stripe_service import get_stripe_secret_key
secret_key = get_stripe_secret_key(db) secret_key = get_stripe_secret_key(db)
@@ -335,14 +430,12 @@ async def create_stripe_payment_intent(intent_data: dict, current_user: User=Dep
secret_key = settings.STRIPE_SECRET_KEY secret_key = settings.STRIPE_SECRET_KEY
if not secret_key: if not secret_key:
raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.') raise HTTPException(status_code=500, detail='Stripe is not configured. Please configure Stripe settings in Admin Panel or set STRIPE_SECRET_KEY environment variable.')
booking_id = intent_data.get('booking_id') booking_id = intent_data.booking_id
amount = float(intent_data.get('amount', 0)) amount = intent_data.amount
currency = intent_data.get('currency', 'usd') currency = intent_data.currency or 'usd'
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}') logger.info(f'Creating Stripe payment intent - Booking ID: {booking_id}, Amount: ${amount:,.2f}, Currency: {currency}')
if not booking_id or amount <= 0:
raise HTTPException(status_code=400, detail='booking_id and amount are required')
if amount > 999999.99: if amount > 999999.99:
logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99") logger.error(f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99")
raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.") raise HTTPException(status_code=400, detail=f"Amount ${amount:,.2f} exceeds Stripe's maximum of $999,999.99. Please contact support for large payments.")

View File

@@ -4,9 +4,17 @@ from sqlalchemy import or_
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.promotion import Promotion, DiscountType from ..models.promotion import Promotion, DiscountType
from ..schemas.promotion import (
ValidatePromotionRequest,
CreatePromotionRequest,
UpdatePromotionRequest
)
logger = get_logger(__name__)
router = APIRouter(prefix='/promotions', tags=['promotions']) router = APIRouter(prefix='/promotions', tags=['promotions'])
@router.get('/') @router.get('/')
@@ -32,6 +40,8 @@ async def get_promotions(search: Optional[str]=Query(None), status_filter: Optio
result.append(promo_dict) 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: except Exception as e:
db.rollback()
logger.error(f'Error fetching promotions: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/{code}') @router.get('/{code}')
@@ -45,13 +55,15 @@ async def get_promotion_by_code(code: str, db: Session=Depends(get_db)):
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching promotion by code: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/validate') @router.post('/validate')
async def validate_promotion(validation_data: dict, db: Session=Depends(get_db)): async def validate_promotion(validation_data: ValidatePromotionRequest, db: Session=Depends(get_db)):
try: try:
code = validation_data.get('code') code = validation_data.code
booking_amount = float(validation_data.get('booking_value') or validation_data.get('booking_amount', 0)) booking_amount = float(validation_data.booking_value or validation_data.booking_amount or 0)
promotion = db.query(Promotion).filter(Promotion.code == code).first() promotion = db.query(Promotion).filter(Promotion.code == code).first()
if not promotion: if not promotion:
raise HTTPException(status_code=404, detail='Promotion code not found') raise HTTPException(status_code=404, detail='Promotion code not found')
@@ -72,20 +84,33 @@ async def validate_promotion(validation_data: dict, db: Session=Depends(get_db))
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error validating promotion: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) @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)): async def create_promotion(promotion_data: CreatePromotionRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try: try:
code = promotion_data.get('code') code = promotion_data.code
existing = db.query(Promotion).filter(Promotion.code == code).first() existing = db.query(Promotion).filter(Promotion.code == code).first()
if existing: if existing:
raise HTTPException(status_code=400, detail='Promotion code already exists') raise HTTPException(status_code=400, detail='Promotion code already exists')
discount_type = promotion_data.get('discount_type') discount_type = promotion_data.discount_type
discount_value = float(promotion_data.get('discount_value', 0)) discount_value = promotion_data.discount_value
if discount_type == 'percentage' and discount_value > 100: promotion = Promotion(
raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') code=code,
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) name=promotion_data.name,
description=promotion_data.description,
discount_type=DiscountType(discount_type),
discount_value=discount_value,
min_booking_amount=promotion_data.min_booking_amount,
max_discount_amount=promotion_data.max_discount_amount,
start_date=datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None,
end_date=datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None,
usage_limit=promotion_data.usage_limit,
used_count=0,
is_active=promotion_data.status == 'active' if promotion_data.status else True
)
db.add(promotion) db.add(promotion)
db.commit() db.commit()
db.refresh(promotion) db.refresh(promotion)
@@ -94,47 +119,46 @@ async def create_promotion(promotion_data: dict, current_user: User=Depends(auth
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error creating promotion: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @router.put('/{id}', dependencies=[Depends(authorize_roles('admin'))])
async def update_promotion(id: int, promotion_data: dict, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): async def update_promotion(id: int, promotion_data: UpdatePromotionRequest, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)):
try: try:
promotion = db.query(Promotion).filter(Promotion.id == id).first() promotion = db.query(Promotion).filter(Promotion.id == id).first()
if not promotion: if not promotion:
raise HTTPException(status_code=404, detail='Promotion not found') raise HTTPException(status_code=404, detail='Promotion not found')
code = promotion_data.get('code') code = promotion_data.code
if code and code != promotion.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: if existing:
raise HTTPException(status_code=400, detail='Promotion code already exists') 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_type = promotion_data.discount_type or (promotion.discount_type.value if isinstance(promotion.discount_type, DiscountType) else promotion.discount_type)
discount_value = promotion_data.get('discount_value') discount_value = promotion_data.discount_value
if discount_value is not None: if discount_value is not None and discount_type == 'percentage' and discount_value > 100:
discount_value = float(discount_value)
if discount_type == 'percentage' and discount_value > 100:
raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%') raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%')
if 'code' in promotion_data: if promotion_data.code is not None:
promotion.code = promotion_data['code'] promotion.code = promotion_data.code
if 'name' in promotion_data: if promotion_data.name is not None:
promotion.name = promotion_data['name'] promotion.name = promotion_data.name
if 'description' in promotion_data: if promotion_data.description is not None:
promotion.description = promotion_data['description'] promotion.description = promotion_data.description
if 'discount_type' in promotion_data: if promotion_data.discount_type is not None:
promotion.discount_type = DiscountType(promotion_data['discount_type']) promotion.discount_type = DiscountType(promotion_data.discount_type)
if 'discount_value' in promotion_data: if promotion_data.discount_value is not None:
promotion.discount_value = discount_value promotion.discount_value = promotion_data.discount_value
if 'min_booking_amount' in promotion_data: if promotion_data.min_booking_amount is not None:
promotion.min_booking_amount = float(promotion_data['min_booking_amount']) if promotion_data['min_booking_amount'] else None promotion.min_booking_amount = promotion_data.min_booking_amount
if 'max_discount_amount' in promotion_data: if promotion_data.max_discount_amount is not None:
promotion.max_discount_amount = float(promotion_data['max_discount_amount']) if promotion_data['max_discount_amount'] else None promotion.max_discount_amount = promotion_data.max_discount_amount
if 'start_date' in promotion_data: if promotion_data.start_date is not None:
promotion.start_date = datetime.fromisoformat(promotion_data['start_date'].replace('Z', '+00:00')) if promotion_data['start_date'] else None 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: if promotion_data.end_date is not None:
promotion.end_date = datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data['end_date'] else None 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: if promotion_data.usage_limit is not None:
promotion.usage_limit = promotion_data['usage_limit'] promotion.usage_limit = promotion_data.usage_limit
if 'status' in promotion_data: if promotion_data.status is not None:
promotion.is_active = promotion_data['status'] == 'active' promotion.is_active = promotion_data.status == 'active'
db.commit() db.commit()
db.refresh(promotion) db.refresh(promotion)
return {'status': 'success', 'message': 'Promotion updated successfully', 'data': {'promotion': promotion}} return {'status': 'success', 'message': 'Promotion updated successfully', 'data': {'promotion': promotion}}
@@ -142,6 +166,7 @@ async def update_promotion(id: int, promotion_data: dict, current_user: User=Dep
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error updating promotion: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
@@ -157,4 +182,5 @@ async def delete_promotion(id: int, current_user: User=Depends(authorize_roles('
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error deleting promotion: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -1,25 +1,51 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query
from ..utils.response_helpers import success_response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import Optional from typing import Optional
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.review import Review, ReviewStatus from ..models.review import Review, ReviewStatus
from ..models.room import Room from ..models.room import Room
from ..schemas.review import CreateReviewRequest
logger = get_logger(__name__)
router = APIRouter(prefix='/reviews', tags=['reviews']) router = APIRouter(prefix='/reviews', tags=['reviews'])
@router.get('/room/{room_id}') @router.get('/room/{room_id}')
async def get_room_reviews(room_id: int, db: Session=Depends(get_db)): async def get_room_reviews(
room_id: int,
page: int = Query(1, ge=1),
limit: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db)
):
try: try:
reviews = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved).order_by(Review.created_at.desc()).all() query = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved)
total = query.count()
offset = (page - 1) * limit
reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all()
result = [] result = []
for review in reviews: 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: 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) result.append(review_dict)
return {'status': 'success', 'data': {'reviews': result}} return {
'status': 'success',
'data': {
'reviews': result,
'pagination': {
'total': total,
'page': page,
'limit': limit,
'totalPages': (total + limit - 1) // limit
}
}
}
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching room reviews: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @router.get('/', dependencies=[Depends(authorize_roles('admin'))])
@@ -44,14 +70,16 @@ async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status
result.append(review_dict) 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: except Exception as e:
db.rollback()
logger.error(f'Error fetching all reviews: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/') @router.post('/')
async def create_review(review_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def create_review(review_data: CreateReviewRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
try: try:
room_id = review_data.get('room_id') room_id = review_data.room_id
rating = review_data.get('rating') rating = review_data.rating
comment = review_data.get('comment') comment = review_data.comment
room = db.query(Room).filter(Room.id == room_id).first() room = db.query(Room).filter(Room.id == room_id).first()
if not room: if not room:
raise HTTPException(status_code=404, detail='Room not found') raise HTTPException(status_code=404, detail='Room not found')
@@ -67,6 +95,7 @@ async def create_review(review_data: dict, current_user: User=Depends(get_curren
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error creating review: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))]) @router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))])
@@ -83,6 +112,7 @@ async def approve_review(id: int, current_user: User=Depends(authorize_roles('ad
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error approving review: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))]) @router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))])
@@ -99,6 +129,7 @@ async def reject_review(id: int, current_user: User=Depends(authorize_roles('adm
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error rejecting review: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))])
@@ -114,4 +145,5 @@ async def delete_review(id: int, current_user: User=Depends(authorize_roles('adm
raise raise
except Exception as e: except Exception as e:
db.rollback() db.rollback()
logger.error(f'Error deleting review: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -4,6 +4,7 @@ from sqlalchemy import and_, or_, func
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user, authorize_roles from ..middleware.auth import get_current_user, authorize_roles
from ..models.user import User from ..models.user import User
from ..models.room import Room, RoomStatus from ..models.room import Room, RoomStatus
@@ -14,6 +15,8 @@ from ..services.room_service import get_rooms_with_ratings, get_amenities_list,
import os import os
import aiofiles import aiofiles
from pathlib import Path from pathlib import Path
logger = get_logger(__name__)
router = APIRouter(prefix='/rooms', tags=['rooms']) router = APIRouter(prefix='/rooms', tags=['rooms'])
@router.get('/') @router.get('/')
@@ -54,6 +57,7 @@ async def get_rooms(request: Request, type: Optional[str]=Query(None), minPrice:
rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url) rooms_with_ratings = await get_rooms_with_ratings(db, rooms, base_url)
return {'status': 'success', 'data': {'rooms': rooms_with_ratings, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} return {'status': 'success', 'data': {'rooms': rooms_with_ratings, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}}
except Exception as e: except Exception as e:
logger.error(f'Error fetching rooms: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/amenities') @router.get('/amenities')
@@ -62,6 +66,7 @@ async def get_amenities(db: Session=Depends(get_db)):
amenities = await get_amenities_list(db) amenities = await get_amenities_list(db)
return {'status': 'success', 'data': {'amenities': amenities}} return {'status': 'success', 'data': {'amenities': amenities}}
except Exception as e: except Exception as e:
logger.error(f'Error fetching amenities: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/available') @router.get('/available')
@@ -159,6 +164,7 @@ async def search_available_rooms(request: Request, from_date: str=Query(..., ali
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
except Exception as e: except Exception as e:
logger.error(f'Error searching available rooms: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/id/{id}') @router.get('/id/{id}')
@@ -364,6 +370,8 @@ async def upload_room_images(id: int, images: List[UploadFile]=File(...), curren
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error uploading room images: {str(e)}', exc_info=True, extra={'room_id': id})
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete('/{id}/images', dependencies=[Depends(authorize_roles('admin', 'staff'))]) @router.delete('/{id}/images', dependencies=[Depends(authorize_roles('admin', 'staff'))])
@@ -421,6 +429,7 @@ async def get_room_booked_dates(id: int, db: Session=Depends(get_db)):
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f'Error fetching booked dates: {str(e)}', exc_info=True, extra={'room_id': id})
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get('/{id}/reviews') @router.get('/{id}/reviews')
@@ -441,4 +450,5 @@ async def get_room_reviews_route(id: int, db: Session=Depends(get_db)):
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
logger.error(f'Error fetching room reviews: {str(e)}', exc_info=True, extra={'room_id': id})
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@@ -5,6 +5,7 @@ from datetime import datetime
import random import random
from ..config.database import get_db from ..config.database import get_db
from ..config.logging_config import get_logger
from ..middleware.auth import get_current_user from ..middleware.auth import get_current_user
from ..models.user import User from ..models.user import User
from ..utils.role_helpers import is_admin from ..utils.role_helpers import is_admin
@@ -19,6 +20,13 @@ from ..models.service_booking import (
) )
from ..services.stripe_service import StripeService, get_stripe_secret_key, get_stripe_publishable_key from ..services.stripe_service import StripeService, get_stripe_secret_key, get_stripe_publishable_key
from ..config.settings import settings from ..config.settings import settings
from ..schemas.service_booking import (
CreateServiceBookingRequest,
CreateServicePaymentIntentRequest,
ConfirmServicePaymentRequest
)
logger = get_logger(__name__)
router = APIRouter(prefix="/service-bookings", tags=["service-bookings"]) router = APIRouter(prefix="/service-bookings", tags=["service-bookings"])
@@ -30,14 +38,14 @@ def generate_service_booking_number() -> str:
@router.post("/") @router.post("/")
async def create_service_booking( async def create_service_booking(
booking_data: dict, booking_data: CreateServiceBookingRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
try: try:
services = booking_data.get("services", []) services = booking_data.services
total_amount = float(booking_data.get("total_amount", 0)) total_amount = booking_data.total_amount
notes = booking_data.get("notes") notes = booking_data.notes
if not services or len(services) == 0: if not services or len(services) == 0:
raise HTTPException(status_code=400, detail="At least one service is required") raise HTTPException(status_code=400, detail="At least one service is required")
@@ -50,8 +58,8 @@ async def create_service_booking(
service_items_data = [] service_items_data = []
for service_item in services: for service_item in services:
service_id = service_item.get("service_id") service_id = service_item.service_id
quantity = service_item.get("quantity", 1) quantity = service_item.quantity
if not service_id: if not service_id:
raise HTTPException(status_code=400, detail="Service ID is required for each item") raise HTTPException(status_code=400, detail="Service ID is required for each item")
@@ -197,6 +205,8 @@ async def get_my_service_bookings(
"data": {"service_bookings": result} "data": {"service_bookings": result}
} }
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching service bookings: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/{id}") @router.get("/{id}")
@@ -249,12 +259,14 @@ async def get_service_booking_by_id(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error fetching service booking by id: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/payment/stripe/create-intent") @router.post("/{id}/payment/stripe/create-intent")
async def create_service_stripe_payment_intent( async def create_service_stripe_payment_intent(
id: int, id: int,
intent_data: dict, intent_data: CreateServicePaymentIntentRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
@@ -270,8 +282,8 @@ async def create_service_stripe_payment_intent(
detail="Stripe is not configured. Please configure Stripe settings in Admin Panel." detail="Stripe is not configured. Please configure Stripe settings in Admin Panel."
) )
amount = float(intent_data.get("amount", 0)) amount = intent_data.amount
currency = intent_data.get("currency", "usd") currency = intent_data.currency
if amount <= 0: if amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be greater than 0") raise HTTPException(status_code=400, detail="Amount must be greater than 0")
@@ -320,17 +332,19 @@ async def create_service_stripe_payment_intent(
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
db.rollback()
logger.error(f'Error creating service payment intent: {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/{id}/payment/stripe/confirm") @router.post("/{id}/payment/stripe/confirm")
async def confirm_service_stripe_payment( async def confirm_service_stripe_payment(
id: int, id: int,
payment_data: dict, payment_data: ConfirmServicePaymentRequest,
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
try: try:
payment_intent_id = payment_data.get("payment_intent_id") payment_intent_id = payment_data.payment_intent_id
if not payment_intent_id: if not payment_intent_id:
raise HTTPException(status_code=400, detail="payment_intent_id is required") raise HTTPException(status_code=400, detail="payment_intent_id is required")

View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import or_ from sqlalchemy import or_
from typing import Optional from typing import Optional
@@ -10,6 +10,8 @@ from ..models.role import Role
from ..models.booking import Booking, BookingStatus from ..models.booking import Booking, BookingStatus
from ..utils.role_helpers import can_manage_users from ..utils.role_helpers import can_manage_users
from ..utils.response_helpers import success_response from ..utils.response_helpers import success_response
from ..services.audit_service import audit_service
from ..schemas.user import CreateUserRequest, UpdateUserRequest
router = APIRouter(prefix='/users', tags=['users']) router = APIRouter(prefix='/users', tags=['users'])
@router.get('/', dependencies=[Depends(authorize_roles('admin'))]) @router.get('/', dependencies=[Depends(authorize_roles('admin'))])
@@ -51,26 +53,53 @@ async def get_user_by_id(id: int, current_user: User=Depends(authorize_roles('ad
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post('/', dependencies=[Depends(authorize_roles('admin'))]) @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)): async def create_user(
request: Request,
user_data: CreateUserRequest,
current_user: User=Depends(authorize_roles('admin')),
db: Session=Depends(get_db)
):
"""Create a user with validated input using Pydantic schema."""
client_ip = request.client.host if request.client else None
user_agent = request.headers.get('User-Agent')
request_id = getattr(request.state, 'request_id', None)
try: try:
email = user_data.get('email') email = user_data.email
password = user_data.get('password') password = user_data.password
full_name = user_data.get('full_name') full_name = user_data.full_name
phone_number = user_data.get('phone_number') phone_number = user_data.phone_number
role = user_data.get('role', 'customer') role_id = user_data.role_id or 3 # Default to customer role
status = user_data.get('status', 'active')
role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4}
role_id = role_map.get(role, 3)
existing = db.query(User).filter(User.email == email).first() existing = db.query(User).filter(User.email == email).first()
if existing: if existing:
raise HTTPException(status_code=400, detail='Email already exists') raise HTTPException(status_code=400, detail='Email already exists')
password_bytes = password.encode('utf-8') password_bytes = password.encode('utf-8')
salt = bcrypt.gensalt() salt = bcrypt.gensalt()
hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8') hashed_password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
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=True)
db.add(user) db.add(user)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
# Log admin action - user creation
await audit_service.log_action(
db=db,
action='admin_user_created',
resource_type='user',
user_id=current_user.id,
resource_id=user.id,
ip_address=client_ip,
user_agent=user_agent,
request_id=request_id,
details={
'created_user_email': user.email,
'created_user_name': user.full_name,
'role_id': user.role_id,
'is_active': user.is_active
},
status='success'
)
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active} user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}
return success_response(data={'user': user_dict}, message='User created successfully') return success_response(data={'user': user_dict}, message='User created successfully')
except HTTPException: except HTTPException:
@@ -80,37 +109,32 @@ async def create_user(user_data: dict, current_user: User=Depends(authorize_role
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.put('/{id}') @router.put('/{id}')
async def update_user(id: int, user_data: dict, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)): async def update_user(id: int, user_data: UpdateUserRequest, current_user: User=Depends(get_current_user), db: Session=Depends(get_db)):
"""Update a user with validated input using Pydantic schema."""
try: try:
if not can_manage_users(current_user, db) and current_user.id != id: if not can_manage_users(current_user, db) and current_user.id != id:
raise HTTPException(status_code=403, detail='Forbidden') raise HTTPException(status_code=403, detail='Forbidden')
user = db.query(User).filter(User.id == id).first() user = db.query(User).filter(User.id == id).first()
if not user: if not user:
raise HTTPException(status_code=404, detail='User not found') raise HTTPException(status_code=404, detail='User not found')
email = user_data.get('email')
if email and email != user.email: # Check email uniqueness if being updated
existing = db.query(User).filter(User.email == email).first() if user_data.email and user_data.email != user.email:
existing = db.query(User).filter(User.email == user_data.email).first()
if existing: if existing:
raise HTTPException(status_code=400, detail='Email already exists') raise HTTPException(status_code=400, detail='Email already exists')
role_map = {'admin': 1, 'staff': 2, 'customer': 3, 'accountant': 4}
if 'full_name' in user_data: # Update fields if provided
user.full_name = user_data['full_name'] if user_data.full_name is not None:
if 'email' in user_data and can_manage_users(current_user, db): user.full_name = user_data.full_name
user.email = user_data['email'] if user_data.email is not None and can_manage_users(current_user, db):
if 'phone_number' in user_data: user.email = user_data.email
user.phone = user_data['phone_number'] if user_data.phone_number is not None:
if 'role' in user_data and can_manage_users(current_user, db): user.phone = user_data.phone_number
user.role_id = role_map.get(user_data['role'], 3) if user_data.role_id is not None and can_manage_users(current_user, db):
if 'status' in user_data and can_manage_users(current_user, db): user.role_id = user_data.role_id
user.is_active = user_data['status'] == 'active' if user_data.is_active is not None and can_manage_users(current_user, db):
if 'currency' in user_data: user.is_active = user_data.is_active
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')
salt = bcrypt.gensalt()
user.password = bcrypt.hashpw(password_bytes, salt).decode('utf-8')
db.commit() db.commit()
db.refresh(user) db.refresh(user)
user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active} user_dict = {'id': user.id, 'email': user.email, 'full_name': user.full_name, 'phone': user.phone, 'phone_number': user.phone, 'currency': getattr(user, 'currency', 'VND'), 'role_id': user.role_id, 'is_active': user.is_active}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,167 @@
"""
Pydantic schemas for booking-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, List
from datetime import datetime
class ServiceItemSchema(BaseModel):
"""Schema for service items in a booking."""
service_id: int = Field(..., gt=0, description="Service ID")
quantity: int = Field(1, gt=0, le=100, description="Quantity of service")
class InvoiceInfoSchema(BaseModel):
"""Schema for invoice information."""
company_name: Optional[str] = Field(None, max_length=200)
company_address: Optional[str] = Field(None, max_length=500)
company_tax_id: Optional[str] = Field(None, max_length=50)
customer_tax_id: Optional[str] = Field(None, max_length=50)
notes: Optional[str] = Field(None, max_length=1000)
terms_and_conditions: Optional[str] = None
payment_instructions: Optional[str] = None
class CreateBookingRequest(BaseModel):
"""Schema for creating a booking."""
room_id: int = Field(..., gt=0, description="Room ID")
check_in_date: str = Field(..., description="Check-in date (YYYY-MM-DD or ISO format)")
check_out_date: str = Field(..., description="Check-out date (YYYY-MM-DD or ISO format)")
total_price: float = Field(..., gt=0, description="Total booking price")
guest_count: int = Field(1, gt=0, le=20, description="Number of guests")
notes: Optional[str] = Field(None, max_length=1000, description="Special requests/notes")
payment_method: str = Field("cash", description="Payment method (cash, stripe, paypal)")
promotion_code: Optional[str] = Field(None, max_length=50)
referral_code: Optional[str] = Field(None, max_length=50)
services: Optional[List[ServiceItemSchema]] = Field(default_factory=list)
invoice_info: Optional[InvoiceInfoSchema] = None
@field_validator('check_in_date', 'check_out_date')
@classmethod
def validate_date_format(cls, v: str) -> str:
"""Validate date format."""
try:
# Try ISO format first
if 'T' in v or 'Z' in v or '+' in v:
datetime.fromisoformat(v.replace('Z', '+00:00'))
else:
# Try simple date format
datetime.strptime(v, '%Y-%m-%d')
return v
except (ValueError, TypeError):
raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.')
@field_validator('payment_method')
@classmethod
def validate_payment_method(cls, v: str) -> str:
"""Validate payment method."""
allowed_methods = ['cash', 'stripe', 'paypal', 'borica']
if v not in allowed_methods:
raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}')
return v
@model_validator(mode='after')
def validate_dates(self):
"""Validate that check-out is after check-in."""
check_in = self.check_in_date
check_out = self.check_out_date
if check_in and check_out:
try:
# Parse dates
if 'T' in check_in or 'Z' in check_in or '+' in check_in:
check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00'))
else:
check_in_dt = datetime.strptime(check_in, '%Y-%m-%d')
if 'T' in check_out or 'Z' in check_out or '+' in check_out:
check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00'))
else:
check_out_dt = datetime.strptime(check_out, '%Y-%m-%d')
if check_in_dt >= check_out_dt:
raise ValueError('Check-out date must be after check-in date')
except (ValueError, TypeError) as e:
if 'Check-out date' in str(e):
raise
raise ValueError('Invalid date format')
return self
model_config = {
"json_schema_extra": {
"example": {
"room_id": 1,
"check_in_date": "2024-12-25",
"check_out_date": "2024-12-30",
"total_price": 500.00,
"guest_count": 2,
"payment_method": "cash",
"notes": "Late check-in requested"
}
}
}
class UpdateBookingRequest(BaseModel):
"""Schema for updating a booking."""
status: Optional[str] = Field(None, description="Booking status")
check_in_date: Optional[str] = Field(None, description="Check-in date")
check_out_date: Optional[str] = Field(None, description="Check-out date")
guest_count: Optional[int] = Field(None, gt=0, le=20)
notes: Optional[str] = Field(None, max_length=1000)
total_price: Optional[float] = Field(None, gt=0)
@field_validator('check_in_date', 'check_out_date')
@classmethod
def validate_date_format(cls, v: Optional[str]) -> Optional[str]:
"""Validate date format if provided."""
if v:
try:
if 'T' in v or 'Z' in v or '+' in v:
datetime.fromisoformat(v.replace('Z', '+00:00'))
else:
datetime.strptime(v, '%Y-%m-%d')
return v
except (ValueError, TypeError):
raise ValueError('Invalid date format. Use YYYY-MM-DD or ISO format.')
return v
@field_validator('status')
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""Validate booking status."""
if v:
allowed_statuses = ['pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled']
if v not in allowed_statuses:
raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}')
return v
@model_validator(mode='after')
def validate_dates(self):
"""Validate dates if both are provided."""
check_in = self.check_in_date
check_out = self.check_out_date
if check_in and check_out:
try:
if 'T' in check_in or 'Z' in check_in or '+' in check_in:
check_in_dt = datetime.fromisoformat(check_in.replace('Z', '+00:00'))
else:
check_in_dt = datetime.strptime(check_in, '%Y-%m-%d')
if 'T' in check_out or 'Z' in check_out or '+' in check_out:
check_out_dt = datetime.fromisoformat(check_out.replace('Z', '+00:00'))
else:
check_out_dt = datetime.strptime(check_out, '%Y-%m-%d')
if check_in_dt >= check_out_dt:
raise ValueError('Check-out date must be after check-in date')
except (ValueError, TypeError) as e:
if 'Check-out date' in str(e):
raise
raise ValueError('Invalid date format')
return self

View File

@@ -0,0 +1,77 @@
"""
Pydantic schemas for invoice-related requests and responses.
"""
from pydantic import BaseModel, Field
from typing import Optional
class CreateInvoiceRequest(BaseModel):
"""Schema for creating an invoice."""
booking_id: int = Field(..., gt=0, description="Booking ID")
tax_rate: float = Field(0.0, ge=0, le=100, description="Tax rate percentage")
discount_amount: float = Field(0.0, ge=0, description="Discount amount")
due_days: int = Field(30, ge=1, le=365, description="Number of days until due")
company_name: Optional[str] = Field(None, max_length=200)
company_address: Optional[str] = Field(None, max_length=500)
company_phone: Optional[str] = Field(None, max_length=50)
company_email: Optional[str] = Field(None, max_length=255)
company_tax_id: Optional[str] = Field(None, max_length=50)
company_logo_url: Optional[str] = Field(None, max_length=500)
customer_tax_id: Optional[str] = Field(None, max_length=50)
notes: Optional[str] = Field(None, max_length=1000)
terms_and_conditions: Optional[str] = None
payment_instructions: Optional[str] = None
model_config = {
"json_schema_extra": {
"example": {
"booking_id": 1,
"tax_rate": 10.0,
"discount_amount": 0.0,
"due_days": 30,
"company_name": "Hotel Name",
"company_address": "123 Main St",
"notes": "Payment due within 30 days"
}
}
}
class UpdateInvoiceRequest(BaseModel):
"""Schema for updating an invoice."""
company_name: Optional[str] = Field(None, max_length=200)
company_address: Optional[str] = Field(None, max_length=500)
company_phone: Optional[str] = Field(None, max_length=50)
company_email: Optional[str] = Field(None, max_length=255)
company_tax_id: Optional[str] = Field(None, max_length=50)
company_logo_url: Optional[str] = Field(None, max_length=500)
customer_tax_id: Optional[str] = Field(None, max_length=50)
notes: Optional[str] = Field(None, max_length=1000)
terms_and_conditions: Optional[str] = None
payment_instructions: Optional[str] = None
tax_rate: Optional[float] = Field(None, ge=0, le=100)
discount_amount: Optional[float] = Field(None, ge=0)
status: Optional[str] = None
model_config = {
"json_schema_extra": {
"example": {
"notes": "Updated notes",
"status": "paid"
}
}
}
class MarkInvoicePaidRequest(BaseModel):
"""Schema for marking an invoice as paid."""
amount: Optional[float] = Field(None, gt=0, description="Payment amount (optional, defaults to full amount)")
model_config = {
"json_schema_extra": {
"example": {
"amount": 500.00
}
}
}

View File

@@ -0,0 +1,110 @@
"""
Pydantic schemas for page content-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional, Dict, Any, List, Union
import json
class PageContentUpdateRequest(BaseModel):
"""Schema for updating page content."""
title: Optional[str] = Field(None, max_length=500)
subtitle: Optional[str] = Field(None, max_length=1000)
description: Optional[str] = Field(None, max_length=5000)
content: Optional[str] = None
meta_title: Optional[str] = Field(None, max_length=200)
meta_description: Optional[str] = Field(None, max_length=500)
meta_keywords: Optional[str] = Field(None, max_length=500)
og_title: Optional[str] = Field(None, max_length=200)
og_description: Optional[str] = Field(None, max_length=500)
og_image: Optional[str] = Field(None, max_length=1000)
canonical_url: Optional[str] = Field(None, max_length=1000)
contact_info: Optional[Union[str, Dict[str, Any]]] = None
map_url: Optional[str] = Field(None, max_length=1000)
social_links: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] = None
footer_links: Optional[Union[str, Dict[str, Any], List[Dict[str, Any]]]] = None
badges: Optional[Union[str, List[Dict[str, Any]]]] = None
hero_title: Optional[str] = Field(None, max_length=500)
hero_subtitle: Optional[str] = Field(None, max_length=1000)
hero_image: Optional[str] = Field(None, max_length=1000)
story_content: Optional[str] = None
values: Optional[Union[str, List[Dict[str, Any]]]] = None
features: Optional[Union[str, List[Dict[str, Any]]]] = None
about_hero_image: Optional[str] = Field(None, max_length=1000)
mission: Optional[str] = Field(None, max_length=2000)
vision: Optional[str] = Field(None, max_length=2000)
team: Optional[Union[str, List[Dict[str, Any]]]] = None
timeline: Optional[Union[str, List[Dict[str, Any]]]] = None
achievements: Optional[Union[str, List[Dict[str, Any]]]] = None
amenities_section_title: Optional[str] = Field(None, max_length=500)
amenities_section_subtitle: Optional[str] = Field(None, max_length=1000)
amenities: Optional[Union[str, List[Dict[str, Any]]]] = None
testimonials_section_title: Optional[str] = Field(None, max_length=500)
testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000)
testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None
gallery_section_title: Optional[str] = Field(None, max_length=500)
gallery_section_subtitle: Optional[str] = Field(None, max_length=1000)
gallery_images: Optional[Union[str, List[str]]] = None
luxury_section_title: Optional[str] = Field(None, max_length=500)
luxury_section_subtitle: Optional[str] = Field(None, max_length=1000)
luxury_section_image: Optional[str] = Field(None, max_length=1000)
luxury_features: Optional[Union[str, List[Dict[str, Any]]]] = None
luxury_gallery_section_title: Optional[str] = Field(None, max_length=500)
luxury_gallery_section_subtitle: Optional[str] = Field(None, max_length=1000)
luxury_gallery: Optional[Union[str, List[Dict[str, Any]]]] = None
luxury_testimonials_section_title: Optional[str] = Field(None, max_length=500)
luxury_testimonials_section_subtitle: Optional[str] = Field(None, max_length=1000)
luxury_testimonials: Optional[Union[str, List[Dict[str, Any]]]] = None
about_preview_title: Optional[str] = Field(None, max_length=500)
about_preview_subtitle: Optional[str] = Field(None, max_length=1000)
about_preview_content: Optional[str] = None
about_preview_image: Optional[str] = Field(None, max_length=1000)
stats: Optional[Union[str, List[Dict[str, Any]]]] = None
luxury_services_section_title: Optional[str] = Field(None, max_length=500)
luxury_services_section_subtitle: Optional[str] = Field(None, max_length=1000)
luxury_services: Optional[Union[str, List[Dict[str, Any]]]] = None
luxury_experiences_section_title: Optional[str] = Field(None, max_length=500)
luxury_experiences_section_subtitle: Optional[str] = Field(None, max_length=1000)
luxury_experiences: Optional[Union[str, List[Dict[str, Any]]]] = None
awards_section_title: Optional[str] = Field(None, max_length=500)
awards_section_subtitle: Optional[str] = Field(None, max_length=1000)
awards: Optional[Union[str, List[Dict[str, Any]]]] = None
cta_title: Optional[str] = Field(None, max_length=500)
cta_subtitle: Optional[str] = Field(None, max_length=1000)
cta_button_text: Optional[str] = Field(None, max_length=200)
cta_button_link: Optional[str] = Field(None, max_length=1000)
cta_image: Optional[str] = Field(None, max_length=1000)
partners_section_title: Optional[str] = Field(None, max_length=500)
partners_section_subtitle: Optional[str] = Field(None, max_length=1000)
partners: Optional[Union[str, List[Dict[str, Any]]]] = None
copyright_text: Optional[str] = Field(None, max_length=500)
is_active: Optional[bool] = True
@field_validator('contact_info', 'social_links', 'footer_links', 'badges', 'values',
'features', 'amenities', 'testimonials', 'gallery_images', 'stats',
'luxury_features', 'luxury_gallery', 'luxury_testimonials',
'luxury_services', 'luxury_experiences', 'awards', 'partners',
'team', 'timeline', 'achievements', mode='before')
@classmethod
def validate_json_fields(cls, v):
"""Validate and parse JSON string fields."""
if v is None:
return None
if isinstance(v, str):
try:
return json.loads(v)
except json.JSONDecodeError:
raise ValueError(f'Invalid JSON format: {v}')
return v
model_config = {
"json_schema_extra": {
"example": {
"title": "Welcome to Our Hotel",
"subtitle": "Experience luxury like never before",
"description": "A beautiful hotel description",
"is_active": True
}
}
}

View File

@@ -0,0 +1,55 @@
"""
Pydantic schemas for payment-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional
class CreatePaymentRequest(BaseModel):
"""Schema for creating a payment."""
booking_id: int = Field(..., gt=0, description="Booking ID")
amount: float = Field(..., gt=0, le=999999.99, description="Payment amount")
payment_method: str = Field(..., description="Payment method")
payment_type: str = Field("full", description="Payment type (full, deposit)")
mark_as_paid: Optional[bool] = Field(False, description="Mark payment as completed immediately")
notes: Optional[str] = Field(None, max_length=1000, description="Payment notes")
@field_validator('payment_method')
@classmethod
def validate_payment_method(cls, v: str) -> str:
"""Validate payment method."""
allowed_methods = ['cash', 'stripe', 'paypal', 'borica']
if v not in allowed_methods:
raise ValueError(f'Payment method must be one of: {", ".join(allowed_methods)}')
return v
class UpdatePaymentStatusRequest(BaseModel):
"""Schema for updating payment status."""
status: str = Field(..., description="New payment status")
notes: Optional[str] = Field(None, max_length=1000, description="Status change notes")
@field_validator('status')
@classmethod
def validate_status(cls, v: str) -> str:
"""Validate payment status."""
allowed_statuses = ['pending', 'completed', 'failed', 'refunded', 'cancelled']
if v not in allowed_statuses:
raise ValueError(f'Status must be one of: {", ".join(allowed_statuses)}')
return v
class CreateStripePaymentIntentRequest(BaseModel):
"""Schema for creating a Stripe payment intent."""
booking_id: int = Field(..., gt=0, description="Booking ID")
amount: float = Field(..., gt=0, le=999999.99, description="Payment amount")
currency: Optional[str] = Field("usd", description="Currency code")
@field_validator('amount')
@classmethod
def validate_amount(cls, v: float) -> float:
"""Validate amount doesn't exceed Stripe limit."""
if v > 999999.99:
raise ValueError(f"Amount ${v:,.2f} exceeds Stripe's maximum of $999,999.99")
return v

View File

@@ -0,0 +1,122 @@
"""
Pydantic schemas for promotion-related requests and responses.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from datetime import datetime
class ValidatePromotionRequest(BaseModel):
"""Schema for validating a promotion code."""
code: str = Field(..., min_length=1, max_length=50, description="Promotion code")
booking_value: Optional[float] = Field(None, ge=0, description="Booking value/amount")
booking_amount: Optional[float] = Field(None, ge=0, description="Booking amount (alias for booking_value)")
@field_validator('code')
@classmethod
def validate_code(cls, v: str) -> str:
"""Validate promotion code format."""
if not v or not v.strip():
raise ValueError("Promotion code cannot be empty")
return v.strip().upper()
model_config = {
"json_schema_extra": {
"example": {
"code": "SUMMER2024",
"booking_value": 500.00
}
}
}
class CreatePromotionRequest(BaseModel):
"""Schema for creating a promotion."""
code: str = Field(..., min_length=1, max_length=50, description="Promotion code")
name: str = Field(..., min_length=1, max_length=200, description="Promotion name")
description: Optional[str] = Field(None, max_length=1000)
discount_type: str = Field(..., description="Discount type: 'percentage' or 'fixed'")
discount_value: float = Field(..., gt=0, description="Discount value")
min_booking_amount: Optional[float] = Field(None, ge=0)
max_discount_amount: Optional[float] = Field(None, ge=0)
start_date: Optional[str] = Field(None, description="Start date (ISO format)")
end_date: Optional[str] = Field(None, description="End date (ISO format)")
usage_limit: Optional[int] = Field(None, ge=1)
status: Optional[str] = Field("active", description="Status: 'active' or 'inactive'")
@field_validator('discount_type')
@classmethod
def validate_discount_type(cls, v: str) -> str:
"""Validate discount type."""
if v not in ['percentage', 'fixed']:
raise ValueError("Discount type must be 'percentage' or 'fixed'")
return v
@field_validator('discount_value')
@classmethod
def validate_discount_value(cls, v: float, info) -> float:
"""Validate discount value based on type."""
if 'discount_type' in info.data and info.data['discount_type'] == 'percentage':
if v > 100:
raise ValueError("Percentage discount cannot exceed 100%")
return v
@field_validator('code')
@classmethod
def validate_code(cls, v: str) -> str:
"""Validate promotion code format."""
if not v or not v.strip():
raise ValueError("Promotion code cannot be empty")
return v.strip().upper()
model_config = {
"json_schema_extra": {
"example": {
"code": "SUMMER2024",
"name": "Summer Sale",
"description": "20% off all bookings",
"discount_type": "percentage",
"discount_value": 20.0,
"min_booking_amount": 100.0,
"max_discount_amount": 500.0,
"start_date": "2024-06-01T00:00:00Z",
"end_date": "2024-08-31T23:59:59Z",
"usage_limit": 100,
"status": "active"
}
}
}
class UpdatePromotionRequest(BaseModel):
"""Schema for updating a promotion."""
code: Optional[str] = Field(None, min_length=1, max_length=50)
name: Optional[str] = Field(None, min_length=1, max_length=200)
description: Optional[str] = Field(None, max_length=1000)
discount_type: Optional[str] = None
discount_value: Optional[float] = Field(None, gt=0)
min_booking_amount: Optional[float] = Field(None, ge=0)
max_discount_amount: Optional[float] = Field(None, ge=0)
start_date: Optional[str] = None
end_date: Optional[str] = None
usage_limit: Optional[int] = Field(None, ge=1)
status: Optional[str] = None
@field_validator('discount_type')
@classmethod
def validate_discount_type(cls, v: Optional[str]) -> Optional[str]:
"""Validate discount type if provided."""
if v and v not in ['percentage', 'fixed']:
raise ValueError("Discount type must be 'percentage' or 'fixed'")
return v
model_config = {
"json_schema_extra": {
"example": {
"name": "Updated Summer Sale",
"discount_value": 25.0,
"status": "active"
}
}
}

View File

@@ -0,0 +1,23 @@
"""
Pydantic schemas for review-related requests and responses.
"""
from pydantic import BaseModel, Field
from typing import Optional
class CreateReviewRequest(BaseModel):
"""Schema for creating a review."""
room_id: int = Field(..., gt=0, description="Room ID")
rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5")
comment: Optional[str] = Field(None, max_length=2000, description="Review comment")
model_config = {
"json_schema_extra": {
"example": {
"room_id": 1,
"rating": 5,
"comment": "Great room, excellent service!"
}
}
}

View File

@@ -0,0 +1,63 @@
"""
Pydantic schemas for service booking-related requests and responses.
"""
from pydantic import BaseModel, Field, model_validator
from typing import Optional, List
from ..schemas.booking import ServiceItemSchema
class CreateServiceBookingRequest(BaseModel):
"""Schema for creating a service booking."""
services: List[ServiceItemSchema] = Field(..., min_length=1, description="List of services to book")
total_amount: float = Field(..., gt=0, description="Total amount for the booking")
notes: Optional[str] = Field(None, max_length=1000, description="Additional notes")
@model_validator(mode='after')
def validate_total_amount(self):
"""Validate that total amount matches calculated total."""
calculated = sum(item.quantity * 1.0 for item in self.services) # Will be validated against service prices in route
# Note: We can't validate exact amount here without service prices, but we validate structure
if self.total_amount <= 0:
raise ValueError("Total amount must be greater than 0")
return self
model_config = {
"json_schema_extra": {
"example": {
"services": [
{"service_id": 1, "quantity": 2}
],
"total_amount": 100.00,
"notes": "Special request"
}
}
}
class CreateServicePaymentIntentRequest(BaseModel):
"""Schema for creating a Stripe payment intent for service booking."""
amount: float = Field(..., gt=0, description="Payment amount")
currency: str = Field("usd", max_length=3, description="Currency code")
model_config = {
"json_schema_extra": {
"example": {
"amount": 100.00,
"currency": "usd"
}
}
}
class ConfirmServicePaymentRequest(BaseModel):
"""Schema for confirming a service payment."""
payment_intent_id: str = Field(..., min_length=1, description="Stripe payment intent ID")
model_config = {
"json_schema_extra": {
"example": {
"payment_intent_id": "pi_1234567890"
}
}
}

View File

@@ -0,0 +1,38 @@
"""
Pydantic schemas for user-related requests and responses.
"""
from pydantic import BaseModel, Field, EmailStr, field_validator
from typing import Optional
class CreateUserRequest(BaseModel):
"""Schema for creating a user."""
full_name: str = Field(..., min_length=2, max_length=100, description="Full name")
email: EmailStr = Field(..., description="Email address")
password: str = Field(..., min_length=8, description="Password")
phone_number: Optional[str] = Field(None, max_length=20, description="Phone number")
role_id: Optional[int] = Field(None, gt=0, description="Role ID")
@field_validator('password')
@classmethod
def validate_password(cls, v: str) -> str:
"""Validate password strength."""
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')
return v
class UpdateUserRequest(BaseModel):
"""Schema for updating a user."""
full_name: Optional[str] = Field(None, min_length=2, max_length=100)
email: Optional[EmailStr] = None
phone_number: Optional[str] = Field(None, max_length=20)
role_id: Optional[int] = Field(None, gt=0)
is_active: Optional[bool] = None

View File

@@ -81,6 +81,12 @@ class AuthService:
} }
async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict: async def register(self, db: Session, name: str, email: str, password: str, phone: Optional[str] = None) -> dict:
# Validate password strength
from ..utils.password_validation import validate_password_strength
is_valid, errors = validate_password_strength(password)
if not is_valid:
error_message = 'Password does not meet requirements: ' + '; '.join(errors)
raise ValueError(error_message)
existing_user = db.query(User).filter(User.email == email).first() existing_user = db.query(User).filter(User.email == email).first()
if existing_user: if existing_user:
@@ -146,11 +152,39 @@ class AuthService:
logger.warning(f"Login attempt for inactive user: {email}") logger.warning(f"Login attempt for inactive user: {email}")
raise ValueError("Account is disabled. Please contact support.") raise ValueError("Account is disabled. Please contact support.")
# Check if account is locked (reset if lockout expired)
if user.locked_until:
if user.locked_until > datetime.utcnow():
remaining_minutes = int((user.locked_until - datetime.utcnow()).total_seconds() / 60)
logger.warning(f"Login attempt for locked account: {email} (locked until {user.locked_until})")
raise ValueError(f"Account is temporarily locked due to multiple failed login attempts. Please try again in {remaining_minutes} minute(s).")
else:
# Lockout expired, reset it
user.locked_until = None
user.failed_login_attempts = 0
db.commit()
user.role = db.query(Role).filter(Role.id == user.role_id).first() user.role = db.query(Role).filter(Role.id == user.role_id).first()
if not self.verify_password(password, user.password): password_valid = self.verify_password(password, user.password)
logger.warning(f"Login attempt with invalid password for user: {email}")
raise ValueError("Invalid email or password") # Handle failed login attempt
if not password_valid:
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
max_attempts = settings.MAX_LOGIN_ATTEMPTS
lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES
# Lock account if max attempts reached
if user.failed_login_attempts >= max_attempts:
user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration)
logger.warning(f"Account locked due to {user.failed_login_attempts} failed login attempts: {email}")
db.commit()
raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).")
else:
remaining_attempts = max_attempts - user.failed_login_attempts
logger.warning(f"Login attempt with invalid password for user: {email} ({user.failed_login_attempts}/{max_attempts} failed attempts)")
db.commit()
raise ValueError(f"Invalid email or password. {remaining_attempts} attempt(s) remaining before account lockout.")
if user.mfa_enabled: if user.mfa_enabled:
if not mfa_token: if not mfa_token:
@@ -164,7 +198,26 @@ class AuthService:
from ..services.mfa_service import mfa_service from ..services.mfa_service import mfa_service
is_backup_code = len(mfa_token) == 8 is_backup_code = len(mfa_token) == 8
if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code): if not mfa_service.verify_mfa(db, user.id, mfa_token, is_backup_code):
raise ValueError("Invalid MFA token") # Increment failed attempts on MFA failure
user.failed_login_attempts = (user.failed_login_attempts or 0) + 1
max_attempts = settings.MAX_LOGIN_ATTEMPTS
lockout_duration = settings.ACCOUNT_LOCKOUT_DURATION_MINUTES
if user.failed_login_attempts >= max_attempts:
user.locked_until = datetime.utcnow() + timedelta(minutes=lockout_duration)
logger.warning(f"Account locked due to {user.failed_login_attempts} failed attempts (MFA failure): {email}")
db.commit()
raise ValueError(f"Account has been temporarily locked due to {max_attempts} failed login attempts. Please try again in {lockout_duration} minute(s).")
else:
remaining_attempts = max_attempts - user.failed_login_attempts
db.commit()
raise ValueError(f"Invalid MFA token. {remaining_attempts} attempt(s) remaining before account lockout.")
# Reset failed login attempts and unlock account on successful login
if user.failed_login_attempts > 0 or user.locked_until:
user.failed_login_attempts = 0
user.locked_until = None
db.commit()
tokens = self.generate_tokens(user.id) tokens = self.generate_tokens(user.id)
@@ -273,6 +326,13 @@ class AuthService:
if not self.verify_password(current_password, user.password): if not self.verify_password(current_password, user.password):
raise ValueError("Current password is incorrect") raise ValueError("Current password is incorrect")
# Validate new password strength
from ..utils.password_validation import validate_password_strength
is_valid, errors = validate_password_strength(password)
if not is_valid:
error_message = 'New password does not meet requirements: ' + '; '.join(errors)
raise ValueError(error_message)
user.password = self.hash_password(password) user.password = self.hash_password(password)
if full_name is not None: if full_name is not None:

View File

@@ -6,6 +6,9 @@ from ..models.invoice import Invoice, InvoiceItem, InvoiceStatus
from ..models.booking import Booking from ..models.booking import Booking
from ..models.payment import Payment, PaymentStatus from ..models.payment import Payment, PaymentStatus
from ..models.user import User from ..models.user import User
from ..config.logging_config import get_logger
logger = get_logger(__name__)
def generate_invoice_number(db: Session, is_proforma: bool=False) -> str: def generate_invoice_number(db: Session, is_proforma: bool=False) -> str:
prefix = 'PRO' if is_proforma else 'INV' prefix = 'PRO' if is_proforma else 'INV'
@@ -24,10 +27,12 @@ def generate_invoice_number(db: Session, is_proforma: bool=False) -> str:
class InvoiceService: class InvoiceService:
@staticmethod @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, **kwargs) -> Dict[str, Any]: 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, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
logger.info(f'Creating invoice from booking {booking_id}', extra={'booking_id': booking_id, 'request_id': request_id})
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: if not booking:
logger.error(f'Booking {booking_id} not found', extra={'booking_id': booking_id, 'request_id': request_id})
raise ValueError('Booking not found') raise ValueError('Booking not found')
user = db.query(User).filter(User.id == booking.user_id).first() user = db.query(User).filter(User.id == booking.user_id).first()
if not user: if not user:
@@ -94,7 +99,7 @@ class InvoiceService:
return InvoiceService.invoice_to_dict(invoice) return InvoiceService.invoice_to_dict(invoice)
@staticmethod @staticmethod
def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, **kwargs) -> Dict[str, Any]: def update_invoice(invoice_id: int, db: Session, updated_by_id: Optional[int]=None, request_id: Optional[str]=None, **kwargs) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice: if not invoice:
raise ValueError('Invoice not found') raise ValueError('Invoice not found')
@@ -121,7 +126,7 @@ class InvoiceService:
return InvoiceService.invoice_to_dict(invoice) return InvoiceService.invoice_to_dict(invoice)
@staticmethod @staticmethod
def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None) -> Dict[str, Any]: def mark_invoice_as_paid(invoice_id: int, db: Session, amount: Optional[float]=None, updated_by_id: Optional[int]=None, request_id: Optional[str]=None) -> Dict[str, Any]:
invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first() invoice = db.query(Invoice).filter(Invoice.id == invoice_id).first()
if not invoice: if not invoice:
raise ValueError('Invoice not found') raise ValueError('Invoice not found')

View File

@@ -4,13 +4,14 @@ from paypalcheckoutsdk.orders import OrdersCreateRequest, OrdersGetRequest, Orde
from paypalcheckoutsdk.payments import CapturesRefundRequest from paypalcheckoutsdk.payments import CapturesRefundRequest
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from ..config.settings import settings from ..config.settings import settings
from ..config.logging_config import get_logger
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus from ..models.booking import Booking, BookingStatus
from ..models.system_settings import SystemSettings from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
import json import json
logger = logging.getLogger(__name__) logger = get_logger(__name__)
def get_paypal_client_id(db: Session) -> Optional[str]: def get_paypal_client_id(db: Session) -> Optional[str]:
try: try:
@@ -285,10 +286,7 @@ class PayPalService:
db.rollback() db.rollback()
raise raise
except Exception as e: 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)}' error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}'
print(f'Error in confirm_payment: {error_msg}') logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'order_id': order_id, 'booking_id': booking_id})
print(f'Traceback: {error_details}')
db.rollback() db.rollback()
raise ValueError(f'Error confirming payment: {error_msg}') raise ValueError(f'Error confirming payment: {error_msg}')

View File

@@ -2,12 +2,13 @@ import logging
import stripe import stripe
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from ..config.settings import settings from ..config.settings import settings
from ..config.logging_config import get_logger
from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus from ..models.payment import Payment, PaymentMethod, PaymentType, PaymentStatus
from ..models.booking import Booking, BookingStatus from ..models.booking import Booking, BookingStatus
from ..models.system_settings import SystemSettings from ..models.system_settings import SystemSettings
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
logger = logging.getLogger(__name__) logger = get_logger(__name__)
def get_stripe_secret_key(db: Session) -> Optional[str]: def get_stripe_secret_key(db: Session) -> Optional[str]:
try: try:
@@ -98,7 +99,7 @@ class StripeService:
if not booking: if not booking:
raise ValueError('Booking not found') raise ValueError('Booking not found')
payment_status = intent_data.get('status') payment_status = intent_data.get('status')
print(f'Payment intent status: {payment_status}') logger.info(f'Payment intent status: {payment_status}', extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id})
if payment_status not in ['succeeded', 'processing']: 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.') 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() payment = db.query(Payment).filter(Payment.booking_id == booking_id, Payment.transaction_id == payment_intent_id, Payment.payment_method == PaymentMethod.stripe).first()
@@ -207,21 +208,20 @@ class StripeService:
try: 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: except AttributeError as ae:
print(f'AttributeError accessing payment fields: {ae}') logger.error(f'AttributeError accessing payment fields: {ae}', exc_info=True, extra={
print(f'Payment object: {payment}') 'payment_id': payment.id if hasattr(payment, 'id') else None,
print(f'Payment payment_method: {(payment.payment_method if hasattr(payment, 'payment_method') else 'missing')}') 'booking_id': booking_id,
print(f'Payment payment_type: {(payment.payment_type if hasattr(payment, 'payment_type') else 'missing')}') 'payment_method': payment.payment_method if hasattr(payment, 'payment_method') else 'missing',
print(f'Payment payment_status: {(payment.payment_status if hasattr(payment, 'payment_status') else 'missing')}') 'payment_type': payment.payment_type if hasattr(payment, 'payment_type') else 'missing',
'payment_status': payment.payment_status if hasattr(payment, 'payment_status') else 'missing'
})
raise raise
except ValueError as e: except ValueError as e:
db.rollback() db.rollback()
raise raise
except Exception as e: 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)}' error_msg = str(e) if str(e) else f'{type(e).__name__}: {repr(e)}'
print(f'Error in confirm_payment: {error_msg}') logger.error(f'Error in confirm_payment: {error_msg}', exc_info=True, extra={'payment_intent_id': payment_intent_id, 'booking_id': booking_id})
print(f'Traceback: {error_details}')
db.rollback() db.rollback()
raise ValueError(f'Error confirming payment: {error_msg}') raise ValueError(f'Error confirming payment: {error_msg}')

View File

@@ -0,0 +1,148 @@
"""
File validation utilities for secure file uploads.
Validates file types using magic bytes (file signatures) to prevent spoofing.
"""
from PIL import Image
import io
from typing import Tuple, Optional
from fastapi import UploadFile, HTTPException, status
# Magic bytes for common image formats
IMAGE_MAGIC_BYTES = {
b'\xFF\xD8\xFF': 'image/jpeg',
b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A': 'image/png',
b'GIF87a': 'image/gif',
b'GIF89a': 'image/gif',
b'RIFF': 'image/webp', # WebP files start with RIFF, need deeper check
b'\x00\x00\x01\x00': 'image/x-icon',
b'\x00\x00\x02\x00': 'image/x-icon',
}
ALLOWED_IMAGE_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
def validate_image_file_signature(file_content: bytes, filename: str) -> Tuple[bool, str]:
"""
Validate file type using magic bytes (file signature).
This prevents MIME type spoofing attacks.
Args:
file_content: The file content as bytes
filename: The filename (for extension checking)
Returns:
Tuple of (is_valid, error_message)
"""
if not file_content:
return False, "File is empty"
# Check magic bytes for image types
file_start = file_content[:16] # Check first 16 bytes
detected_type = None
# Check for JPEG
if file_content.startswith(b'\xFF\xD8\xFF'):
detected_type = 'image/jpeg'
# Check for PNG
elif file_content.startswith(b'\x89\x50\x4E\x47\x0D\x0A\x1A\x0A'):
detected_type = 'image/png'
# Check for GIF
elif file_content.startswith(b'GIF87a') or file_content.startswith(b'GIF89a'):
detected_type = 'image/gif'
# Check for WebP (RIFF header with WEBP in bytes 8-11)
elif file_content.startswith(b'RIFF') and len(file_content) > 12:
if file_content[8:12] == b'WEBP':
detected_type = 'image/webp'
# Check for ICO
elif file_content.startswith(b'\x00\x00\x01\x00') or file_content.startswith(b'\x00\x00\x02\x00'):
detected_type = 'image/x-icon'
# If magic bytes don't match known image types, try PIL verification
if not detected_type:
try:
# Try to open with PIL to verify it's a valid image
img = Image.open(io.BytesIO(file_content))
img.verify()
# Get format from PIL
img_format = img.format.lower() if img.format else None
if img_format == 'jpeg':
detected_type = 'image/jpeg'
elif img_format == 'png':
detected_type = 'image/png'
elif img_format == 'gif':
detected_type = 'image/gif'
elif img_format == 'webp':
detected_type = 'image/webp'
else:
return False, f"Unsupported image format: {img_format}"
except Exception:
return False, "File is not a valid image or is corrupted"
# Verify detected type is in allowed list
if detected_type not in ALLOWED_IMAGE_TYPES and detected_type != 'image/x-icon':
return False, f"File type {detected_type} is not allowed. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}"
return True, detected_type
async def validate_uploaded_image(file: UploadFile, max_size: int) -> bytes:
"""
Validate an uploaded image file completely.
Args:
file: FastAPI UploadFile object
max_size: Maximum file size in bytes
Returns:
File content as bytes
Raises:
HTTPException if validation fails
"""
# Check MIME type first (quick check)
if not file.content_type or not file.content_type.startswith('image/'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'File must be an image. Received MIME type: {file.content_type}'
)
# Read file content
content = await file.read()
# Validate file size
if len(content) > max_size:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f'File size ({len(content)} bytes) exceeds maximum allowed size ({max_size} bytes / {max_size // 1024 // 1024}MB)'
)
# Validate file signature (magic bytes)
is_valid, result = validate_image_file_signature(content, file.filename or '')
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid file type: {result}. File signature validation failed. Please upload a valid image file.'
)
# Additional PIL validation to ensure image is not corrupted
try:
img = Image.open(io.BytesIO(content))
# Verify image integrity
img.verify()
# Re-open for further processing (verify() closes the image)
img = Image.open(io.BytesIO(content))
# Check image dimensions to prevent decompression bombs
if img.width > 10000 or img.height > 10000:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Image dimensions too large. Maximum dimensions: 10000x10000 pixels'
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f'Invalid or corrupted image file: {str(e)}'
)
return content

View File

@@ -0,0 +1,99 @@
"""
HTML sanitization utilities for backend content storage.
Prevents XSS attacks by sanitizing HTML before storing in database.
"""
import bleach
from typing import Optional
# Allowed HTML tags for rich content
ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'u', 'b', 'i', 'span', 'div',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li',
'a', 'blockquote', 'pre', 'code',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'img', 'hr', 'section', 'article'
]
# Allowed HTML attributes
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title', 'target', 'rel'],
'img': ['src', 'alt', 'title', 'width', 'height', 'class'],
'div': ['class', 'id', 'style'],
'span': ['class', 'id', 'style'],
'p': ['class', 'id', 'style'],
'h1': ['class', 'id'],
'h2': ['class', 'id'],
'h3': ['class', 'id'],
'h4': ['class', 'id'],
'h5': ['class', 'id'],
'h6': ['class', 'id'],
'table': ['class', 'id'],
'tr': ['class', 'id'],
'th': ['class', 'id', 'colspan', 'rowspan'],
'td': ['class', 'id', 'colspan', 'rowspan'],
}
# Allowed URL schemes
ALLOWED_SCHEMES = ['http', 'https', 'mailto', 'tel']
def sanitize_html(html_content: Optional[str]) -> str:
"""
Sanitize HTML content to prevent XSS attacks.
Args:
html_content: HTML string to sanitize (can be None)
Returns:
Sanitized HTML string safe for storage
"""
if not html_content:
return ''
# Clean HTML content
cleaned = bleach.clean(
html_content,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
protocols=ALLOWED_SCHEMES,
strip=True, # Strip disallowed tags instead of escaping
strip_comments=True, # Remove HTML comments
)
# Additional link sanitization - ensure external links have rel="noopener"
if '<a' in cleaned:
import re
# Add rel="noopener noreferrer" to external links
def add_rel(match):
tag = match.group(0)
if 'href=' in tag and ('http://' in tag or 'https://' in tag):
if 'rel=' not in tag:
# Insert rel attribute before closing >
return tag[:-1] + ' rel="noopener noreferrer">'
elif 'noopener' not in tag and 'noreferrer' not in tag:
# Add to existing rel attribute
tag = tag.replace('rel="', 'rel="noopener noreferrer ')
tag = tag.replace("rel='", "rel='noopener noreferrer ")
return tag
return tag
cleaned = re.sub(r'<a[^>]*>', add_rel, cleaned)
return cleaned
def sanitize_text_for_html(text: Optional[str]) -> str:
"""
Escape text content to be safely included in HTML.
Use this for plain text that should be displayed as-is.
Args:
text: Plain text string to escape
Returns:
HTML-escaped string
"""
if not text:
return ''
return bleach.clean(text, tags=[], strip=True)

View File

@@ -0,0 +1,59 @@
"""
Password validation utilities for enforcing password strength requirements.
"""
import re
from typing import Tuple, List
# Password strength requirements
MIN_PASSWORD_LENGTH = 8
REQUIRE_UPPERCASE = True
REQUIRE_LOWERCASE = True
REQUIRE_NUMBER = True
REQUIRE_SPECIAL = True
def validate_password_strength(password: str) -> Tuple[bool, List[str]]:
"""
Validate password meets strength requirements.
Args:
password: The password to validate
Returns:
Tuple of (is_valid, list_of_errors)
"""
errors = []
if not password:
return False, ['Password is required']
# Check minimum length
if len(password) < MIN_PASSWORD_LENGTH:
errors.append(f'Password must be at least {MIN_PASSWORD_LENGTH} characters long')
# Check for uppercase letter
if REQUIRE_UPPERCASE and not re.search(r'[A-Z]', password):
errors.append('Password must contain at least one uppercase letter')
# Check for lowercase letter
if REQUIRE_LOWERCASE and not re.search(r'[a-z]', password):
errors.append('Password must contain at least one lowercase letter')
# Check for number
if REQUIRE_NUMBER and not re.search(r'\d', password):
errors.append('Password must contain at least one number')
# Check for special character
if REQUIRE_SPECIAL and not re.search(r'[!@#$%^&*(),.?":{}|<>]', password):
errors.append('Password must contain at least one special character (!@#$%^&*(),.?":{}|<>)')
# Check for common weak passwords
common_passwords = [
'password', '12345678', 'qwerty', 'abc123', 'password123',
'admin', 'letmein', 'welcome', 'monkey', '1234567890'
]
if password.lower() in common_passwords:
errors.append('Password is too common. Please choose a stronger password')
is_valid = len(errors) == 0
return is_valid, errors

View File

@@ -0,0 +1,21 @@
"""
Utility functions for request handling
"""
from typing import Optional
from fastapi import Request
def get_request_id(request: Optional[Request] = None) -> Optional[str]:
"""
Extract request_id from request state.
Args:
request: FastAPI Request object
Returns:
Request ID string or None
"""
if not request:
return None
return getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None

View File

@@ -2,6 +2,7 @@
Utility functions for standardizing API responses Utility functions for standardizing API responses
""" """
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import HTTPException, Request
def success_response( def success_response(
data: Any = None, data: Any = None,
@@ -31,6 +32,7 @@ def success_response(
def error_response( def error_response(
message: str, message: str,
errors: Optional[list] = None, errors: Optional[list] = None,
request_id: Optional[str] = None,
**kwargs **kwargs
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
@@ -45,7 +47,40 @@ def error_response(
if errors: if errors:
response['errors'] = errors response['errors'] = errors
if request_id:
response['request_id'] = request_id
response.update(kwargs) response.update(kwargs)
return response return response
def raise_http_exception(
status_code: int,
message: str,
errors: Optional[list] = None,
request: Optional[Request] = None,
**kwargs
) -> None:
"""
Raise an HTTPException with standardized error response format.
Args:
status_code: HTTP status code
message: Error message
errors: Optional list of error details
request: Optional Request object to extract request_id
**kwargs: Additional fields to include in response
"""
request_id = None
if request:
request_id = getattr(request.state, 'request_id', None) if hasattr(request, 'state') else None
detail = error_response(
message=message,
errors=errors,
request_id=request_id,
**kwargs
)
raise HTTPException(status_code=status_code, detail=detail)

View File

@@ -0,0 +1,13 @@
Copyright (c) 2014-2017, Mozilla Foundation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,103 @@
bleach-6.1.0.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
bleach-6.1.0.dist-info/LICENSE,sha256=vsIjjBSaYyuPsmgT9oes6rq4AyfzJwdpwsFhV4g9MTA,569
bleach-6.1.0.dist-info/METADATA,sha256=1SuJgikPmVEIDjs_NHu_oLycasw9HiTE19bLhRC8FSw,30425
bleach-6.1.0.dist-info/RECORD,,
bleach-6.1.0.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleach-6.1.0.dist-info/WHEEL,sha256=yQN5g4mg4AybRjkgi-9yy4iQEFibGQmlz78Pik5Or-A,92
bleach-6.1.0.dist-info/top_level.txt,sha256=dcv0wKIySB0zMjAEXLwY4V0-3IN9UZQGAT1wDmfQICY,7
bleach/__init__.py,sha256=bCOdn7NC262aA1v98sl-lklPqeaw_5LiXqYSf-XAwUM,3649
bleach/__pycache__/__init__.cpython-312.pyc,,
bleach/__pycache__/callbacks.cpython-312.pyc,,
bleach/__pycache__/css_sanitizer.cpython-312.pyc,,
bleach/__pycache__/html5lib_shim.cpython-312.pyc,,
bleach/__pycache__/linkifier.cpython-312.pyc,,
bleach/__pycache__/parse_shim.cpython-312.pyc,,
bleach/__pycache__/sanitizer.cpython-312.pyc,,
bleach/_vendor/README.rst,sha256=eXeKT2JdZB4WX1kuhTa8W9Jp9VXtwIKFxo5RUL5exmM,2160
bleach/_vendor/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleach/_vendor/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/__pycache__/parse.cpython-312.pyc,,
bleach/_vendor/html5lib-1.1.dist-info/AUTHORS.rst,sha256=DrNAMifoDpuQyJn-KW-H6K8Tt2a5rKnV2UF4-DRrGUI,983
bleach/_vendor/html5lib-1.1.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
bleach/_vendor/html5lib-1.1.dist-info/LICENSE,sha256=FqOZkWGekvGGgJMtoqkZn999ld8-yu3FLqBiGKq6_W8,1084
bleach/_vendor/html5lib-1.1.dist-info/METADATA,sha256=Y3w-nd_22HQnQRy3yypVsV_ke2FF94uUD4-vGpc2DnI,16076
bleach/_vendor/html5lib-1.1.dist-info/RECORD,sha256=u-y_W5lhdsHC1OSMnA4bCi3-11IgQ_FAIW6viMu8_LA,3486
bleach/_vendor/html5lib-1.1.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleach/_vendor/html5lib-1.1.dist-info/WHEEL,sha256=kGT74LWyRUZrL4VgLh6_g12IeVl_9u9ZVhadrgXZUEY,110
bleach/_vendor/html5lib-1.1.dist-info/top_level.txt,sha256=XEX6CHpskSmvjJB4tP6m4Q5NYXhIf_0ceMc0PNbzJPQ,9
bleach/_vendor/html5lib/__init__.py,sha256=pWnYcfZ69wNLrdQL7bpr49FUi8O8w0KhKCOHsyRgYGQ,1143
bleach/_vendor/html5lib/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/_ihatexml.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/_inputstream.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/_tokenizer.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/_utils.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/constants.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/html5parser.cpython-312.pyc,,
bleach/_vendor/html5lib/__pycache__/serializer.cpython-312.pyc,,
bleach/_vendor/html5lib/_ihatexml.py,sha256=ifOwF7pXqmyThIXc3boWc96s4MDezqRrRVp7FwDYUFs,16728
bleach/_vendor/html5lib/_inputstream.py,sha256=IKuMiY8rzb7pqIGCpbvTqsxysLEpgEHWYvYEFu4LUAI,32300
bleach/_vendor/html5lib/_tokenizer.py,sha256=WvJQa2Mli4NtTmhLXkX8Jy5FcWttqCaiDTiKyaw8D-k,77028
bleach/_vendor/html5lib/_trie/__init__.py,sha256=nqfgO910329BEVJ5T4psVwQtjd2iJyEXQ2-X8c1YxwU,109
bleach/_vendor/html5lib/_trie/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/html5lib/_trie/__pycache__/_base.cpython-312.pyc,,
bleach/_vendor/html5lib/_trie/__pycache__/py.cpython-312.pyc,,
bleach/_vendor/html5lib/_trie/_base.py,sha256=CaybYyMro8uERQYjby2tTeSUatnWDfWroUN9N7ety5w,1013
bleach/_vendor/html5lib/_trie/py.py,sha256=zg7RZSHxJ8mLmuI_7VEIV8AomISrgkvqCP477AgXaG0,1763
bleach/_vendor/html5lib/_utils.py,sha256=AxAJSG15eyarCgKMnlUwzs1X6jFHXqEvhlYEOxAFmis,4919
bleach/_vendor/html5lib/constants.py,sha256=Ll-yzLU_jcjyAI_h57zkqZ7aQWE5t5xA4y_jQgoUUhw,83464
bleach/_vendor/html5lib/filters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
bleach/_vendor/html5lib/filters/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/alphabeticalattributes.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/base.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/inject_meta_charset.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/lint.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/optionaltags.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/sanitizer.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/__pycache__/whitespace.cpython-312.pyc,,
bleach/_vendor/html5lib/filters/alphabeticalattributes.py,sha256=lViZc2JMCclXi_5gduvmdzrRxtO5Xo9ONnbHBVCsykU,919
bleach/_vendor/html5lib/filters/base.py,sha256=z-IU9ZAYjpsVsqmVt7kuWC63jR11hDMr6CVrvuao8W0,286
bleach/_vendor/html5lib/filters/inject_meta_charset.py,sha256=egDXUEHXmAG9504xz0K6ALDgYkvUrC2q15YUVeNlVQg,2945
bleach/_vendor/html5lib/filters/lint.py,sha256=upXATs6By7cot7o0bnNqR15sPq2Fn6Vnjvoy3gyO_rY,3631
bleach/_vendor/html5lib/filters/optionaltags.py,sha256=8lWT75J0aBOHmPgfmqTHSfPpPMp01T84NKu0CRedxcE,10588
bleach/_vendor/html5lib/filters/sanitizer.py,sha256=XGNSdzIqDTaHot1V-rRj1V_XOolApJ7n95tHP9JcgNU,26885
bleach/_vendor/html5lib/filters/whitespace.py,sha256=8eWqZxd4UC4zlFGW6iyY6f-2uuT8pOCSALc3IZt7_t4,1214
bleach/_vendor/html5lib/html5parser.py,sha256=w5hZJh0cvD3g4CS196DiTmuGpSKCMYe1GS46-yf_WZQ,117174
bleach/_vendor/html5lib/serializer.py,sha256=K2kfoLyMPMFPfdusfR30SrxNkf0mJB92-P5_RntyaaI,15747
bleach/_vendor/html5lib/treeadapters/__init__.py,sha256=18hyI-at2aBsdKzpwRwa5lGF1ipgctaTYXoU9En2ZQg,650
bleach/_vendor/html5lib/treeadapters/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/html5lib/treeadapters/__pycache__/genshi.cpython-312.pyc,,
bleach/_vendor/html5lib/treeadapters/__pycache__/sax.cpython-312.pyc,,
bleach/_vendor/html5lib/treeadapters/genshi.py,sha256=CH27pAsDKmu4ZGkAUrwty7u0KauGLCZRLPMzaO3M5vo,1715
bleach/_vendor/html5lib/treeadapters/sax.py,sha256=BKS8woQTnKiqeffHsxChUqL4q2ZR_wb5fc9MJ3zQC8s,1776
bleach/_vendor/html5lib/treebuilders/__init__.py,sha256=AysSJyvPfikCMMsTVvaxwkgDieELD5dfR8FJIAuq7hY,3592
bleach/_vendor/html5lib/treebuilders/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/html5lib/treebuilders/__pycache__/base.cpython-312.pyc,,
bleach/_vendor/html5lib/treebuilders/__pycache__/dom.cpython-312.pyc,,
bleach/_vendor/html5lib/treebuilders/__pycache__/etree.cpython-312.pyc,,
bleach/_vendor/html5lib/treebuilders/__pycache__/etree_lxml.cpython-312.pyc,,
bleach/_vendor/html5lib/treebuilders/base.py,sha256=oeZNGEB-kt90YJGVH05gb5a8E7ids2AbYwGRsVCieWk,14553
bleach/_vendor/html5lib/treebuilders/dom.py,sha256=22whb0C71zXIsai5mamg6qzBEiigcBIvaDy4Asw3at0,8925
bleach/_vendor/html5lib/treebuilders/etree.py,sha256=EbmHx-wQ-11MVucTPtF7Ul92-mQGN3Udu_KfDn-Ifhk,12824
bleach/_vendor/html5lib/treebuilders/etree_lxml.py,sha256=OazDHZGO_q4FnVs4Dhs4hzzn2JwGAOs-rfV8LAlUGW4,14754
bleach/_vendor/html5lib/treewalkers/__init__.py,sha256=OBPtc1TU5mGyy18QDMxKEyYEz0wxFUUNj5v0-XgmYhY,5719
bleach/_vendor/html5lib/treewalkers/__pycache__/__init__.cpython-312.pyc,,
bleach/_vendor/html5lib/treewalkers/__pycache__/base.cpython-312.pyc,,
bleach/_vendor/html5lib/treewalkers/__pycache__/dom.cpython-312.pyc,,
bleach/_vendor/html5lib/treewalkers/__pycache__/etree.cpython-312.pyc,,
bleach/_vendor/html5lib/treewalkers/__pycache__/etree_lxml.cpython-312.pyc,,
bleach/_vendor/html5lib/treewalkers/__pycache__/genshi.cpython-312.pyc,,
bleach/_vendor/html5lib/treewalkers/base.py,sha256=ouiOsuSzvI0KgzdWP8PlxIaSNs9falhbiinAEc_UIJY,7476
bleach/_vendor/html5lib/treewalkers/dom.py,sha256=EHyFR8D8lYNnyDU9lx_IKigVJRyecUGua0mOi7HBukc,1413
bleach/_vendor/html5lib/treewalkers/etree.py,sha256=gkD4tfEfRWPsEGvgHHJxZmKZXUvBzVVGz3v5C_MIiOE,4539
bleach/_vendor/html5lib/treewalkers/etree_lxml.py,sha256=eLedbn6nPjlpebibsWVijey7WEpzDwxU3ubwUoudBuA,6345
bleach/_vendor/html5lib/treewalkers/genshi.py,sha256=4D2PECZ5n3ZN3qu3jMl9yY7B81jnQApBQSVlfaIuYbA,2309
bleach/_vendor/parse.py,sha256=Rq-WbjO2JHrh1X2UWRFaPrRs2p-AnJ8U4FKrwv6NrLI,39023
bleach/_vendor/parse.py.SHA256SUM,sha256=-AaiqN-9otw_X0vFjKkbKWFvkp68iLME92_wI-8-vm0,75
bleach/_vendor/vendor.txt,sha256=6FFZyenumgWqnhLgbCa4yzL4HVNaSUDC2DHNyR5Fy6w,184
bleach/_vendor/vendor_install.sh,sha256=x_Pn4dkfzPMJCZKwHHFxp0EAL5RsIfz-HSdTWHuI4yA,453
bleach/callbacks.py,sha256=JNTGiM5_3bKsGltpR9ZYEz_C_b7-vfDlTTdQCirbdyc,752
bleach/css_sanitizer.py,sha256=QFMxRKBUMSuNvYkVpB2WRBQO609eFbU-p9P_LhU6jtM,2526
bleach/html5lib_shim.py,sha256=cWdAh70QZWz4MwtihdiA1gZJ0hTkvRjUYurE4uoCHCg,23294
bleach/linkifier.py,sha256=vWOXKuRXirpCwejUEEyfe8EWJ7rBlieMDEerg95OhPU,22375
bleach/parse_shim.py,sha256=VDPOdBOKbuDEceKVvfoggcr6A332bkcq4Z8jMtOJlAQ,50
bleach/sanitizer.py,sha256=JqDuTINOybpc_eHBzG_H7cnkHdFskZGbfsaBc-hDPH8,21934

View File

@@ -0,0 +1,5 @@
Wheel-Version: 1.0
Generator: bdist_wheel (0.41.2)
Root-Is-Purelib: true
Tag: py3-none-any

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