- Backend: Review model, full API (public/user/admin endpoints) - Frontend: list, detail, write/edit pages, admin management - 1 CC reward for writing a review on completed vehicle requests - Navigation updates (Header + admin sidebar) - "Write Review" button on my-request page for completed requests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
475 lines
14 KiB
Python
475 lines
14 KiB
Python
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
from sqlalchemy import desc
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional, List
|
|
from datetime import datetime
|
|
|
|
from ..database import get_db
|
|
from ..models import User, VehicleRequest, Review, RequestVehicle
|
|
from .auth import get_current_user
|
|
from .notification import notify_system
|
|
|
|
router = APIRouter(prefix="/reviews", tags=["reviews"])
|
|
|
|
|
|
# =====================
|
|
# Pydantic Schemas
|
|
# =====================
|
|
|
|
class ReviewCreate(BaseModel):
|
|
request_id: int
|
|
rating: int = Field(..., ge=1, le=5)
|
|
title: str = Field(..., max_length=255)
|
|
content: str
|
|
|
|
|
|
class ReviewUpdate(BaseModel):
|
|
rating: Optional[int] = Field(None, ge=1, le=5)
|
|
title: Optional[str] = Field(None, max_length=255)
|
|
content: Optional[str] = None
|
|
|
|
|
|
class VehicleInfo(BaseModel):
|
|
car_id: Optional[int] = None
|
|
car_name: Optional[str] = None
|
|
main_image: Optional[str] = None
|
|
year: Optional[int] = None
|
|
mileage: Optional[int] = None
|
|
fuel: Optional[str] = None
|
|
final_price: Optional[int] = None
|
|
|
|
|
|
class ReviewListItem(BaseModel):
|
|
id: int
|
|
rating: int
|
|
title: str
|
|
content_preview: str
|
|
author_name: Optional[str] = None
|
|
main_image: Optional[str] = None
|
|
view_count: int
|
|
created_at: datetime
|
|
|
|
class Config:
|
|
from_attributes = True
|
|
|
|
|
|
class ReviewDetail(BaseModel):
|
|
id: int
|
|
request_id: int
|
|
rating: int
|
|
title: str
|
|
content: str
|
|
author_id: int
|
|
author_name: Optional[str] = None
|
|
author_email: Optional[str] = None
|
|
cc_rewarded: bool
|
|
is_published: bool
|
|
view_count: int
|
|
vehicles: List[VehicleInfo] = []
|
|
created_at: datetime
|
|
updated_at: Optional[datetime] = None
|
|
|
|
|
|
class ReviewListResponse(BaseModel):
|
|
reviews: List[ReviewListItem]
|
|
total: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
|
|
|
|
class ReviewableRequestItem(BaseModel):
|
|
request_id: int
|
|
maker_name: Optional[str] = None
|
|
model_name: Optional[str] = None
|
|
grade_name: Optional[str] = None
|
|
status: str
|
|
completed_at: Optional[datetime] = None
|
|
vehicle_count: int = 0
|
|
main_image: Optional[str] = None
|
|
|
|
|
|
class AdminReviewListItem(BaseModel):
|
|
id: int
|
|
rating: int
|
|
title: str
|
|
author_name: Optional[str] = None
|
|
author_email: Optional[str] = None
|
|
is_published: bool
|
|
cc_rewarded: bool
|
|
view_count: int
|
|
created_at: datetime
|
|
|
|
|
|
# =====================
|
|
# Helper Functions
|
|
# =====================
|
|
|
|
def _extract_vehicles(request: VehicleRequest) -> List[VehicleInfo]:
|
|
"""Extract vehicle info from a request's recommended vehicles"""
|
|
vehicles = []
|
|
for rv in request.recommended_vehicles:
|
|
if not rv.is_approved:
|
|
continue
|
|
car_data = rv.car_data or {}
|
|
vehicles.append(VehicleInfo(
|
|
car_id=rv.car_id or car_data.get("local_car_id"),
|
|
car_name=car_data.get("car_name"),
|
|
main_image=car_data.get("main_image"),
|
|
year=car_data.get("year"),
|
|
mileage=car_data.get("mileage"),
|
|
fuel=car_data.get("fuel"),
|
|
final_price=car_data.get("final_price"),
|
|
))
|
|
return vehicles
|
|
|
|
|
|
def _get_main_image(request: VehicleRequest) -> Optional[str]:
|
|
"""Get the first approved vehicle's main image"""
|
|
for rv in request.recommended_vehicles:
|
|
if rv.is_approved and rv.car_data:
|
|
img = rv.car_data.get("main_image")
|
|
if img:
|
|
return img
|
|
return None
|
|
|
|
|
|
# =====================
|
|
# Public Endpoints
|
|
# =====================
|
|
|
|
@router.get("/", response_model=ReviewListResponse)
|
|
def get_reviews(
|
|
page: int = Query(1, ge=1),
|
|
page_size: int = Query(12, ge=1, le=50),
|
|
rating: Optional[int] = Query(None, ge=1, le=5),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get published reviews list"""
|
|
query = db.query(Review).filter(Review.is_published == True)
|
|
|
|
if rating:
|
|
query = query.filter(Review.rating == rating)
|
|
|
|
total = query.count()
|
|
total_pages = (total + page_size - 1) // page_size
|
|
|
|
reviews = query.order_by(desc(Review.created_at)) \
|
|
.offset((page - 1) * page_size) \
|
|
.limit(page_size) \
|
|
.all()
|
|
|
|
items = []
|
|
for r in reviews:
|
|
main_image = _get_main_image(r.request) if r.request else None
|
|
items.append(ReviewListItem(
|
|
id=r.id,
|
|
rating=r.rating,
|
|
title=r.title,
|
|
content_preview=r.content[:150] + ("..." if len(r.content) > 150 else ""),
|
|
author_name=r.author.name if r.author else None,
|
|
main_image=main_image,
|
|
view_count=r.view_count,
|
|
created_at=r.created_at,
|
|
))
|
|
|
|
return ReviewListResponse(
|
|
reviews=items,
|
|
total=total,
|
|
page=page,
|
|
page_size=page_size,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
|
|
@router.get("/my/reviewable-requests", response_model=List[ReviewableRequestItem])
|
|
def get_reviewable_requests(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get user's completed requests that don't have a review yet"""
|
|
requests = db.query(VehicleRequest).filter(
|
|
VehicleRequest.user_id == current_user.id,
|
|
VehicleRequest.status == "completed",
|
|
).all()
|
|
|
|
# Filter out requests that already have a review
|
|
existing_review_request_ids = set(
|
|
r.request_id for r in db.query(Review.request_id).filter(
|
|
Review.author_id == current_user.id
|
|
).all()
|
|
)
|
|
|
|
items = []
|
|
for req in requests:
|
|
if req.id in existing_review_request_ids:
|
|
continue
|
|
approved_vehicles = [v for v in req.recommended_vehicles if v.is_approved]
|
|
main_image = None
|
|
for v in approved_vehicles:
|
|
if v.car_data and v.car_data.get("main_image"):
|
|
main_image = v.car_data["main_image"]
|
|
break
|
|
|
|
items.append(ReviewableRequestItem(
|
|
request_id=req.id,
|
|
maker_name=req.maker_name,
|
|
model_name=req.model_name,
|
|
grade_name=req.grade_name,
|
|
status=req.status,
|
|
completed_at=req.admin_reviewed_at,
|
|
vehicle_count=len(approved_vehicles),
|
|
main_image=main_image,
|
|
))
|
|
|
|
return items
|
|
|
|
|
|
@router.get("/admin/list", response_model=List[AdminReviewListItem])
|
|
def admin_get_reviews(
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Admin: Get all reviews including unpublished"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
reviews = db.query(Review).order_by(desc(Review.created_at)).all()
|
|
|
|
return [
|
|
AdminReviewListItem(
|
|
id=r.id,
|
|
rating=r.rating,
|
|
title=r.title,
|
|
author_name=r.author.name if r.author else None,
|
|
author_email=r.author.email if r.author else None,
|
|
is_published=r.is_published,
|
|
cc_rewarded=r.cc_rewarded,
|
|
view_count=r.view_count,
|
|
created_at=r.created_at,
|
|
)
|
|
for r in reviews
|
|
]
|
|
|
|
|
|
@router.put("/admin/{review_id}/toggle-publish")
|
|
def admin_toggle_publish(
|
|
review_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Admin: Toggle review published status"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
review = db.query(Review).filter(Review.id == review_id).first()
|
|
if not review:
|
|
raise HTTPException(status_code=404, detail="Review not found")
|
|
|
|
review.is_published = not review.is_published
|
|
db.commit()
|
|
|
|
return {"message": f"Review {'published' if review.is_published else 'unpublished'}", "is_published": review.is_published}
|
|
|
|
|
|
@router.delete("/admin/{review_id}")
|
|
def admin_delete_review(
|
|
review_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Admin: Delete a review"""
|
|
if not current_user.is_admin:
|
|
raise HTTPException(status_code=403, detail="Admin access required")
|
|
|
|
review = db.query(Review).filter(Review.id == review_id).first()
|
|
if not review:
|
|
raise HTTPException(status_code=404, detail="Review not found")
|
|
|
|
db.delete(review)
|
|
db.commit()
|
|
|
|
return {"message": "Review deleted"}
|
|
|
|
|
|
@router.get("/{review_id}", response_model=ReviewDetail)
|
|
def get_review(
|
|
review_id: int,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get review detail (increments view count)"""
|
|
review = db.query(Review).filter(Review.id == review_id).first()
|
|
if not review:
|
|
raise HTTPException(status_code=404, detail="Review not found")
|
|
|
|
if not review.is_published:
|
|
raise HTTPException(status_code=404, detail="Review not found")
|
|
|
|
# Increment view count
|
|
review.view_count = (review.view_count or 0) + 1
|
|
db.commit()
|
|
|
|
vehicles = _extract_vehicles(review.request) if review.request else []
|
|
|
|
return ReviewDetail(
|
|
id=review.id,
|
|
request_id=review.request_id,
|
|
rating=review.rating,
|
|
title=review.title,
|
|
content=review.content,
|
|
author_id=review.author_id,
|
|
author_name=review.author.name if review.author else None,
|
|
author_email=review.author.email if review.author else None,
|
|
cc_rewarded=review.cc_rewarded,
|
|
is_published=review.is_published,
|
|
view_count=review.view_count,
|
|
vehicles=vehicles,
|
|
created_at=review.created_at,
|
|
updated_at=review.updated_at,
|
|
)
|
|
|
|
|
|
# =====================
|
|
# User Endpoints (CRUD)
|
|
# =====================
|
|
|
|
@router.post("/", response_model=ReviewDetail)
|
|
def create_review(
|
|
review_data: ReviewCreate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a review for a completed vehicle request (rewards 1 CC)"""
|
|
# Verify the request exists and belongs to user
|
|
request = db.query(VehicleRequest).filter(
|
|
VehicleRequest.id == review_data.request_id
|
|
).first()
|
|
if not request:
|
|
raise HTTPException(status_code=404, detail="Vehicle request not found")
|
|
|
|
if request.user_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="You can only review your own requests")
|
|
|
|
if request.status != "completed":
|
|
raise HTTPException(status_code=400, detail="Can only review completed requests")
|
|
|
|
# Check for existing review
|
|
existing = db.query(Review).filter(Review.request_id == review_data.request_id).first()
|
|
if existing:
|
|
raise HTTPException(status_code=400, detail="You have already written a review for this request")
|
|
|
|
# Create review
|
|
review = Review(
|
|
request_id=review_data.request_id,
|
|
author_id=current_user.id,
|
|
rating=review_data.rating,
|
|
title=review_data.title,
|
|
content=review_data.content,
|
|
cc_rewarded=True,
|
|
is_published=True,
|
|
)
|
|
db.add(review)
|
|
|
|
# Reward 1 CC
|
|
current_user.cc_balance = (current_user.cc_balance or 0) + 1.0
|
|
db.commit()
|
|
db.refresh(review)
|
|
|
|
# Send notification about CC reward
|
|
try:
|
|
notify_system(
|
|
db,
|
|
current_user.id,
|
|
"Review Reward",
|
|
"You earned 1 CC for writing a review! Thank you for your feedback.",
|
|
link="/reviews",
|
|
)
|
|
except Exception:
|
|
pass # Don't fail review creation if notification fails
|
|
|
|
vehicles = _extract_vehicles(review.request) if review.request else []
|
|
|
|
return ReviewDetail(
|
|
id=review.id,
|
|
request_id=review.request_id,
|
|
rating=review.rating,
|
|
title=review.title,
|
|
content=review.content,
|
|
author_id=review.author_id,
|
|
author_name=review.author.name if review.author else None,
|
|
author_email=review.author.email if review.author else None,
|
|
cc_rewarded=review.cc_rewarded,
|
|
is_published=review.is_published,
|
|
view_count=review.view_count,
|
|
vehicles=vehicles,
|
|
created_at=review.created_at,
|
|
updated_at=review.updated_at,
|
|
)
|
|
|
|
|
|
@router.put("/{review_id}", response_model=ReviewDetail)
|
|
def update_review(
|
|
review_id: int,
|
|
review_data: ReviewUpdate,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update own review"""
|
|
review = db.query(Review).filter(Review.id == review_id).first()
|
|
if not review:
|
|
raise HTTPException(status_code=404, detail="Review not found")
|
|
|
|
if review.author_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="You can only edit your own reviews")
|
|
|
|
if review_data.rating is not None:
|
|
review.rating = review_data.rating
|
|
if review_data.title is not None:
|
|
review.title = review_data.title
|
|
if review_data.content is not None:
|
|
review.content = review_data.content
|
|
|
|
review.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(review)
|
|
|
|
vehicles = _extract_vehicles(review.request) if review.request else []
|
|
|
|
return ReviewDetail(
|
|
id=review.id,
|
|
request_id=review.request_id,
|
|
rating=review.rating,
|
|
title=review.title,
|
|
content=review.content,
|
|
author_id=review.author_id,
|
|
author_name=review.author.name if review.author else None,
|
|
author_email=review.author.email if review.author else None,
|
|
cc_rewarded=review.cc_rewarded,
|
|
is_published=review.is_published,
|
|
view_count=review.view_count,
|
|
vehicles=vehicles,
|
|
created_at=review.created_at,
|
|
updated_at=review.updated_at,
|
|
)
|
|
|
|
|
|
@router.delete("/{review_id}")
|
|
def delete_review(
|
|
review_id: int,
|
|
current_user: User = Depends(get_current_user),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Delete own review"""
|
|
review = db.query(Review).filter(Review.id == review_id).first()
|
|
if not review:
|
|
raise HTTPException(status_code=404, detail="Review not found")
|
|
|
|
if review.author_id != current_user.id:
|
|
raise HTTPException(status_code=403, detail="You can only delete your own reviews")
|
|
|
|
db.delete(review)
|
|
db.commit()
|
|
|
|
return {"message": "Review deleted"}
|