update
This commit is contained in:
BIN
Backend/src/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/__pycache__/main.cpython-312.pyc
Normal file
BIN
Backend/src/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/ai/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/ai/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/ai/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/ai/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/ai/models/__pycache__/chat.cpython-312.pyc
Normal file
BIN
Backend/src/ai/models/__pycache__/chat.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/ai/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/ai/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/ai/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/ai/services/__pycache__/__init__.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.
Binary file not shown.
BIN
Backend/src/analytics/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/analytics/__pycache__/__init__.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.
Binary file not shown.
@@ -1,8 +1,10 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Response
|
||||
from sqlalchemy.orm import Session, load_only, joinedload
|
||||
from sqlalchemy import func, and_
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta
|
||||
import csv
|
||||
import io
|
||||
from ...shared.config.database import get_db
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
@@ -164,15 +166,26 @@ async def get_customer_dashboard_stats(current_user: User=Depends(get_current_us
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/revenue')
|
||||
async def get_revenue_report(start_date: Optional[str]=Query(None), end_date: Optional[str]=Query(None), current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')), db: Session=Depends(get_db)):
|
||||
async def get_revenue_report(
|
||||
start_date: Optional[str]=Query(None),
|
||||
end_date: Optional[str]=Query(None),
|
||||
format: Optional[str]=Query('json', regex='^(json|csv)$'),
|
||||
current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session=Depends(get_db)
|
||||
):
|
||||
try:
|
||||
query = db.query(Payment).filter(Payment.payment_status == PaymentStatus.completed)
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Payment.payment_date >= start)
|
||||
else:
|
||||
start = datetime.utcnow() - timedelta(days=30)
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Payment.payment_date <= end)
|
||||
else:
|
||||
end = datetime.utcnow()
|
||||
|
||||
total_revenue = db.query(func.sum(Payment.amount)).filter(Payment.payment_status == PaymentStatus.completed).scalar() or 0.0
|
||||
revenue_by_method = db.query(Payment.payment_method, func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(Payment.payment_method).all()
|
||||
method_breakdown = {}
|
||||
@@ -181,6 +194,147 @@ async def get_revenue_report(start_date: Optional[str]=Query(None), end_date: Op
|
||||
method_breakdown[method_name] = float(total or 0)
|
||||
daily_revenue = db.query(func.date(Payment.payment_date).label('date'), func.sum(Payment.amount).label('total')).filter(Payment.payment_status == PaymentStatus.completed).group_by(func.date(Payment.payment_date)).order_by(func.date(Payment.payment_date).desc()).limit(30).all()
|
||||
daily_breakdown = [{'date': date.isoformat() if isinstance(date, datetime) else str(date), 'revenue': float(total or 0)} for date, total in daily_revenue]
|
||||
|
||||
if format == 'csv':
|
||||
# Generate CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(['Revenue Report'])
|
||||
writer.writerow(['Period', f'{start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}'])
|
||||
writer.writerow(['Total Revenue', f'{total_revenue:.2f}'])
|
||||
writer.writerow([])
|
||||
writer.writerow(['Revenue by Payment Method'])
|
||||
writer.writerow(['Method', 'Amount'])
|
||||
for method, amount in method_breakdown.items():
|
||||
writer.writerow([method, f'{amount:.2f}'])
|
||||
writer.writerow([])
|
||||
writer.writerow(['Daily Revenue'])
|
||||
writer.writerow(['Date', 'Revenue'])
|
||||
for day in daily_breakdown:
|
||||
writer.writerow([day['date'], f"{day['revenue']:.2f}"])
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type='text/csv',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="revenue_report_{start.strftime("%Y%m%d")}_{end.strftime("%Y%m%d")}.csv"'
|
||||
}
|
||||
)
|
||||
|
||||
return success_response(data={'total_revenue': float(total_revenue), 'revenue_by_method': method_breakdown, 'daily_breakdown': daily_breakdown})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get('/export')
|
||||
async def export_reports(
|
||||
start_date: Optional[str]=Query(None),
|
||||
end_date: Optional[str]=Query(None),
|
||||
format: Optional[str]=Query('csv', regex='^(csv|json)$'),
|
||||
current_user: User=Depends(authorize_roles('admin', 'staff', 'accountant')),
|
||||
db: Session=Depends(get_db)
|
||||
):
|
||||
"""Export comprehensive reports in CSV or JSON format."""
|
||||
try:
|
||||
if start_date:
|
||||
start = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
start = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
if end_date:
|
||||
end = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
else:
|
||||
end = datetime.utcnow()
|
||||
|
||||
# Get bookings data
|
||||
bookings = db.query(Booking).filter(
|
||||
and_(
|
||||
Booking.created_at >= start,
|
||||
Booking.created_at <= end
|
||||
)
|
||||
).all()
|
||||
|
||||
# Get payments data
|
||||
payments = db.query(Payment).filter(
|
||||
and_(
|
||||
Payment.created_at >= start,
|
||||
Payment.created_at <= end
|
||||
)
|
||||
).all()
|
||||
|
||||
if format == 'csv':
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Bookings section
|
||||
writer.writerow(['Bookings Report'])
|
||||
writer.writerow(['Period', f'{start.strftime("%Y-%m-%d")} to {end.strftime("%Y-%m-%d")}'])
|
||||
writer.writerow([])
|
||||
writer.writerow(['Booking ID', 'Booking Number', 'User ID', 'Room ID', 'Check In', 'Check Out', 'Status', 'Total Price', 'Created At'])
|
||||
for booking in bookings:
|
||||
writer.writerow([
|
||||
booking.id,
|
||||
booking.booking_number,
|
||||
booking.user_id,
|
||||
booking.room_id,
|
||||
booking.check_in_date.strftime('%Y-%m-%d') if booking.check_in_date else '',
|
||||
booking.check_out_date.strftime('%Y-%m-%d') if booking.check_out_date else '',
|
||||
booking.status.value if hasattr(booking.status, 'value') else str(booking.status),
|
||||
float(booking.total_price) if booking.total_price else 0.0,
|
||||
booking.created_at.isoformat() if booking.created_at else ''
|
||||
])
|
||||
|
||||
writer.writerow([])
|
||||
writer.writerow(['Payments Report'])
|
||||
writer.writerow(['Payment ID', 'Booking ID', 'Amount', 'Payment Method', 'Payment Type', 'Status', 'Transaction ID', 'Payment Date', 'Created At'])
|
||||
for payment in payments:
|
||||
writer.writerow([
|
||||
payment.id,
|
||||
payment.booking_id,
|
||||
float(payment.amount) if payment.amount else 0.0,
|
||||
payment.payment_method.value if hasattr(payment.payment_method, 'value') else str(payment.payment_method),
|
||||
payment.payment_type.value if hasattr(payment.payment_type, 'value') else str(payment.payment_type),
|
||||
payment.payment_status.value if hasattr(payment.payment_status, 'value') else str(payment.payment_status),
|
||||
payment.transaction_id or '',
|
||||
payment.payment_date.isoformat() if payment.payment_date else '',
|
||||
payment.created_at.isoformat() if payment.created_at else ''
|
||||
])
|
||||
|
||||
return Response(
|
||||
content=output.getvalue(),
|
||||
media_type='text/csv',
|
||||
headers={
|
||||
'Content-Disposition': f'attachment; filename="reports_export_{start.strftime("%Y%m%d")}_{end.strftime("%Y%m%d")}.csv"'
|
||||
}
|
||||
)
|
||||
|
||||
# JSON format
|
||||
return success_response(data={
|
||||
'period': {
|
||||
'start_date': start.isoformat(),
|
||||
'end_date': end.isoformat()
|
||||
},
|
||||
'bookings': [{
|
||||
'id': b.id,
|
||||
'booking_number': b.booking_number,
|
||||
'user_id': b.user_id,
|
||||
'room_id': b.room_id,
|
||||
'check_in_date': b.check_in_date.isoformat() if b.check_in_date else None,
|
||||
'check_out_date': b.check_out_date.isoformat() if b.check_out_date else None,
|
||||
'status': b.status.value if hasattr(b.status, 'value') else str(b.status),
|
||||
'total_price': float(b.total_price) if b.total_price else 0.0,
|
||||
'created_at': b.created_at.isoformat() if b.created_at else None
|
||||
} for b in bookings],
|
||||
'payments': [{
|
||||
'id': p.id,
|
||||
'booking_id': p.booking_id,
|
||||
'amount': float(p.amount) if p.amount else 0.0,
|
||||
'payment_method': p.payment_method.value if hasattr(p.payment_method, 'value') else str(p.payment_method),
|
||||
'payment_type': p.payment_type.value if hasattr(p.payment_type, 'value') else str(p.payment_type),
|
||||
'payment_status': p.payment_status.value if hasattr(p.payment_status, 'value') else str(p.payment_status),
|
||||
'transaction_id': p.transaction_id,
|
||||
'payment_date': p.payment_date.isoformat() if p.payment_date else None,
|
||||
'created_at': p.created_at.isoformat() if p.created_at else None
|
||||
} for p in payments]
|
||||
})
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/auth/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/auth/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/auth/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/auth/models/__pycache__/role.cpython-312.pyc
Normal file
BIN
Backend/src/auth/models/__pycache__/role.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/models/__pycache__/user.cpython-312.pyc
Normal file
BIN
Backend/src/auth/models/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/auth/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc
Normal file
BIN
Backend/src/auth/routes/__pycache__/auth_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc
Normal file
BIN
Backend/src/auth/routes/__pycache__/user_routes.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/auth/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc
Normal file
BIN
Backend/src/auth/schemas/__pycache__/auth.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/schemas/__pycache__/user.cpython-312.pyc
Normal file
BIN
Backend/src/auth/schemas/__pycache__/user.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/auth/services/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/auth/services/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/bookings/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/bookings/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/bookings/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/bookings/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/bookings/models/__pycache__/booking.cpython-312.pyc
Normal file
BIN
Backend/src/bookings/models/__pycache__/booking.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/bookings/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/bookings/routes/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||
@@ -0,0 +1,9 @@
|
||||
from .booking import CreateBookingRequest, UpdateBookingRequest
|
||||
from .admin_booking import AdminCreateBookingRequest
|
||||
|
||||
__all__ = [
|
||||
'CreateBookingRequest',
|
||||
'UpdateBookingRequest',
|
||||
'AdminCreateBookingRequest',
|
||||
]
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/bookings/schemas/__pycache__/booking.cpython-312.pyc
Normal file
BIN
Backend/src/bookings/schemas/__pycache__/booking.cpython-312.pyc
Normal file
Binary file not shown.
92
Backend/src/bookings/schemas/admin_booking.py
Normal file
92
Backend/src/bookings/schemas/admin_booking.py
Normal 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
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
Backend/src/content/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/content/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/content/models/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/content/models/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/content/models/__pycache__/banner.cpython-312.pyc
Normal file
BIN
Backend/src/content/models/__pycache__/banner.cpython-312.pyc
Normal file
Binary file not shown.
BIN
Backend/src/content/models/__pycache__/blog.cpython-312.pyc
Normal file
BIN
Backend/src/content/models/__pycache__/blog.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Backend/src/content/routes/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/content/routes/__pycache__/__init__.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.
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.
BIN
Backend/src/content/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
Backend/src/content/schemas/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/content/schemas/__pycache__/blog.cpython-312.pyc
Normal file
BIN
Backend/src/content/schemas/__pycache__/blog.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Backend/src/content/schemas/__pycache__/privacy.cpython-312.pyc
Normal file
BIN
Backend/src/content/schemas/__pycache__/privacy.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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
116
Backend/src/guest_management/models/guest_complaint.py
Normal file
116
Backend/src/guest_management/models/guest_complaint.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Guest complaint management model.
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Enum, ForeignKey, DateTime, Boolean, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
from ...shared.config.database import Base
|
||||
|
||||
|
||||
class ComplaintStatus(str, enum.Enum):
|
||||
"""Complaint status enumeration."""
|
||||
open = 'open'
|
||||
in_progress = 'in_progress'
|
||||
resolved = 'resolved'
|
||||
closed = 'closed'
|
||||
escalated = 'escalated'
|
||||
|
||||
|
||||
class ComplaintPriority(str, enum.Enum):
|
||||
"""Complaint priority enumeration."""
|
||||
low = 'low'
|
||||
medium = 'medium'
|
||||
high = 'high'
|
||||
urgent = 'urgent'
|
||||
|
||||
|
||||
class ComplaintCategory(str, enum.Enum):
|
||||
"""Complaint category enumeration."""
|
||||
room_quality = 'room_quality'
|
||||
service = 'service'
|
||||
cleanliness = 'cleanliness'
|
||||
noise = 'noise'
|
||||
billing = 'billing'
|
||||
staff_behavior = 'staff_behavior'
|
||||
amenities = 'amenities'
|
||||
other = 'other'
|
||||
|
||||
|
||||
class GuestComplaint(Base):
|
||||
"""Model for guest complaints and their resolution."""
|
||||
__tablename__ = 'guest_complaints'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
|
||||
# Guest information
|
||||
guest_id = Column(Integer, ForeignKey('users.id'), nullable=False, index=True)
|
||||
booking_id = Column(Integer, ForeignKey('bookings.id'), nullable=True, index=True)
|
||||
room_id = Column(Integer, ForeignKey('rooms.id'), nullable=True, index=True)
|
||||
|
||||
# Complaint details
|
||||
category = Column(Enum(ComplaintCategory), nullable=False, index=True)
|
||||
priority = Column(Enum(ComplaintPriority), nullable=False, default=ComplaintPriority.medium, index=True)
|
||||
status = Column(Enum(ComplaintStatus), nullable=False, default=ComplaintStatus.open, index=True)
|
||||
|
||||
title = Column(String(255), nullable=False)
|
||||
description = Column(Text, nullable=False)
|
||||
|
||||
# Resolution
|
||||
resolution = Column(Text, nullable=True)
|
||||
resolved_at = Column(DateTime, nullable=True)
|
||||
resolved_by = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Assignment
|
||||
assigned_to = Column(Integer, ForeignKey('users.id'), nullable=True, index=True)
|
||||
escalated_to = Column(Integer, ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Tracking
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
closed_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Additional information
|
||||
guest_satisfaction_rating = Column(Integer, nullable=True) # 1-5 rating after resolution
|
||||
guest_feedback = Column(Text, nullable=True)
|
||||
internal_notes = Column(Text, nullable=True)
|
||||
attachments = Column(JSON, nullable=True) # Array of file paths/URLs
|
||||
|
||||
# Follow-up
|
||||
requires_follow_up = Column(Boolean, nullable=False, default=False)
|
||||
follow_up_date = Column(DateTime, nullable=True)
|
||||
follow_up_completed = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
# Relationships
|
||||
guest = relationship('User', foreign_keys=[guest_id])
|
||||
booking = relationship('Booking', foreign_keys=[booking_id])
|
||||
room = relationship('Room', foreign_keys=[room_id])
|
||||
assignee = relationship('User', foreign_keys=[assigned_to])
|
||||
resolver = relationship('User', foreign_keys=[resolved_by])
|
||||
escalator = relationship('User', foreign_keys=[escalated_to])
|
||||
|
||||
|
||||
class ComplaintUpdate(Base):
|
||||
"""Model for tracking complaint updates and communication."""
|
||||
__tablename__ = 'complaint_updates'
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
complaint_id = Column(Integer, ForeignKey('guest_complaints.id'), nullable=False, index=True)
|
||||
|
||||
# Update details
|
||||
update_type = Column(String(50), nullable=False) # 'status_change', 'assignment', 'note', 'resolution'
|
||||
description = Column(Text, nullable=False)
|
||||
|
||||
# Who made the update
|
||||
updated_by = Column(Integer, ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Timestamp
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Additional data
|
||||
update_metadata = Column(JSON, nullable=True) # Store additional context
|
||||
|
||||
# Relationships
|
||||
complaint = relationship('GuestComplaint', backref='updates')
|
||||
updater = relationship('User', foreign_keys=[updated_by])
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
437
Backend/src/guest_management/routes/complaint_routes.py
Normal file
437
Backend/src/guest_management/routes/complaint_routes.py
Normal file
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
Routes for guest complaint management.
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_, func
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ...shared.config.database import get_db
|
||||
from ...shared.config.logging_config import get_logger
|
||||
from ...security.middleware.auth import get_current_user, authorize_roles
|
||||
from ...auth.models.user import User
|
||||
from ..models.guest_complaint import (
|
||||
GuestComplaint, ComplaintStatus, ComplaintPriority, ComplaintCategory, ComplaintUpdate
|
||||
)
|
||||
from ..schemas.complaint import (
|
||||
CreateComplaintRequest, UpdateComplaintRequest,
|
||||
AddComplaintUpdateRequest, ResolveComplaintRequest
|
||||
)
|
||||
from ...shared.utils.response_helpers import success_response
|
||||
|
||||
logger = get_logger(__name__)
|
||||
router = APIRouter(prefix='/complaints', tags=['complaints'])
|
||||
|
||||
|
||||
@router.post('/')
|
||||
async def create_complaint(
|
||||
complaint_data: CreateComplaintRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new guest complaint."""
|
||||
try:
|
||||
# Verify booking ownership if booking_id provided
|
||||
if complaint_data.booking_id:
|
||||
from ...bookings.models.booking import Booking
|
||||
booking = db.query(Booking).filter(Booking.id == complaint_data.booking_id).first()
|
||||
if not booking:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail='Booking not found')
|
||||
|
||||
# Check if user owns the booking (unless admin/staff)
|
||||
from ...shared.utils.role_helpers import is_admin, is_staff
|
||||
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
||||
if booking.user_id != current_user.id:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=403, detail='Access denied')
|
||||
|
||||
complaint = GuestComplaint(
|
||||
guest_id=current_user.id,
|
||||
booking_id=complaint_data.booking_id,
|
||||
room_id=complaint_data.room_id,
|
||||
category=ComplaintCategory(complaint_data.category),
|
||||
priority=ComplaintPriority(complaint_data.priority),
|
||||
status=ComplaintStatus.open,
|
||||
title=complaint_data.title,
|
||||
description=complaint_data.description,
|
||||
attachments=complaint_data.attachments or []
|
||||
)
|
||||
|
||||
db.add(complaint)
|
||||
db.flush()
|
||||
|
||||
# Create initial update
|
||||
update = ComplaintUpdate(
|
||||
complaint_id=complaint.id,
|
||||
update_type='status_change',
|
||||
description=f'Complaint created: {complaint_data.title}',
|
||||
updated_by=current_user.id,
|
||||
update_metadata={'status': 'open', 'priority': complaint_data.priority}
|
||||
)
|
||||
db.add(update)
|
||||
|
||||
db.commit()
|
||||
db.refresh(complaint)
|
||||
|
||||
return success_response(
|
||||
data={'complaint': {
|
||||
'id': complaint.id,
|
||||
'title': complaint.title,
|
||||
'status': complaint.status.value,
|
||||
'priority': complaint.priority.value,
|
||||
'category': complaint.category.value,
|
||||
'created_at': complaint.created_at.isoformat()
|
||||
}},
|
||||
message='Complaint created successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error creating complaint: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while creating the complaint')
|
||||
|
||||
|
||||
@router.get('/')
|
||||
async def get_complaints(
|
||||
status: Optional[str] = Query(None),
|
||||
priority: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
assigned_to: Optional[int] = Query(None),
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get complaints with filtering."""
|
||||
try:
|
||||
from ...shared.utils.role_helpers import is_admin, is_staff
|
||||
|
||||
query = db.query(GuestComplaint)
|
||||
|
||||
# Filter by user role
|
||||
try:
|
||||
is_admin_user = is_admin(current_user, db)
|
||||
is_staff_user = is_staff(current_user, db)
|
||||
except Exception as role_error:
|
||||
logger.warning(f'Error checking user role: {str(role_error)}')
|
||||
is_admin_user = False
|
||||
is_staff_user = False
|
||||
|
||||
if not (is_admin_user or is_staff_user):
|
||||
# Customers can only see their own complaints
|
||||
query = query.filter(GuestComplaint.guest_id == current_user.id)
|
||||
elif assigned_to:
|
||||
# Staff/admin can filter by assignee
|
||||
query = query.filter(GuestComplaint.assigned_to == assigned_to)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
try:
|
||||
query = query.filter(GuestComplaint.status == ComplaintStatus(status))
|
||||
except ValueError:
|
||||
logger.warning(f'Invalid status filter: {status}')
|
||||
if priority:
|
||||
try:
|
||||
query = query.filter(GuestComplaint.priority == ComplaintPriority(priority))
|
||||
except ValueError:
|
||||
logger.warning(f'Invalid priority filter: {priority}')
|
||||
if category:
|
||||
try:
|
||||
query = query.filter(GuestComplaint.category == ComplaintCategory(category))
|
||||
except ValueError:
|
||||
logger.warning(f'Invalid category filter: {category}')
|
||||
|
||||
# Pagination
|
||||
total = query.count()
|
||||
offset = (page - 1) * limit
|
||||
complaints = query.order_by(GuestComplaint.created_at.desc()).offset(offset).limit(limit).all()
|
||||
|
||||
complaints_data = []
|
||||
for complaint in complaints:
|
||||
complaints_data.append({
|
||||
'id': complaint.id,
|
||||
'title': complaint.title,
|
||||
'category': complaint.category.value,
|
||||
'priority': complaint.priority.value,
|
||||
'status': complaint.status.value,
|
||||
'guest_id': complaint.guest_id,
|
||||
'booking_id': complaint.booking_id,
|
||||
'room_id': complaint.room_id,
|
||||
'assigned_to': complaint.assigned_to,
|
||||
'created_at': complaint.created_at.isoformat(),
|
||||
'updated_at': complaint.updated_at.isoformat()
|
||||
})
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'complaints': complaints_data,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total': total,
|
||||
'total_pages': (total + limit - 1) // limit
|
||||
}
|
||||
},
|
||||
message='Complaints retrieved successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error retrieving complaints: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f'An error occurred while retrieving complaints: {str(e)}')
|
||||
|
||||
|
||||
@router.get('/{complaint_id}')
|
||||
async def get_complaint(
|
||||
complaint_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific complaint with details."""
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
# Check access
|
||||
from ...shared.utils.role_helpers import is_admin, is_staff
|
||||
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
||||
if complaint.guest_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail='Access denied')
|
||||
|
||||
# Get updates
|
||||
updates = db.query(ComplaintUpdate).filter(
|
||||
ComplaintUpdate.complaint_id == complaint_id
|
||||
).order_by(ComplaintUpdate.created_at.asc()).all()
|
||||
|
||||
complaint_data = {
|
||||
'id': complaint.id,
|
||||
'title': complaint.title,
|
||||
'description': complaint.description,
|
||||
'category': complaint.category.value,
|
||||
'priority': complaint.priority.value,
|
||||
'status': complaint.status.value,
|
||||
'guest_id': complaint.guest_id,
|
||||
'booking_id': complaint.booking_id,
|
||||
'room_id': complaint.room_id,
|
||||
'assigned_to': complaint.assigned_to,
|
||||
'resolution': complaint.resolution,
|
||||
'resolved_at': complaint.resolved_at.isoformat() if complaint.resolved_at else None,
|
||||
'resolved_by': complaint.resolved_by,
|
||||
'guest_satisfaction_rating': complaint.guest_satisfaction_rating,
|
||||
'guest_feedback': complaint.guest_feedback,
|
||||
'internal_notes': complaint.internal_notes if (is_admin(current_user, db) or is_staff(current_user, db)) else None,
|
||||
'attachments': complaint.attachments,
|
||||
'requires_follow_up': complaint.requires_follow_up,
|
||||
'follow_up_date': complaint.follow_up_date.isoformat() if complaint.follow_up_date else None,
|
||||
'created_at': complaint.created_at.isoformat(),
|
||||
'updated_at': complaint.updated_at.isoformat(),
|
||||
'updates': [{
|
||||
'id': u.id,
|
||||
'update_type': u.update_type,
|
||||
'description': u.description,
|
||||
'updated_by': u.updated_by,
|
||||
'created_at': u.created_at.isoformat()
|
||||
} for u in updates]
|
||||
}
|
||||
|
||||
return success_response(
|
||||
data={'complaint': complaint_data},
|
||||
message='Complaint retrieved successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f'Error retrieving complaint: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while retrieving complaint')
|
||||
|
||||
|
||||
@router.put('/{complaint_id}')
|
||||
async def update_complaint(
|
||||
complaint_id: int,
|
||||
update_data: UpdateComplaintRequest,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a complaint (admin/staff only)."""
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
# Track changes
|
||||
changes = []
|
||||
|
||||
if update_data.status:
|
||||
old_status = complaint.status.value
|
||||
complaint.status = ComplaintStatus(update_data.status)
|
||||
changes.append(f'Status changed from {old_status} to {update_data.status}')
|
||||
|
||||
if update_data.status == 'resolved' and not complaint.resolved_at:
|
||||
complaint.resolved_at = datetime.utcnow()
|
||||
complaint.resolved_by = current_user.id
|
||||
|
||||
if update_data.priority:
|
||||
old_priority = complaint.priority.value
|
||||
complaint.priority = ComplaintPriority(update_data.priority)
|
||||
changes.append(f'Priority changed from {old_priority} to {update_data.priority}')
|
||||
|
||||
if update_data.assigned_to is not None:
|
||||
old_assignee = complaint.assigned_to
|
||||
complaint.assigned_to = update_data.assigned_to
|
||||
changes.append(f'Assigned to user {update_data.assigned_to}')
|
||||
|
||||
if update_data.resolution:
|
||||
complaint.resolution = update_data.resolution
|
||||
|
||||
if update_data.internal_notes is not None:
|
||||
complaint.internal_notes = update_data.internal_notes
|
||||
|
||||
if update_data.requires_follow_up is not None:
|
||||
complaint.requires_follow_up = update_data.requires_follow_up
|
||||
|
||||
if update_data.follow_up_date:
|
||||
complaint.follow_up_date = datetime.fromisoformat(update_data.follow_up_date.replace('Z', '+00:00'))
|
||||
|
||||
# Create update record
|
||||
if changes:
|
||||
update = ComplaintUpdate(
|
||||
complaint_id=complaint.id,
|
||||
update_type='status_change' if update_data.status else 'note',
|
||||
description='; '.join(changes),
|
||||
updated_by=current_user.id
|
||||
)
|
||||
db.add(update)
|
||||
|
||||
db.commit()
|
||||
db.refresh(complaint)
|
||||
|
||||
return success_response(
|
||||
data={'complaint': {
|
||||
'id': complaint.id,
|
||||
'status': complaint.status.value,
|
||||
'priority': complaint.priority.value,
|
||||
'assigned_to': complaint.assigned_to
|
||||
}},
|
||||
message='Complaint updated successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error updating complaint: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while updating complaint')
|
||||
|
||||
|
||||
@router.post('/{complaint_id}/resolve')
|
||||
async def resolve_complaint(
|
||||
complaint_id: int,
|
||||
resolve_data: ResolveComplaintRequest,
|
||||
current_user: User = Depends(authorize_roles('admin', 'staff')),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Resolve a complaint."""
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
complaint.status = ComplaintStatus.resolved
|
||||
complaint.resolution = resolve_data.resolution
|
||||
complaint.resolved_at = datetime.utcnow()
|
||||
complaint.resolved_by = current_user.id
|
||||
|
||||
if resolve_data.guest_satisfaction_rating:
|
||||
complaint.guest_satisfaction_rating = resolve_data.guest_satisfaction_rating
|
||||
if resolve_data.guest_feedback:
|
||||
complaint.guest_feedback = resolve_data.guest_feedback
|
||||
|
||||
# Create update record
|
||||
update = ComplaintUpdate(
|
||||
complaint_id=complaint.id,
|
||||
update_type='resolution',
|
||||
description=f'Complaint resolved: {resolve_data.resolution}',
|
||||
updated_by=current_user.id,
|
||||
update_metadata={
|
||||
'satisfaction_rating': resolve_data.guest_satisfaction_rating,
|
||||
'guest_feedback': resolve_data.guest_feedback
|
||||
}
|
||||
)
|
||||
db.add(update)
|
||||
|
||||
db.commit()
|
||||
db.refresh(complaint)
|
||||
|
||||
return success_response(
|
||||
data={'complaint': {
|
||||
'id': complaint.id,
|
||||
'status': complaint.status.value,
|
||||
'resolved_at': complaint.resolved_at.isoformat()
|
||||
}},
|
||||
message='Complaint resolved successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error resolving complaint: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while resolving complaint')
|
||||
|
||||
|
||||
@router.post('/{complaint_id}/updates')
|
||||
async def add_complaint_update(
|
||||
complaint_id: int,
|
||||
update_data: AddComplaintUpdateRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Add an update to a complaint."""
|
||||
try:
|
||||
complaint = db.query(GuestComplaint).filter(GuestComplaint.id == complaint_id).first()
|
||||
if not complaint:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=404, detail='Complaint not found')
|
||||
|
||||
# Check access
|
||||
from ...shared.utils.role_helpers import is_admin, is_staff
|
||||
if not (is_admin(current_user, db) or is_staff(current_user, db)):
|
||||
if complaint.guest_id != current_user.id:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=403, detail='Access denied')
|
||||
|
||||
update = ComplaintUpdate(
|
||||
complaint_id=complaint_id,
|
||||
update_type=update_data.update_type,
|
||||
description=update_data.description,
|
||||
updated_by=current_user.id,
|
||||
update_metadata=update_data.metadata or {}
|
||||
)
|
||||
|
||||
db.add(update)
|
||||
db.commit()
|
||||
db.refresh(update)
|
||||
|
||||
return success_response(
|
||||
data={'update': {
|
||||
'id': update.id,
|
||||
'update_type': update.update_type,
|
||||
'description': update.description,
|
||||
'created_at': update.created_at.isoformat()
|
||||
}},
|
||||
message='Update added successfully'
|
||||
)
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f'Error adding complaint update: {str(e)}', exc_info=True)
|
||||
raise HTTPException(status_code=500, detail='An error occurred while adding update')
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user