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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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.

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

View 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])

View 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')

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