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

View File

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

View File

@@ -0,0 +1,98 @@
"""
Pydantic schemas for guest complaint management.
"""
from pydantic import BaseModel, Field, field_validator
from typing import Optional, List
from datetime import datetime
class CreateComplaintRequest(BaseModel):
"""Schema for creating a guest complaint."""
booking_id: Optional[int] = Field(None, gt=0, description="Related booking ID")
room_id: Optional[int] = Field(None, gt=0, description="Related room ID")
category: str = Field(..., description="Complaint category")
priority: str = Field("medium", description="Complaint priority")
title: str = Field(..., min_length=1, max_length=255, description="Complaint title")
description: str = Field(..., min_length=1, description="Complaint description")
attachments: Optional[List[str]] = Field(default_factory=list, description="Attachment URLs")
@field_validator('category')
@classmethod
def validate_category(cls, v: str) -> str:
"""Validate complaint category."""
allowed = ['room_quality', 'service', 'cleanliness', 'noise', 'billing', 'staff_behavior', 'amenities', 'other']
if v not in allowed:
raise ValueError(f'Category must be one of: {", ".join(allowed)}')
return v
@field_validator('priority')
@classmethod
def validate_priority(cls, v: str) -> str:
"""Validate complaint priority."""
allowed = ['low', 'medium', 'high', 'urgent']
if v not in allowed:
raise ValueError(f'Priority must be one of: {", ".join(allowed)}')
return v
class UpdateComplaintRequest(BaseModel):
"""Schema for updating a complaint."""
status: Optional[str] = Field(None, description="New status")
priority: Optional[str] = Field(None, description="New priority")
assigned_to: Optional[int] = Field(None, gt=0, description="Assign to user ID")
resolution: Optional[str] = Field(None, description="Resolution details")
internal_notes: Optional[str] = Field(None, description="Internal notes")
requires_follow_up: Optional[bool] = Field(None, description="Requires follow-up")
follow_up_date: Optional[str] = Field(None, description="Follow-up date (ISO format)")
@field_validator('status')
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
"""Validate complaint status."""
if v:
allowed = ['open', 'in_progress', 'resolved', 'closed', 'escalated']
if v not in allowed:
raise ValueError(f'Status must be one of: {", ".join(allowed)}')
return v
@field_validator('priority')
@classmethod
def validate_priority(cls, v: Optional[str]) -> Optional[str]:
"""Validate complaint priority."""
if v:
allowed = ['low', 'medium', 'high', 'urgent']
if v not in allowed:
raise ValueError(f'Priority must be one of: {", ".join(allowed)}')
return v
class AddComplaintUpdateRequest(BaseModel):
"""Schema for adding an update to a complaint."""
update_type: str = Field(..., description="Type of update")
description: str = Field(..., min_length=1, description="Update description")
metadata: Optional[dict] = Field(default_factory=dict, description="Additional metadata")
@field_validator('update_type')
@classmethod
def validate_update_type(cls, v: str) -> str:
"""Validate update type."""
allowed = ['status_change', 'assignment', 'note', 'resolution', 'escalation']
if v not in allowed:
raise ValueError(f'Update type must be one of: {", ".join(allowed)}')
return v
class ResolveComplaintRequest(BaseModel):
"""Schema for resolving a complaint."""
resolution: str = Field(..., min_length=1, description="Resolution details")
guest_satisfaction_rating: Optional[int] = Field(None, ge=1, le=5, description="Guest satisfaction rating (1-5)")
guest_feedback: Optional[str] = Field(None, description="Guest feedback on resolution")
@field_validator('guest_satisfaction_rating')
@classmethod
def validate_rating(cls, v: Optional[int]) -> Optional[int]:
"""Validate rating."""
if v is not None and (v < 1 or v > 5):
raise ValueError('Rating must be between 1 and 5')
return v