This commit is contained in:
Iliyan Angelov
2025-11-30 23:29:01 +02:00
parent 39fcfff811
commit 0fa2adeb19
1058 changed files with 4630 additions and 296 deletions

View File

@@ -1,6 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session, joinedload, selectinload, load_only
from sqlalchemy import and_, or_, func
from sqlalchemy.exc import IntegrityError
from typing import Optional
from datetime import datetime
import random
@@ -25,6 +26,7 @@ from ...loyalty.services.loyalty_service import LoyaltyService
from ...shared.utils.currency_helpers import get_currency_symbol
from ...shared.utils.response_helpers import success_response
from ..schemas.booking import CreateBookingRequest, UpdateBookingRequest
from ..schemas.admin_booking import AdminCreateBookingRequest
router = APIRouter(prefix='/bookings', tags=['bookings'])
def _generate_invoice_email_html(invoice: dict, is_proforma: bool=False) -> str:
@@ -176,9 +178,13 @@ async def create_booking(booking_data: CreateBookingRequest, current_user: User=
role = db.query(Role).filter(Role.id == current_user.role_id).first()
if role and role.name in ['admin', 'staff', 'accountant']:
raise HTTPException(status_code=403, detail='Admin, staff, and accountant users cannot create bookings')
import logging
logger = logging.getLogger(__name__)
# Start transaction
transaction = db.begin()
try:
import logging
logger = logging.getLogger(__name__)
logger.info(f'Received booking request from user {current_user.id}: {booking_data.dict()}')
# Extract validated data from Pydantic model
@@ -194,8 +200,10 @@ async def create_booking(booking_data: CreateBookingRequest, current_user: User=
services = booking_data.services or []
invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {}
room = db.query(Room).filter(Room.id == room_id).first()
# Lock room row to prevent race conditions (SELECT FOR UPDATE)
room = db.query(Room).filter(Room.id == room_id).with_for_update().first()
if not room:
transaction.rollback()
raise HTTPException(status_code=404, detail='Room not found')
# Parse dates (schema validation already ensures format is valid)
@@ -210,9 +218,20 @@ async def create_booking(booking_data: CreateBookingRequest, current_user: User=
# Date validation already done in schema, but keeping as safety check
if check_in >= check_out:
transaction.rollback()
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()
# Check for overlapping bookings with row-level locking to prevent race conditions
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
)
).with_for_update().first()
if overlapping:
transaction.rollback()
raise HTTPException(status_code=409, detail='Room already booked for the selected dates')
# Check for maintenance blocks
@@ -370,19 +389,20 @@ async def create_booking(booking_data: CreateBookingRequest, current_user: User=
total_price = unit_price * quantity
service_usage = ServiceUsage(booking_id=booking.id, service_id=service_id, quantity=quantity, unit_price=unit_price, total_price=total_price)
db.add(service_usage)
db.commit()
# Commit transaction - all database operations are atomic
transaction.commit()
db.refresh(booking)
# Send booking confirmation notification
# Send booking confirmation notification (outside transaction)
try:
from ...notifications.services.notification_service import NotificationService
if booking.status == BookingStatus.confirmed:
NotificationService.send_booking_confirmation(db, booking)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f'Failed to send booking confirmation notification: {e}')
# Create invoice (outside transaction to avoid nested transaction issues)
try:
from ...payments.services.invoice_service import InvoiceService
from ...shared.utils.mailer import send_email
@@ -486,8 +506,17 @@ async def create_booking(booking_data: CreateBookingRequest, current_user: User=
message = f'Booking created. Please pay {deposit_percentage}% deposit to confirm.' if requires_deposit else 'Booking created successfully'
return success_response(data={'booking': booking_dict}, message=message)
except HTTPException:
if 'transaction' in locals():
transaction.rollback()
raise
except IntegrityError as e:
transaction.rollback()
logger.error(f'Database integrity error during booking creation: {str(e)}')
raise HTTPException(status_code=409, detail='Booking conflict detected. Please try again.')
except Exception as e:
if 'transaction' in locals():
transaction.rollback()
logger.error(f'Error creating booking: {str(e)}', exc_info=True)
import logging
import traceback
logger = logging.getLogger(__name__)
@@ -745,7 +774,14 @@ async def update_booking(id: int, booking_data: UpdateBookingRequest, current_us
if booking_data.notes is not None:
booking.special_requests = booking_data.notes
# Restrict staff from modifying booking prices (only admin can)
if booking_data.total_price is not None:
from ...shared.utils.role_helpers import is_admin
if not is_admin(current_user, db):
raise HTTPException(
status_code=403,
detail='Staff members cannot modify booking prices. Please contact an administrator.'
)
booking.total_price = booking_data.total_price
db.commit()
@@ -908,60 +944,44 @@ async def check_booking_by_number(booking_number: str, db: Session=Depends(get_d
raise HTTPException(status_code=500, detail=str(e))
@router.post('/admin-create', dependencies=[Depends(authorize_roles('admin', 'staff'))])
async def admin_create_booking(booking_data: dict, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
async def admin_create_booking(booking_data: AdminCreateBookingRequest, current_user: User=Depends(authorize_roles('admin', 'staff')), db: Session=Depends(get_db)):
"""Create a booking on behalf of a user (admin/staff only)"""
import logging
logger = logging.getLogger(__name__)
# Start transaction
transaction = db.begin()
try:
import logging
logger = logging.getLogger(__name__)
if not isinstance(booking_data, dict):
logger.error(f'Invalid booking_data type: {type(booking_data)}, value: {booking_data}')
raise HTTPException(status_code=400, detail='Invalid request body. Expected JSON object.')
# Get user_id from booking_data (required for admin/staff bookings)
user_id = booking_data.get('user_id')
if not user_id:
raise HTTPException(status_code=400, detail='user_id is required for admin/staff bookings')
# Extract validated data from Pydantic model
user_id = booking_data.user_id
room_id = booking_data.room_id
check_in_date = booking_data.check_in_date
check_out_date = booking_data.check_out_date
total_price = booking_data.total_price
guest_count = booking_data.guest_count
notes = booking_data.notes
payment_method = booking_data.payment_method
payment_status = booking_data.payment_status
promotion_code = booking_data.promotion_code
status = booking_data.status
services = booking_data.services or []
invoice_info = booking_data.invoice_info.dict() if booking_data.invoice_info else {}
# Verify user exists
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
transaction.rollback()
raise HTTPException(status_code=404, detail='User not found')
logger.info(f'Admin/Staff {current_user.id} creating booking for user {user_id}: {booking_data}')
logger.info(f'Admin/Staff {current_user.id} creating booking for user {user_id}')
room_id = booking_data.get('room_id')
check_in_date = booking_data.get('check_in_date')
check_out_date = booking_data.get('check_out_date')
total_price = booking_data.get('total_price')
guest_count = booking_data.get('guest_count', 1)
if guest_count < 1 or guest_count > 20:
raise HTTPException(status_code=400, detail='Guest count must be between 1 and 20')
notes = booking_data.get('notes')
payment_method = booking_data.get('payment_method', 'cash')
payment_status = booking_data.get('payment_status', 'unpaid') # 'full', 'deposit', or 'unpaid'
promotion_code = booking_data.get('promotion_code')
status = booking_data.get('status', 'confirmed') # Default to confirmed for admin bookings
invoice_info = booking_data.get('invoice_info', {})
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()
# Lock room row to prevent race conditions
room = db.query(Room).filter(Room.id == room_id).with_for_update().first()
if not room:
transaction.rollback()
raise HTTPException(status_code=404, detail='Room not found')
# Parse dates
# Parse dates (already validated by Pydantic)
if 'T' in check_in_date or 'Z' in check_in_date or '+' in check_in_date:
check_in = datetime.fromisoformat(check_in_date.replace('Z', '+00:00'))
else:
@@ -971,11 +991,7 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
else:
check_out = datetime.strptime(check_out_date, '%Y-%m-%d')
# Validate dates
if check_in >= check_out:
raise HTTPException(status_code=400, detail='Check-out date must be after check-in date')
# Check for overlapping bookings
# Check for overlapping bookings with row-level locking
overlapping = db.query(Booking).filter(
and_(
Booking.room_id == room_id,
@@ -983,8 +999,9 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
Booking.check_in_date < check_out,
Booking.check_out_date > check_in
)
).first()
).with_for_update().first()
if overlapping:
transaction.rollback()
raise HTTPException(status_code=409, detail='Room already booked for the selected dates')
booking_number = generate_booking_number()
@@ -996,17 +1013,15 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
room_total = room_price * number_of_nights
# Calculate services total if any
services = booking_data.get('services', [])
services_total = 0.0
if services:
from ...hotel_services.models.service import Service
for service_item in services:
service_id = service_item.get('service_id')
quantity = service_item.get('quantity', 1)
if service_id:
service = db.query(Service).filter(Service.id == service_id).first()
if service and service.is_active:
services_total += float(service.price) * quantity
service_id = service_item.service_id
quantity = service_item.quantity
service = db.query(Service).filter(Service.id == service_id).first()
if service and service.is_active:
services_total += float(service.price) * quantity
original_price = room_total + services_total
@@ -1181,10 +1196,8 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
if services:
from ...hotel_services.models.service import Service
for service_item in services:
service_id = service_item.get('service_id')
quantity = service_item.get('quantity', 1)
if not service_id:
continue
service_id = service_item.service_id
quantity = service_item.quantity
service = db.query(Service).filter(Service.id == service_id).first()
if not service or not service.is_active:
continue
@@ -1199,7 +1212,8 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
)
db.add(service_usage)
db.commit()
# Commit transaction
transaction.commit()
db.refresh(booking)
# Load booking with relationships
@@ -1305,12 +1319,16 @@ async def admin_create_booking(booking_data: dict, current_user: User=Depends(au
message=f'Booking created successfully by {current_user.full_name}'
)
except HTTPException:
if 'transaction' in locals():
transaction.rollback()
raise
except IntegrityError as e:
if 'transaction' in locals():
transaction.rollback()
logger.error(f'Database integrity error during admin booking creation: {str(e)}')
raise HTTPException(status_code=409, detail='Booking conflict detected. Please try again.')
except Exception as e:
import logging
import traceback
logger = logging.getLogger(__name__)
logger.error(f'Error creating booking (admin/staff): {str(e)}')
logger.error(f'Traceback: {traceback.format_exc()}')
db.rollback()
raise HTTPException(status_code=500, detail=str(e))
if 'transaction' in locals():
transaction.rollback()
logger.error(f'Error creating booking (admin/staff): {str(e)}', exc_info=True)
raise HTTPException(status_code=500, detail='An error occurred while creating the booking')

View File

@@ -0,0 +1,9 @@
from .booking import CreateBookingRequest, UpdateBookingRequest
from .admin_booking import AdminCreateBookingRequest
__all__ = [
'CreateBookingRequest',
'UpdateBookingRequest',
'AdminCreateBookingRequest',
]

View File

@@ -0,0 +1,92 @@
"""
Pydantic schemas for admin/staff booking creation.
"""
from pydantic import BaseModel, Field, field_validator, model_validator
from typing import Optional, List
from datetime import datetime
from .booking import ServiceItemSchema, InvoiceInfoSchema
class AdminCreateBookingRequest(BaseModel):
"""Schema for admin/staff creating a booking on behalf of a user."""
user_id: int = Field(..., gt=0, description="User ID for whom the booking is created")
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)")
payment_status: str = Field("unpaid", description="Payment status (unpaid, full, deposit)")
promotion_code: Optional[str] = Field(None, max_length=50)
status: str = Field("confirmed", description="Booking status (pending, confirmed, etc.)")
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:
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.')
@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
@field_validator('payment_status')
@classmethod
def validate_payment_status(cls, v: str) -> str:
"""Validate payment status."""
allowed_statuses = ['unpaid', 'full', 'deposit']
if v not in allowed_statuses:
raise ValueError(f'Payment status must be one of: {", ".join(allowed_statuses)}')
return v
@field_validator('status')
@classmethod
def validate_status(cls, v: str) -> str:
"""Validate booking status."""
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 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:
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