From b5698b6018eb83e0da64e1cadebdd5b3634e9480 Mon Sep 17 00:00:00 2001 From: Iliyan Angelov Date: Fri, 28 Nov 2025 14:36:37 +0200 Subject: [PATCH] updates --- .../__pycache__/csrf.cpython-312.pyc | Bin 6051 -> 6051 bytes .../__pycache__/security.cpython-312.pyc | Bin 2109 -> 2243 bytes Backend/src/middleware/security.py | 5 +- .../__pycache__/review_routes.cpython-312.pyc | Bin 12131 -> 14494 bytes Backend/src/routes/review_routes.py | 166 +++++++++++++++--- .../__pycache__/review.cpython-312.pyc | Bin 1083 -> 1084 bytes Backend/src/schemas/review.py | 2 +- Frontend/index.html | 2 +- Frontend/src/App.tsx | 5 + .../src/components/layout/SidebarAdmin.tsx | 8 +- .../src/components/rooms/ReviewSection.tsx | 31 +++- .../src/pages/admin/ReviewManagementPage.tsx | 7 +- 12 files changed, 191 insertions(+), 35 deletions(-) diff --git a/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc b/Backend/src/middleware/__pycache__/csrf.cpython-312.pyc index 4566c0bea2758af81702f878b0e653e66c61524a..cbc3c83b1b03b7c5546e0265eafcbca2fe36506b 100644 GIT binary patch delta 18 YcmZ3izgVC1G%qg~0}wpi$T>?K05F3D=>Px# delta 18 YcmZ3izgVC1G%qg~0}w3P$T>?K054JmtN;K2 diff --git a/Backend/src/middleware/__pycache__/security.cpython-312.pyc b/Backend/src/middleware/__pycache__/security.cpython-312.pyc index e2b4d27ed022e377953886a7653813d4c3f0091f..9318f5c6161de29ac8f4b229a2b2b16bb54bf6c3 100644 GIT binary patch delta 246 zcmdlha9EJwa=E8dHQgG3X{QPWWJ=O|oMTxnoy2VAw3hKqFIce%}r6^{@MKKLXN-R#*EiK9f z>)));V$CGLEvWN>nSodKf~?s^9`g@MldIUG8Eq$DW6R}`XO&ox{)It)vN!u90YyfM P3kpVG8Gv+=640XnC6-T` delta 94 zcmX>sxL1JhG%qg~0}z~iu92C|v5_x|nNfanCbQe*qs)bym$7&;32+JOd|+namAxQq wc9F;YgTf^CXh!SFG3>by(!VgsO}@zfNI-#6;(~(FR|X(mqy#h&01M+9Z2$lO 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 9717940f2f6e34a535f165bbed24860eed897791..b5b663288a51b490c1e8a7331d71002b31457ade 100644 GIT binary patch literal 14494 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 Zbl%ayL(7i(^Nw->c6(P?^DQ{Ke*@4o7T5p) 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 fc2f5e49928a2b45326f24993dcbf1d6b2e2c763..5a984d9796988229eeee5019c7db9ab598371e48 100644 GIT binary patch delta 227 zcmdnZv4?~AG%qg~0}!09*36tgk@qrV*TjcW#!M;fDLg5>Yxq{PfFv0hqBv8zQrT1Z zZGh|)ffPZYJV%O9itrrfOvX&cDDFy;$wiFQlrt}|GcZ)~1*Mi{rj{!t=jZ08=9OqN z-{Q*6%!|)S%}X!In5@8*%q|2p#!r)Hawk(TqtN6>OxcWSll_>DM1>`02(J*_P;$XD z@``X&gL_BtWtOPP)0vg|riavtL}3BSw>>m zk~?EBvxHBc!K}Us`o&?Bo1apelWJF_IGLA4 In*}5T05{Dy;s5{u 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}
+ )}