- Admin: video management with Google Maps match status, manual restaurant mapping, restaurant remap on name change - Admin: user management tab with favorites/reviews detail - Admin: channel deletion fix for IDs with slashes - Frontend: responsive mobile layout (map top, list bottom, 2-row header) - Frontend: channel-colored map markers with legend - Frontend: my reviews list, favorites toggle, visit counter overlay - Frontend: force light mode for dark theme devices - Backend: visit tracking (site_visits table), user reviews endpoint - Backend: bulk transcript/extract streaming, geocode key fixes - Nginx config for production deployment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
169 lines
5.0 KiB
Python
169 lines
5.0 KiB
Python
"""Review API routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
|
|
from core import review
|
|
from api.deps import get_current_user
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class ReviewCreate(BaseModel):
|
|
rating: float = Field(..., ge=0.5, le=5.0)
|
|
review_text: Optional[str] = None
|
|
visited_at: Optional[date] = None
|
|
|
|
|
|
class ReviewUpdate(BaseModel):
|
|
rating: Optional[float] = Field(None, ge=0.5, le=5.0)
|
|
review_text: Optional[str] = None
|
|
visited_at: Optional[date] = None
|
|
|
|
|
|
# --- Restaurant reviews ---
|
|
|
|
@router.get("/restaurants/{restaurant_id}/reviews")
|
|
def list_restaurant_reviews(
|
|
restaurant_id: str,
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
"""List reviews for a restaurant (public)."""
|
|
reviews = review.get_reviews_for_restaurant(restaurant_id, limit=limit, offset=offset)
|
|
stats = review.get_restaurant_avg_rating(restaurant_id)
|
|
return {"reviews": reviews, **stats}
|
|
|
|
|
|
@router.post("/restaurants/{restaurant_id}/reviews", status_code=201)
|
|
def create_restaurant_review(
|
|
restaurant_id: str,
|
|
body: ReviewCreate,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""Create a review for a restaurant (requires auth)."""
|
|
created = review.create_review(
|
|
user_id=current_user["sub"],
|
|
restaurant_id=restaurant_id,
|
|
rating=body.rating,
|
|
review_text=body.review_text,
|
|
visited_at=body.visited_at,
|
|
)
|
|
return created
|
|
|
|
|
|
@router.put("/reviews/{review_id}")
|
|
def update_review_route(
|
|
review_id: str,
|
|
body: ReviewUpdate,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""Update a review (own review only)."""
|
|
updated = review.update_review(
|
|
review_id=review_id,
|
|
user_id=current_user["sub"],
|
|
rating=body.rating,
|
|
review_text=body.review_text,
|
|
visited_at=body.visited_at,
|
|
)
|
|
if not updated:
|
|
raise HTTPException(404, "Review not found or not yours")
|
|
return updated
|
|
|
|
|
|
@router.delete("/reviews/{review_id}", status_code=204)
|
|
def delete_review_route(
|
|
review_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""Delete a review (own review only)."""
|
|
deleted = review.delete_review(review_id, current_user["sub"])
|
|
if not deleted:
|
|
raise HTTPException(404, "Review not found or not yours")
|
|
|
|
|
|
@router.get("/users/me/reviews")
|
|
def list_my_reviews(
|
|
limit: int = Query(20, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""List current user's reviews."""
|
|
return review.get_user_reviews(current_user["sub"], limit=limit, offset=offset)
|
|
|
|
|
|
# --- Favorites ---
|
|
|
|
@router.get("/restaurants/{restaurant_id}/favorite")
|
|
def get_favorite_status(
|
|
restaurant_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""Check if current user has favorited this restaurant."""
|
|
from core.db import conn
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(
|
|
"SELECT id FROM user_favorites WHERE user_id = :u AND restaurant_id = :r",
|
|
{"u": current_user["sub"], "r": restaurant_id},
|
|
)
|
|
return {"favorited": cur.fetchone() is not None}
|
|
|
|
|
|
@router.post("/restaurants/{restaurant_id}/favorite")
|
|
def toggle_favorite(
|
|
restaurant_id: str,
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""Toggle favorite. Returns new state."""
|
|
from core.db import conn
|
|
user_id = current_user["sub"]
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(
|
|
"SELECT id FROM user_favorites WHERE user_id = :u AND restaurant_id = :r",
|
|
{"u": user_id, "r": restaurant_id},
|
|
)
|
|
row = cur.fetchone()
|
|
if row:
|
|
cur.execute("DELETE FROM user_favorites WHERE id = :fid", {"fid": row[0]})
|
|
return {"favorited": False}
|
|
else:
|
|
cur.execute(
|
|
"INSERT INTO user_favorites (user_id, restaurant_id) VALUES (:u, :r)",
|
|
{"u": user_id, "r": restaurant_id},
|
|
)
|
|
return {"favorited": True}
|
|
|
|
|
|
@router.get("/users/me/favorites")
|
|
def list_my_favorites(
|
|
current_user: dict = Depends(get_current_user),
|
|
):
|
|
"""List current user's favorite restaurants."""
|
|
from core.db import conn
|
|
sql = """
|
|
SELECT r.id, r.name, r.address, r.region, r.latitude, r.longitude,
|
|
r.cuisine_type, r.price_range, r.google_place_id,
|
|
r.business_status, r.rating, r.rating_count,
|
|
f.created_at
|
|
FROM user_favorites f
|
|
JOIN restaurants r ON r.id = f.restaurant_id
|
|
WHERE f.user_id = :u
|
|
ORDER BY f.created_at DESC
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {"u": current_user["sub"]})
|
|
cols = [d[0].lower() for d in cur.description]
|
|
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
for r in rows:
|
|
if r.get("created_at"):
|
|
r["created_at"] = r["created_at"].isoformat()
|
|
return rows
|