diff --git a/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc b/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc index 4566c0be..cbc3c83b 100644 Binary files a/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc and b/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc differ diff --git a/Backend/src/middleware/__pycache__/security.cpython-312.pyc b/Backend/src/middleware/__pycache__/security.cpython-312.pyc index e2b4d27e..9318f5c6 100644 Binary files a/Backend/src/middleware/__pycache__/security.cpython-312.pyc and b/Backend/src/middleware/__pycache__/security.cpython-312.pyc differ diff --git a/Backend/src/middleware/security.py b/Backend/src/middleware/security.py index 4f6b9bb3..ae692cdc 100644 --- a/Backend/src/middleware/security.py +++ b/Backend/src/middleware/security.py @@ -17,11 +17,12 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware): # Consider moving to nonces/hashes in future for stricter policy security_headers['Content-Security-Policy'] = ( "default-src 'self'; " - "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com; " "style-src 'self' 'unsafe-inline'; " "img-src 'self' data: https:; " "font-src 'self' data:; " - "connect-src 'self' https:; " + "connect-src 'self' https: https://js.stripe.com https://hooks.stripe.com; " + "frame-src 'self' https://js.stripe.com https://hooks.stripe.com; " "base-uri 'self'; " "form-action 'self'; " "frame-ancestors 'none'; " diff --git a/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc b/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc index 9717940f..b5b66328 100644 Binary files a/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc and b/Backend/src/routes/__pycache__/review_routes.cpython-312.pyc differ diff --git a/Backend/src/routes/review_routes.py b/Backend/src/routes/review_routes.py index dcc9bc74..525cfb46 100644 --- a/Backend/src/routes/review_routes.py +++ b/Backend/src/routes/review_routes.py @@ -1,6 +1,7 @@ from fastapi import APIRouter, Depends, HTTPException, status, Query -from ..utils.response_helpers import success_response -from sqlalchemy.orm import Session +from ..utils.response_helpers import success_response, error_response +from sqlalchemy.orm import Session, joinedload +from sqlalchemy import func from typing import Optional from ..config.database import get_db from ..config.logging_config import get_logger @@ -21,20 +22,49 @@ async def get_room_reviews( db: Session = Depends(get_db) ): try: - query = db.query(Review).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved) + # Calculate average rating and total reviews for approved reviews + stats = db.query( + func.avg(Review.rating).label('average_rating'), + func.count(Review.id).label('total_reviews') + ).filter( + Review.room_id == room_id, + Review.status == ReviewStatus.approved + ).first() + + average_rating = round(float(stats.average_rating or 0), 1) if stats and stats.average_rating else 0 + total_reviews = stats.total_reviews or 0 if stats else 0 + + query = db.query(Review).options( + joinedload(Review.user) + ).filter(Review.room_id == room_id, Review.status == ReviewStatus.approved) total = query.count() offset = (page - 1) * limit reviews = query.order_by(Review.created_at.desc()).offset(offset).limit(limit).all() result = [] for review in reviews: - review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} + review_dict = { + 'id': review.id, + 'user_id': review.user_id, + 'room_id': review.room_id, + 'rating': review.rating, + 'comment': review.comment, + 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, + 'created_at': review.created_at.isoformat() if review.created_at else None + } if review.user: - review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email} + review_dict['user'] = { + 'id': review.user.id, + 'full_name': review.user.full_name, + 'name': review.user.full_name, # Add name alias for compatibility + 'email': review.user.email + } result.append(review_dict) return { 'status': 'success', 'data': { 'reviews': result, + 'average_rating': average_rating, + 'total_reviews': total_reviews, 'pagination': { 'total': total, 'page': page, @@ -51,7 +81,10 @@ async def get_room_reviews( @router.get('/', dependencies=[Depends(authorize_roles('admin'))]) async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status'), page: int=Query(1, ge=1), limit: int=Query(10, ge=1, le=100), current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: - query = db.query(Review) + query = db.query(Review).options( + joinedload(Review.user), + joinedload(Review.room).joinedload(Room.room_type) + ) if status_filter: try: query = query.filter(Review.status == ReviewStatus(status_filter)) @@ -64,9 +97,25 @@ async def get_all_reviews(status_filter: Optional[str]=Query(None, alias='status for review in reviews: review_dict = {'id': review.id, 'user_id': review.user_id, 'room_id': review.room_id, 'rating': review.rating, 'comment': review.comment, 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, 'created_at': review.created_at.isoformat() if review.created_at else None} if review.user: - review_dict['user'] = {'id': review.user.id, 'full_name': review.user.full_name, 'email': review.user.email, 'phone': review.user.phone} + review_dict['user'] = { + 'id': review.user.id, + 'full_name': review.user.full_name, + 'name': review.user.full_name, # Add name alias for frontend compatibility + 'email': review.user.email, + 'phone': review.user.phone + } if review.room: - review_dict['room'] = {'id': review.room.id, 'room_number': review.room.room_number} + room_dict = { + 'id': review.room.id, + 'room_number': review.room.room_number + } + # Include room type if available + if review.room.room_type: + room_dict['room_type'] = { + 'id': review.room.room_type.id, + 'name': review.room.room_type.name + } + review_dict['room'] = room_dict result.append(review_dict) return {'status': 'success', 'data': {'reviews': result, 'pagination': {'total': total, 'page': page, 'limit': limit, 'totalPages': (total + limit - 1) // limit}}} except Exception as e: @@ -79,58 +128,131 @@ async def create_review(review_data: CreateReviewRequest, current_user: User=Dep try: room_id = review_data.room_id rating = review_data.rating - comment = review_data.comment + # Ensure comment is a string (not None) since the model requires it + comment = review_data.comment if review_data.comment else '' + room = db.query(Room).filter(Room.id == room_id).first() if not room: - raise HTTPException(status_code=404, detail='Room not found') + raise HTTPException( + status_code=404, + detail=error_response(message='Room not found') + ) + existing = db.query(Review).filter(Review.user_id == current_user.id, Review.room_id == room_id).first() if existing: - raise HTTPException(status_code=400, detail='You have already reviewed this room') - review = Review(user_id=current_user.id, room_id=room_id, rating=rating, comment=comment, status=ReviewStatus.pending) + raise HTTPException( + status_code=400, + detail=error_response(message='You have already reviewed this room') + ) + + review = Review( + user_id=current_user.id, + room_id=room_id, + rating=rating, + comment=comment or '', # Ensure comment is a string + status=ReviewStatus.pending + ) db.add(review) db.commit() db.refresh(review) - return {'status': 'success', 'message': 'Review submitted successfully and is pending approval', 'data': {'review': review}} + + review_dict = { + 'id': review.id, + 'user_id': review.user_id, + 'room_id': review.room_id, + 'rating': review.rating, + 'comment': review.comment, + 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, + 'created_at': review.created_at.isoformat() if review.created_at else None + } + + return success_response( + data={'review': review_dict}, + message='Review submitted successfully and is pending approval' + ) except HTTPException: raise except Exception as e: db.rollback() logger.error(f'Error creating review: {str(e)}', exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException( + status_code=500, + detail=error_response(message='An error occurred while creating the review') + ) -@router.put('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))]) +@router.patch('/{id}/approve', dependencies=[Depends(authorize_roles('admin'))]) async def approve_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: review = db.query(Review).filter(Review.id == id).first() if not review: - raise HTTPException(status_code=404, detail='Review not found') + raise HTTPException( + status_code=404, + detail=error_response(message='Review not found') + ) review.status = ReviewStatus.approved db.commit() db.refresh(review) - return {'status': 'success', 'message': 'Review approved successfully', 'data': {'review': review}} + + review_dict = { + 'id': review.id, + 'user_id': review.user_id, + 'room_id': review.room_id, + 'rating': review.rating, + 'comment': review.comment, + 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, + 'created_at': review.created_at.isoformat() if review.created_at else None + } + + return success_response( + data={'review': review_dict}, + message='Review approved successfully' + ) except HTTPException: raise except Exception as e: db.rollback() logger.error(f'Error approving review: {str(e)}', exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException( + status_code=500, + detail=error_response(message='An error occurred while approving the review') + ) -@router.put('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))]) +@router.patch('/{id}/reject', dependencies=[Depends(authorize_roles('admin'))]) async def reject_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): try: review = db.query(Review).filter(Review.id == id).first() if not review: - raise HTTPException(status_code=404, detail='Review not found') + raise HTTPException( + status_code=404, + detail=error_response(message='Review not found') + ) review.status = ReviewStatus.rejected db.commit() db.refresh(review) - return {'status': 'success', 'message': 'Review rejected successfully', 'data': {'review': review}} + + review_dict = { + 'id': review.id, + 'user_id': review.user_id, + 'room_id': review.room_id, + 'rating': review.rating, + 'comment': review.comment, + 'status': review.status.value if isinstance(review.status, ReviewStatus) else review.status, + 'created_at': review.created_at.isoformat() if review.created_at else None + } + + return success_response( + data={'review': review_dict}, + message='Review rejected successfully' + ) except HTTPException: raise except Exception as e: db.rollback() logger.error(f'Error rejecting review: {str(e)}', exc_info=True) - raise HTTPException(status_code=500, detail=str(e)) + raise HTTPException( + status_code=500, + detail=error_response(message='An error occurred while rejecting the review') + ) @router.delete('/{id}', dependencies=[Depends(authorize_roles('admin'))]) async def delete_review(id: int, current_user: User=Depends(authorize_roles('admin')), db: Session=Depends(get_db)): diff --git a/Backend/src/schemas/__pycache__/review.cpython-312.pyc b/Backend/src/schemas/__pycache__/review.cpython-312.pyc index fc2f5e49..5a984d97 100644 Binary files a/Backend/src/schemas/__pycache__/review.cpython-312.pyc and b/Backend/src/schemas/__pycache__/review.cpython-312.pyc differ diff --git a/Backend/src/schemas/review.py b/Backend/src/schemas/review.py index 3604d123..11c94fd5 100644 --- a/Backend/src/schemas/review.py +++ b/Backend/src/schemas/review.py @@ -9,7 +9,7 @@ class CreateReviewRequest(BaseModel): """Schema for creating a review.""" room_id: int = Field(..., gt=0, description="Room ID") rating: int = Field(..., ge=1, le=5, description="Rating from 1 to 5") - comment: Optional[str] = Field(None, max_length=2000, description="Review comment") + comment: str = Field(..., min_length=1, max_length=2000, description="Review comment") model_config = { "json_schema_extra": { diff --git a/Frontend/index.html b/Frontend/index.html index d1a275e3..0493bcde 100644 --- a/Frontend/index.html +++ b/Frontend/index.html @@ -7,7 +7,7 @@ - + diff --git a/Frontend/src/App.tsx b/Frontend/src/App.tsx index d32f3a1e..935647a0 100644 --- a/Frontend/src/App.tsx +++ b/Frontend/src/App.tsx @@ -86,6 +86,7 @@ const RatePlanManagementPage = lazy(() => import('./pages/admin/RatePlanManageme const PackageManagementPage = lazy(() => import('./pages/admin/PackageManagementPage')); const SecurityManagementPage = lazy(() => import('./pages/admin/SecurityManagementPage')); const EmailCampaignManagementPage = lazy(() => import('./pages/admin/EmailCampaignManagementPage')); +const ReviewManagementPage = lazy(() => import('./pages/admin/ReviewManagementPage')); const StaffDashboardPage = lazy(() => import('./pages/staff/DashboardPage')); const StaffBookingManagementPage = lazy(() => import('./pages/staff/BookingManagementPage')); @@ -464,6 +465,10 @@ function App() { path="email-campaigns" element={} /> + } + /> {} diff --git a/Frontend/src/components/layout/SidebarAdmin.tsx b/Frontend/src/components/layout/SidebarAdmin.tsx index 949b0071..408ac659 100644 --- a/Frontend/src/components/layout/SidebarAdmin.tsx +++ b/Frontend/src/components/layout/SidebarAdmin.tsx @@ -26,7 +26,8 @@ import { Mail, TrendingUp, Building2, - Crown + Crown, + Star } from 'lucide-react'; import useAuthStore from '../../store/useAuthStore'; import { useResponsive } from '../../hooks'; @@ -212,6 +213,11 @@ const SidebarAdmin: React.FC = ({ icon: Globe, label: 'Page Content' }, + { + path: '/admin/reviews', + icon: Star, + label: 'Reviews' + }, ] }, { diff --git a/Frontend/src/components/rooms/ReviewSection.tsx b/Frontend/src/components/rooms/ReviewSection.tsx index fc3e5a04..c6461c29 100644 --- a/Frontend/src/components/rooms/ReviewSection.tsx +++ b/Frontend/src/components/rooms/ReviewSection.tsx @@ -74,9 +74,22 @@ const ReviewSection: React.FC = ({ setLoading(true); const response = await getRoomReviews(roomId); if (response.status === 'success' && response.data) { - setReviews(response.data.reviews || []); - setAverageRating(response.data.average_rating || 0); - setTotalReviews(response.data.total_reviews || 0); + const reviewsList = response.data.reviews || []; + setReviews(reviewsList); + // Use backend values, but fallback to calculated values if backend doesn't provide them + const backendTotal = response.data.total_reviews || 0; + const backendAverage = response.data.average_rating || 0; + + // If backend doesn't provide totals but we have reviews, calculate them + if (backendTotal === 0 && reviewsList.length > 0) { + const calculatedTotal = reviewsList.length; + const calculatedAverage = reviewsList.reduce((sum, r) => sum + r.rating, 0) / calculatedTotal; + setTotalReviews(calculatedTotal); + setAverageRating(calculatedAverage); + } else { + setTotalReviews(backendTotal); + setAverageRating(backendAverage); + } } } catch (error) { console.error('Error fetching reviews:', error); @@ -153,9 +166,11 @@ const ReviewSection: React.FC = ({
- {averageRating > 0 + {totalReviews > 0 && averageRating > 0 ? averageRating.toFixed(1) - : 'N/A'} + : totalReviews === 0 + ? '—' + : averageRating.toFixed(1)}
= ({ />
- {totalReviews} review{totalReviews !== 1 ? 's' : ''} + {totalReviews > 0 + ? `${totalReviews} review${totalReviews !== 1 ? 's' : ''}` + : 'No reviews yet'}
@@ -274,7 +291,7 @@ const ReviewSection: React.FC = ({ {}

- All Reviews ({totalReviews}) + All Reviews ({reviews.length > 0 ? reviews.length : totalReviews})

{loading ? ( diff --git a/Frontend/src/pages/admin/ReviewManagementPage.tsx b/Frontend/src/pages/admin/ReviewManagementPage.tsx index da8f99cd..db68e0fb 100644 --- a/Frontend/src/pages/admin/ReviewManagementPage.tsx +++ b/Frontend/src/pages/admin/ReviewManagementPage.tsx @@ -146,7 +146,12 @@ const ReviewManagementPage: React.FC = () => { {reviews.map((review) => ( -
{review.user?.name}
+
+ {review.user?.full_name || review.user?.name || 'Unknown User'} +
+ {review.user?.email && ( +
{review.user.email}
+ )}