feat: Add PostgreSQL support and enhance admin dashboard
- Migrate all service files from hardcoded SQLite to dual SQLite/PostgreSQL support - Add PostgreSQL service to docker-compose.yml - Create database abstraction layer (database.py) with execute_query, row_to_dict helpers - Update all 7 service files: account, rating, contact, ai_cost, quota, site_assistant, admin - Add new admin endpoint /database-stats for table size and row count visualization - Add database_type field to system health endpoint - Update .env.example with proper PostgreSQL connection string
This commit is contained in:
@@ -1,51 +1,64 @@
|
||||
"""Rating service — stores and aggregates user ratings per tool."""
|
||||
"""Rating service — stores and aggregates user ratings per tool.
|
||||
|
||||
Supports both SQLite (development) and PostgreSQL (production).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import current_app
|
||||
from app.utils.database import db_connection, execute_query, is_postgres, row_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
"""Create a SQLite connection."""
|
||||
db_path = current_app.config["DATABASE_PATH"]
|
||||
db_dir = os.path.dirname(db_path)
|
||||
if db_dir:
|
||||
os.makedirs(db_dir, exist_ok=True)
|
||||
connection = sqlite3.connect(db_path)
|
||||
connection.row_factory = sqlite3.Row
|
||||
return connection
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def init_ratings_db():
|
||||
"""Create ratings table if it does not exist."""
|
||||
with _connect() as conn:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tool_ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
|
||||
feedback TEXT DEFAULT '',
|
||||
tag TEXT DEFAULT '',
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
with db_connection() as conn:
|
||||
if is_postgres():
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS tool_ratings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tool TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
|
||||
feedback TEXT DEFAULT '',
|
||||
tag TEXT DEFAULT '',
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_ratings_tool
|
||||
ON tool_ratings(tool)
|
||||
""")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_ratings_fingerprint_tool
|
||||
ON tool_ratings(fingerprint, tool)
|
||||
""")
|
||||
else:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS tool_ratings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool TEXT NOT NULL,
|
||||
rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5),
|
||||
feedback TEXT DEFAULT '',
|
||||
tag TEXT DEFAULT '',
|
||||
fingerprint TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_ratings_tool
|
||||
ON tool_ratings(tool);
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_ratings_tool
|
||||
ON tool_ratings(tool);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_ratings_fingerprint_tool
|
||||
ON tool_ratings(fingerprint, tool);
|
||||
"""
|
||||
)
|
||||
CREATE INDEX IF NOT EXISTS idx_tool_ratings_fingerprint_tool
|
||||
ON tool_ratings(fingerprint, tool);
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def submit_rating(
|
||||
@@ -57,48 +70,75 @@ def submit_rating(
|
||||
) -> None:
|
||||
"""Store a rating. Limits one rating per fingerprint per tool per day."""
|
||||
now = _utc_now()
|
||||
today = now[:10] # YYYY-MM-DD
|
||||
today = now[:10]
|
||||
|
||||
with _connect() as conn:
|
||||
# Check for duplicate rating from same fingerprint today
|
||||
existing = conn.execute(
|
||||
"""SELECT id FROM tool_ratings
|
||||
WHERE fingerprint = ? AND tool = ? AND created_at LIKE ?
|
||||
LIMIT 1""",
|
||||
(fingerprint, tool, f"{today}%"),
|
||||
).fetchone()
|
||||
with db_connection() as conn:
|
||||
like_sql = "LIKE %s" if is_postgres() else "LIKE ?"
|
||||
sql = (
|
||||
f"""SELECT id FROM tool_ratings
|
||||
WHERE fingerprint = %s AND tool = %s AND created_at {like_sql}
|
||||
LIMIT 1"""
|
||||
if is_postgres()
|
||||
else f"""SELECT id FROM tool_ratings
|
||||
WHERE fingerprint = ? AND tool = ? AND created_at {like_sql}
|
||||
LIMIT 1"""
|
||||
)
|
||||
cursor = execute_query(conn, sql, (fingerprint, tool, f"{today}%"))
|
||||
existing = cursor.fetchone()
|
||||
|
||||
if existing:
|
||||
# Update existing rating instead of creating duplicate
|
||||
conn.execute(
|
||||
existing = row_to_dict(existing)
|
||||
update_sql = (
|
||||
"""UPDATE tool_ratings
|
||||
SET rating = ?, feedback = ?, tag = ?, created_at = ?
|
||||
WHERE id = ?""",
|
||||
(rating, feedback, tag, now, existing["id"]),
|
||||
SET rating = %s, feedback = %s, tag = %s, created_at = %s
|
||||
WHERE id = %s"""
|
||||
if is_postgres()
|
||||
else """UPDATE tool_ratings
|
||||
SET rating = ?, feedback = ?, tag = ?, created_at = ?
|
||||
WHERE id = ?"""
|
||||
)
|
||||
execute_query(
|
||||
conn, update_sql, (rating, feedback, tag, now, existing["id"])
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
insert_sql = (
|
||||
"""INSERT INTO tool_ratings (tool, rating, feedback, tag, fingerprint, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(tool, rating, feedback, tag, fingerprint, now),
|
||||
VALUES (%s, %s, %s, %s, %s, %s)"""
|
||||
if is_postgres()
|
||||
else """INSERT INTO tool_ratings (tool, rating, feedback, tag, fingerprint, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)"""
|
||||
)
|
||||
execute_query(
|
||||
conn, insert_sql, (tool, rating, feedback, tag, fingerprint, now)
|
||||
)
|
||||
|
||||
|
||||
def get_tool_rating_summary(tool: str) -> dict:
|
||||
"""Return aggregate rating data for one tool."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
with db_connection() as conn:
|
||||
sql = (
|
||||
"""SELECT
|
||||
COUNT(*) as count,
|
||||
COALESCE(AVG(rating), 0) as average,
|
||||
COALESCE(SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END), 0) as star5,
|
||||
COALESCE(SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END), 0) as star4,
|
||||
COALESCE(SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END), 0) as star3,
|
||||
COALESCE(SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END), 0) as star2,
|
||||
COALESCE(SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END), 0) as star1
|
||||
FROM tool_ratings WHERE tool = ?""",
|
||||
(tool,),
|
||||
).fetchone()
|
||||
COUNT(*) as count,
|
||||
COALESCE(AVG(rating), 0) as average,
|
||||
COALESCE(SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END), 0) as star5,
|
||||
COALESCE(SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END), 0) as star4,
|
||||
COALESCE(SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END), 0) as star3,
|
||||
COALESCE(SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END), 0) as star2,
|
||||
COALESCE(SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END), 0) as star1
|
||||
FROM tool_ratings WHERE tool = %s"""
|
||||
if is_postgres()
|
||||
else """SELECT
|
||||
COUNT(*) as count,
|
||||
COALESCE(AVG(rating), 0) as average,
|
||||
COALESCE(SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END), 0) as star5,
|
||||
COALESCE(SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END), 0) as star4,
|
||||
COALESCE(SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END), 0) as star3,
|
||||
COALESCE(SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END), 0) as star2,
|
||||
COALESCE(SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END), 0) as star1
|
||||
FROM tool_ratings WHERE tool = ?"""
|
||||
)
|
||||
cursor = execute_query(conn, sql, (tool,))
|
||||
row = row_to_dict(cursor.fetchone())
|
||||
|
||||
return {
|
||||
"tool": tool,
|
||||
@@ -116,16 +156,16 @@ def get_tool_rating_summary(tool: str) -> dict:
|
||||
|
||||
def get_all_ratings_summary() -> list[dict]:
|
||||
"""Return aggregated ratings for all tools that have at least one rating."""
|
||||
with _connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT
|
||||
tool,
|
||||
COUNT(*) as count,
|
||||
COALESCE(AVG(rating), 0) as average
|
||||
FROM tool_ratings
|
||||
GROUP BY tool
|
||||
ORDER BY count DESC"""
|
||||
).fetchall()
|
||||
with db_connection() as conn:
|
||||
sql = """SELECT
|
||||
tool,
|
||||
COUNT(*) as count,
|
||||
COALESCE(AVG(rating), 0) as average
|
||||
FROM tool_ratings
|
||||
GROUP BY tool
|
||||
ORDER BY count DESC"""
|
||||
cursor = execute_query(conn, sql)
|
||||
rows = [row_to_dict(r) for r in cursor.fetchall()]
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -139,15 +179,15 @@ def get_all_ratings_summary() -> list[dict]:
|
||||
|
||||
def get_global_rating_summary() -> dict:
|
||||
"""Return aggregate rating stats across all rated tools."""
|
||||
with _connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
with db_connection() as conn:
|
||||
sql = """
|
||||
SELECT
|
||||
COUNT(*) AS count,
|
||||
COALESCE(AVG(rating), 0) AS average
|
||||
FROM tool_ratings
|
||||
"""
|
||||
).fetchone()
|
||||
"""
|
||||
cursor = execute_query(conn, sql)
|
||||
row = row_to_dict(cursor.fetchone())
|
||||
|
||||
return {
|
||||
"rating_count": int(row["count"]) if row else 0,
|
||||
|
||||
Reference in New Issue
Block a user