feat: Add Review (사용후기) board system with CC reward
- 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>
This commit is contained in:
474
backend/app/api/reviews.py
Normal file
474
backend/app/api/reviews.py
Normal file
@@ -0,0 +1,474 @@
|
||||
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"}
|
||||
Reference in New Issue
Block a user