- 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>
197 lines
5.8 KiB
Python
197 lines
5.8 KiB
Python
"""User review DB operations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
|
|
import oracledb
|
|
|
|
from core.db import conn
|
|
|
|
|
|
def create_review(
|
|
user_id: str,
|
|
restaurant_id: str,
|
|
rating: float,
|
|
review_text: str | None = None,
|
|
visited_at: date | None = None,
|
|
) -> dict:
|
|
"""Create a new review. Returns the created review dict."""
|
|
sql = """
|
|
INSERT INTO user_reviews (user_id, restaurant_id, rating, review_text, visited_at)
|
|
VALUES (:user_id, :restaurant_id, :rating, :review_text, :visited_at)
|
|
RETURNING id INTO :out_id
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
out_id = cur.var(oracledb.STRING)
|
|
cur.execute(sql, {
|
|
"user_id": user_id,
|
|
"restaurant_id": restaurant_id,
|
|
"rating": rating,
|
|
"review_text": review_text,
|
|
"visited_at": visited_at,
|
|
"out_id": out_id,
|
|
})
|
|
new_id = out_id.getvalue()[0]
|
|
|
|
return get_review_by_id(new_id)
|
|
|
|
|
|
def update_review(
|
|
review_id: str,
|
|
user_id: str,
|
|
rating: float | None = None,
|
|
review_text: str | None = None,
|
|
visited_at: date | None = None,
|
|
) -> dict:
|
|
"""Update an existing review. Only the owner can update.
|
|
|
|
Returns the updated review dict, or None if not found / not owner.
|
|
"""
|
|
sql = """
|
|
UPDATE user_reviews
|
|
SET rating = COALESCE(:rating, rating),
|
|
review_text = COALESCE(:review_text, review_text),
|
|
visited_at = COALESCE(:visited_at, visited_at),
|
|
updated_at = SYSTIMESTAMP
|
|
WHERE id = :id AND user_id = :user_id
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {
|
|
"rating": rating,
|
|
"review_text": review_text,
|
|
"visited_at": visited_at,
|
|
"id": review_id,
|
|
"user_id": user_id,
|
|
})
|
|
if cur.rowcount == 0:
|
|
return None
|
|
|
|
return get_review_by_id(review_id)
|
|
|
|
|
|
def delete_review(review_id: str, user_id: str) -> bool:
|
|
"""Delete a review. Only the owner can delete. Returns True if deleted."""
|
|
sql = "DELETE FROM user_reviews WHERE id = :id AND user_id = :user_id"
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {"id": review_id, "user_id": user_id})
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_review_by_id(review_id: str) -> dict | None:
|
|
"""Get a single review by ID."""
|
|
sql = """
|
|
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
|
|
r.visited_at, r.created_at, r.updated_at,
|
|
u.nickname, u.avatar_url
|
|
FROM user_reviews r
|
|
JOIN tasteby_users u ON u.id = r.user_id
|
|
WHERE r.id = :id
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {"id": review_id})
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return None
|
|
return _row_to_dict(row)
|
|
|
|
|
|
def get_reviews_for_restaurant(
|
|
restaurant_id: str,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
"""List reviews for a restaurant, including user nickname/avatar."""
|
|
sql = """
|
|
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
|
|
r.visited_at, r.created_at, r.updated_at,
|
|
u.nickname, u.avatar_url
|
|
FROM user_reviews r
|
|
JOIN tasteby_users u ON u.id = r.user_id
|
|
WHERE r.restaurant_id = :restaurant_id
|
|
ORDER BY r.created_at DESC
|
|
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {
|
|
"restaurant_id": restaurant_id,
|
|
"off": offset,
|
|
"lim": limit,
|
|
})
|
|
return [_row_to_dict(row) for row in cur.fetchall()]
|
|
|
|
|
|
def get_user_reviews(
|
|
user_id: str,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
) -> list[dict]:
|
|
"""List reviews by a specific user, including restaurant name."""
|
|
sql = """
|
|
SELECT r.id, r.user_id, r.restaurant_id, r.rating, r.review_text,
|
|
r.visited_at, r.created_at, r.updated_at,
|
|
u.nickname, u.avatar_url,
|
|
rest.name AS restaurant_name
|
|
FROM user_reviews r
|
|
JOIN tasteby_users u ON u.id = r.user_id
|
|
LEFT JOIN restaurants rest ON rest.id = r.restaurant_id
|
|
WHERE r.user_id = :user_id
|
|
ORDER BY r.created_at DESC
|
|
OFFSET :off ROWS FETCH NEXT :lim ROWS ONLY
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {
|
|
"user_id": user_id,
|
|
"off": offset,
|
|
"lim": limit,
|
|
})
|
|
rows = []
|
|
for row in cur.fetchall():
|
|
d = _row_to_dict(row)
|
|
d["restaurant_name"] = row[10]
|
|
rows.append(d)
|
|
return rows
|
|
|
|
|
|
def get_restaurant_avg_rating(restaurant_id: str) -> dict:
|
|
"""Get average rating and review count for a restaurant."""
|
|
sql = """
|
|
SELECT ROUND(AVG(rating), 1) AS avg_rating, COUNT(*) AS review_count
|
|
FROM user_reviews
|
|
WHERE restaurant_id = :restaurant_id
|
|
"""
|
|
with conn() as c:
|
|
cur = c.cursor()
|
|
cur.execute(sql, {"restaurant_id": restaurant_id})
|
|
row = cur.fetchone()
|
|
return {
|
|
"avg_rating": float(row[0]) if row[0] else None,
|
|
"review_count": int(row[1]),
|
|
}
|
|
|
|
|
|
def _row_to_dict(row) -> dict:
|
|
"""Convert a review query row to a dict."""
|
|
review_text = row[4]
|
|
if hasattr(review_text, "read"):
|
|
review_text = review_text.read()
|
|
|
|
return {
|
|
"id": row[0],
|
|
"user_id": row[1],
|
|
"restaurant_id": row[2],
|
|
"rating": float(row[3]),
|
|
"review_text": review_text,
|
|
"visited_at": row[5].isoformat() if row[5] else None,
|
|
"created_at": row[6].isoformat() if row[6] else None,
|
|
"updated_at": row[7].isoformat() if row[7] else None,
|
|
"user_nickname": row[8],
|
|
"user_avatar_url": row[9],
|
|
}
|