updates
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||||
@@ -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}')
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/middleware/__pycache__/csrf.cpython-312.pyc
Normal file
BIN
Backend/src/middleware/__pycache__/csrf.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
114
Backend/src/middleware/admin_ip_whitelist.py
Normal file
114
Backend/src/middleware/admin_ip_whitelist.py
Normal 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)
|
||||||
@@ -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
|
||||||
158
Backend/src/middleware/csrf.py
Normal file
158
Backend/src/middleware/csrf.py
Normal 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)
|
||||||
|
|
||||||
@@ -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:
|
||||||
response_content['stack'] = traceback.format_exc()
|
# Only include stack traces in development mode
|
||||||
return JSONResponse(status_code=status_code, content=response_content)
|
# Double-check environment to prevent accidental exposure
|
||||||
|
env_check = os.getenv('ENVIRONMENT', 'development').lower()
|
||||||
|
if env_check == 'development':
|
||||||
|
response_content['stack'] = traceback.format_exc()
|
||||||
|
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)
|
||||||
53
Backend/src/middleware/request_size_limit.py
Normal file
53
Backend/src/middleware/request_size_limit.py
Normal 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
|
||||||
|
|
||||||
@@ -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
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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'),
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
)
|
||||||
@@ -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'),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,18 +248,16 @@ 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
|
|
||||||
|
|
||||||
original_price = room_total + services_total
|
original_price = room_total + services_total
|
||||||
|
|
||||||
@@ -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)):
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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)}')
|
||||||
@@ -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.")
|
||||||
|
|||||||
@@ -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)
|
raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%')
|
||||||
if discount_type == 'percentage' and discount_value > 100:
|
if promotion_data.code is not None:
|
||||||
raise HTTPException(status_code=400, detail='Percentage discount cannot exceed 100%')
|
promotion.code = promotion_data.code
|
||||||
if 'code' in promotion_data:
|
if promotion_data.name is not None:
|
||||||
promotion.code = promotion_data['code']
|
promotion.name = promotion_data.name
|
||||||
if 'name' in promotion_data:
|
if promotion_data.description is not None:
|
||||||
promotion.name = promotion_data['name']
|
promotion.description = promotion_data.description
|
||||||
if 'description' in promotion_data:
|
if promotion_data.discount_type is not None:
|
||||||
promotion.description = promotion_data['description']
|
promotion.discount_type = DiscountType(promotion_data.discount_type)
|
||||||
if 'discount_type' in promotion_data:
|
if promotion_data.discount_value is not None:
|
||||||
promotion.discount_type = DiscountType(promotion_data['discount_type'])
|
promotion.discount_value = promotion_data.discount_value
|
||||||
if 'discount_value' in promotion_data:
|
if promotion_data.min_booking_amount is not None:
|
||||||
promotion.discount_value = discount_value
|
promotion.min_booking_amount = promotion_data.min_booking_amount
|
||||||
if 'min_booking_amount' in promotion_data:
|
if promotion_data.max_discount_amount is not None:
|
||||||
promotion.min_booking_amount = float(promotion_data['min_booking_amount']) if promotion_data['min_booking_amount'] else None
|
promotion.max_discount_amount = promotion_data.max_discount_amount
|
||||||
if 'max_discount_amount' in promotion_data:
|
if promotion_data.start_date is not None:
|
||||||
promotion.max_discount_amount = float(promotion_data['max_discount_amount']) if promotion_data['max_discount_amount'] else None
|
promotion.start_date = datetime.fromisoformat(promotion_data.start_date.replace('Z', '+00:00')) if promotion_data.start_date else None
|
||||||
if 'start_date' in promotion_data:
|
if promotion_data.end_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.end_date = datetime.fromisoformat(promotion_data.end_date.replace('Z', '+00:00')) if promotion_data.end_date else None
|
||||||
if 'end_date' in promotion_data:
|
if promotion_data.usage_limit is not None:
|
||||||
promotion.end_date = datetime.fromisoformat(promotion_data['end_date'].replace('Z', '+00:00')) if promotion_data['end_date'] else None
|
promotion.usage_limit = promotion_data.usage_limit
|
||||||
if 'usage_limit' in promotion_data:
|
if promotion_data.status is not None:
|
||||||
promotion.usage_limit = promotion_data['usage_limit']
|
promotion.is_active = promotion_data.status == 'active'
|
||||||
if 'status' in promotion_data:
|
|
||||||
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))
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
BIN
Backend/src/schemas/__pycache__/booking.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/booking.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/invoice.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/invoice.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/page_content.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/page_content.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/payment.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/payment.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/promotion.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/promotion.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/review.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/review.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/service_booking.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/schemas/__pycache__/user.cpython-312.pyc
Normal file
BIN
Backend/src/schemas/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
167
Backend/src/schemas/booking.py
Normal file
167
Backend/src/schemas/booking.py
Normal 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
|
||||||
|
|
||||||
77
Backend/src/schemas/invoice.py
Normal file
77
Backend/src/schemas/invoice.py
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
Backend/src/schemas/page_content.py
Normal file
110
Backend/src/schemas/page_content.py
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
55
Backend/src/schemas/payment.py
Normal file
55
Backend/src/schemas/payment.py
Normal 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
|
||||||
|
|
||||||
122
Backend/src/schemas/promotion.py
Normal file
122
Backend/src/schemas/promotion.py
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
23
Backend/src/schemas/review.py
Normal file
23
Backend/src/schemas/review.py
Normal 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!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
63
Backend/src/schemas/service_booking.py
Normal file
63
Backend/src/schemas/service_booking.py
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
38
Backend/src/schemas/user.py
Normal file
38
Backend/src/schemas/user.py
Normal 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
|
||||||
|
|
||||||
BIN
Backend/src/services/__pycache__/audit_service.cpython-312.pyc
Normal file
BIN
Backend/src/services/__pycache__/audit_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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}')
|
||||||
@@ -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}')
|
||||||
|
|
||||||
|
|||||||
BIN
Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc
Normal file
BIN
Backend/src/utils/__pycache__/html_sanitizer.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/utils/__pycache__/request_helpers.cpython-312.pyc
Normal file
BIN
Backend/src/utils/__pycache__/request_helpers.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
148
Backend/src/utils/file_validation.py
Normal file
148
Backend/src/utils/file_validation.py
Normal 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
|
||||||
|
|
||||||
99
Backend/src/utils/html_sanitizer.py
Normal file
99
Backend/src/utils/html_sanitizer.py
Normal 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)
|
||||||
|
|
||||||
59
Backend/src/utils/password_validation.py
Normal file
59
Backend/src/utils/password_validation.py
Normal 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
|
||||||
|
|
||||||
21
Backend/src/utils/request_helpers.py
Normal file
21
Backend/src/utils/request_helpers.py
Normal 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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
pip
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user