Add ratings, reviews, and publisher reputation system
- Add database schema for reviews, issues, and stats caching - Add API endpoints for reviews (CRUD, voting, flagging) - Add API endpoints for issues (report, list, resolve) - Add publisher stats and badge system - Add trust score calculation (0-100 scale) - Update tool detail page with ratings, reviews, issues sections - Update publisher profile with stats, badges, trust score - Add dedicated reviews and issues pages with filtering - Update dashboard tools table with rating/issues columns - Update tool cards with inline rating display Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
@@ -21,6 +21,11 @@ from argon2.exceptions import VerifyMismatchError
|
||||
from .db import connect_db, init_db, query_all, query_one
|
||||
from .rate_limit import RateLimiter
|
||||
from .sync import process_webhook, get_categories_cache_path, get_repo_dir
|
||||
from .stats import (
|
||||
refresh_tool_stats, get_tool_stats, refresh_publisher_stats,
|
||||
get_publisher_stats, track_tool_usage, calculate_badges, BADGES,
|
||||
get_badge_info, format_count,
|
||||
)
|
||||
|
||||
MAX_BODY_BYTES = 512 * 1024
|
||||
MAX_CONFIG_BYTES = 64 * 1024
|
||||
@@ -40,6 +45,9 @@ RATE_LIMITS = {
|
||||
"login_failed": {"limit": 5, "window": 900},
|
||||
"tokens": {"limit": 10, "window": 3600},
|
||||
"publish": {"limit": 20, "window": 3600},
|
||||
"review": {"limit": 10, "window": 3600},
|
||||
"issue": {"limit": 20, "window": 3600},
|
||||
"vote": {"limit": 100, "window": 3600},
|
||||
}
|
||||
|
||||
ALLOWED_SORT = {
|
||||
@@ -2924,6 +2932,912 @@ def create_app() -> Flask:
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
# ─── Reviews & Ratings API ────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["POST"])
|
||||
@require_token
|
||||
def submit_review(owner: str, name: str) -> Response:
|
||||
"""Submit a review for a tool. One review per user per tool."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
# Find the tool
|
||||
tool = query_one(
|
||||
g.db,
|
||||
"SELECT id, publisher_id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
|
||||
[owner, name],
|
||||
)
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
|
||||
|
||||
# Can't review your own tool
|
||||
if tool["publisher_id"] == g.current_publisher["id"]:
|
||||
return error_response("VALIDATION_ERROR", "You cannot review your own tool", 400)
|
||||
|
||||
# Check rate limit
|
||||
rate_resp = enforce_token_rate_limit("review", g.current_token["hash"])
|
||||
if rate_resp:
|
||||
return rate_resp
|
||||
|
||||
data = request.get_json() or {}
|
||||
rating = data.get("rating")
|
||||
title = (data.get("title") or "").strip()[:100]
|
||||
content = (data.get("content") or "").strip()[:2000]
|
||||
|
||||
if not isinstance(rating, int) or rating < 1 or rating > 5:
|
||||
return error_response("VALIDATION_ERROR", "Rating must be an integer from 1 to 5")
|
||||
|
||||
# Check for minimum content length (spam prevention)
|
||||
if content and len(content) < 10:
|
||||
return error_response("VALIDATION_ERROR", "Review content must be at least 10 characters")
|
||||
|
||||
# Check if user already reviewed this tool
|
||||
existing = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM reviews WHERE tool_id = ? AND reviewer_id = ?",
|
||||
[tool["id"], g.current_publisher["id"]],
|
||||
)
|
||||
if existing:
|
||||
return error_response(
|
||||
"ALREADY_REVIEWED",
|
||||
"You have already reviewed this tool. Use PUT to update.",
|
||||
409,
|
||||
)
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO reviews (tool_id, reviewer_id, rating, title, content, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[tool["id"], g.current_publisher["id"], rating, title or None, content or None, now, now],
|
||||
)
|
||||
review_id = g.db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, tool["id"])
|
||||
|
||||
response = jsonify({
|
||||
"data": {
|
||||
"id": review_id,
|
||||
"rating": rating,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"created_at": now,
|
||||
}
|
||||
})
|
||||
response.status_code = 201
|
||||
return response
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["GET"])
|
||||
def list_reviews(owner: str, name: str) -> Response:
|
||||
"""List reviews for a tool with pagination."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
tool = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
|
||||
[owner, name],
|
||||
)
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
|
||||
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
sort = request.args.get("sort", "recent") # recent, helpful, highest, lowest
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
if sort == "helpful":
|
||||
order_sql = "helpful_count DESC, created_at DESC"
|
||||
elif sort == "highest":
|
||||
order_sql = "rating DESC, created_at DESC"
|
||||
elif sort == "lowest":
|
||||
order_sql = "rating ASC, created_at DESC"
|
||||
else:
|
||||
order_sql = "created_at DESC"
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
f"""
|
||||
SELECT r.*, p.slug as reviewer_slug, p.display_name as reviewer_name
|
||||
FROM reviews r
|
||||
LEFT JOIN publishers p ON r.reviewer_id = p.id
|
||||
WHERE r.tool_id = ? AND r.status = 'published'
|
||||
ORDER BY {order_sql}
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[tool["id"], per_page, offset],
|
||||
)
|
||||
|
||||
count_row = query_one(
|
||||
g.db,
|
||||
"SELECT COUNT(*) as total FROM reviews WHERE tool_id = ? AND status = 'published'",
|
||||
[tool["id"]],
|
||||
)
|
||||
total = count_row["total"] if count_row else 0
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"rating": row["rating"],
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"reviewer_slug": row["reviewer_slug"],
|
||||
"reviewer_name": row["reviewer_name"] or "Anonymous",
|
||||
"helpful_count": row["helpful_count"],
|
||||
"unhelpful_count": row["unhelpful_count"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": data,
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/rating", methods=["GET"])
|
||||
def get_tool_rating(owner: str, name: str) -> Response:
|
||||
"""Get rating summary for a tool."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
tool = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
|
||||
[owner, name],
|
||||
)
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
|
||||
|
||||
stats = get_tool_stats(g.db, tool["id"])
|
||||
if not stats:
|
||||
stats = {
|
||||
"average_rating": 0,
|
||||
"rating_count": 0,
|
||||
"rating_1": 0,
|
||||
"rating_2": 0,
|
||||
"rating_3": 0,
|
||||
"rating_4": 0,
|
||||
"rating_5": 0,
|
||||
"unique_users": 0,
|
||||
"open_issues": 0,
|
||||
"security_issues": 0,
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"average_rating": stats["average_rating"],
|
||||
"rating_count": stats["rating_count"],
|
||||
"distribution": {
|
||||
"1": stats["rating_1"],
|
||||
"2": stats["rating_2"],
|
||||
"3": stats["rating_3"],
|
||||
"4": stats["rating_4"],
|
||||
"5": stats["rating_5"],
|
||||
},
|
||||
"unique_users": stats["unique_users"],
|
||||
"open_issues": stats["open_issues"],
|
||||
"security_issues": stats["security_issues"],
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/reviews/<int:review_id>", methods=["PUT"])
|
||||
@require_token
|
||||
def update_review(review_id: int) -> Response:
|
||||
"""Update an existing review (owner only)."""
|
||||
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
|
||||
|
||||
if review["reviewer_id"] != g.current_publisher["id"]:
|
||||
return error_response("FORBIDDEN", "You can only edit your own reviews", 403)
|
||||
|
||||
data = request.get_json() or {}
|
||||
rating = data.get("rating")
|
||||
title = data.get("title")
|
||||
content = data.get("content")
|
||||
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
if rating is not None:
|
||||
if not isinstance(rating, int) or rating < 1 or rating > 5:
|
||||
return error_response("VALIDATION_ERROR", "Rating must be 1-5")
|
||||
updates.append("rating = ?")
|
||||
params.append(rating)
|
||||
|
||||
if title is not None:
|
||||
updates.append("title = ?")
|
||||
params.append(title.strip()[:100] if title else None)
|
||||
|
||||
if content is not None:
|
||||
content = content.strip()[:2000]
|
||||
if content and len(content) < 10:
|
||||
return error_response("VALIDATION_ERROR", "Review content must be at least 10 characters")
|
||||
updates.append("content = ?")
|
||||
params.append(content if content else None)
|
||||
|
||||
if not updates:
|
||||
return error_response("VALIDATION_ERROR", "No fields to update")
|
||||
|
||||
updates.append("updated_at = ?")
|
||||
params.append(datetime.utcnow().isoformat())
|
||||
params.append(review_id)
|
||||
|
||||
g.db.execute(f"UPDATE reviews SET {', '.join(updates)} WHERE id = ?", params)
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, review["tool_id"])
|
||||
|
||||
return jsonify({"data": {"status": "updated", "review_id": review_id}})
|
||||
|
||||
@app.route("/api/v1/reviews/<int:review_id>", methods=["DELETE"])
|
||||
@require_token
|
||||
def delete_review(review_id: int) -> Response:
|
||||
"""Delete a review (owner only)."""
|
||||
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
|
||||
|
||||
if review["reviewer_id"] != g.current_publisher["id"]:
|
||||
return error_response("FORBIDDEN", "You can only delete your own reviews", 403)
|
||||
|
||||
tool_id = review["tool_id"]
|
||||
g.db.execute("DELETE FROM reviews WHERE id = ?", [review_id])
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, tool_id)
|
||||
|
||||
return jsonify({"data": {"status": "deleted", "review_id": review_id}})
|
||||
|
||||
@app.route("/api/v1/reviews/<int:review_id>/vote", methods=["POST"])
|
||||
def vote_review(review_id: int) -> Response:
|
||||
"""Vote a review as helpful or unhelpful."""
|
||||
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
vote_type = data.get("vote")
|
||||
if vote_type not in ("helpful", "unhelpful"):
|
||||
return error_response("VALIDATION_ERROR", "Vote must be 'helpful' or 'unhelpful'")
|
||||
|
||||
# Use publisher ID if logged in, otherwise hash IP
|
||||
if hasattr(g, "current_publisher") and g.current_publisher:
|
||||
voter_id = f"pub:{g.current_publisher['id']}"
|
||||
else:
|
||||
voter_id = f"ip:{hashlib.sha256((request.remote_addr or 'unknown').encode()).hexdigest()[:16]}"
|
||||
|
||||
# Check for existing vote
|
||||
existing = query_one(
|
||||
g.db,
|
||||
"SELECT vote_type FROM review_votes WHERE review_id = ? AND voter_id = ?",
|
||||
[review_id, voter_id],
|
||||
)
|
||||
|
||||
if existing:
|
||||
if existing["vote_type"] == vote_type:
|
||||
# Remove vote (toggle off)
|
||||
g.db.execute("DELETE FROM review_votes WHERE review_id = ? AND voter_id = ?", [review_id, voter_id])
|
||||
# Decrement count
|
||||
if vote_type == "helpful":
|
||||
g.db.execute("UPDATE reviews SET helpful_count = helpful_count - 1 WHERE id = ?", [review_id])
|
||||
else:
|
||||
g.db.execute("UPDATE reviews SET unhelpful_count = unhelpful_count - 1 WHERE id = ?", [review_id])
|
||||
g.db.commit()
|
||||
return jsonify({"data": {"status": "removed", "vote": None}})
|
||||
else:
|
||||
# Change vote
|
||||
g.db.execute(
|
||||
"UPDATE review_votes SET vote_type = ?, created_at = ? WHERE review_id = ? AND voter_id = ?",
|
||||
[vote_type, datetime.utcnow().isoformat(), review_id, voter_id],
|
||||
)
|
||||
# Adjust counts
|
||||
if vote_type == "helpful":
|
||||
g.db.execute(
|
||||
"UPDATE reviews SET helpful_count = helpful_count + 1, unhelpful_count = unhelpful_count - 1 WHERE id = ?",
|
||||
[review_id],
|
||||
)
|
||||
else:
|
||||
g.db.execute(
|
||||
"UPDATE reviews SET helpful_count = helpful_count - 1, unhelpful_count = unhelpful_count + 1 WHERE id = ?",
|
||||
[review_id],
|
||||
)
|
||||
g.db.commit()
|
||||
return jsonify({"data": {"status": "changed", "vote": vote_type}})
|
||||
else:
|
||||
# New vote
|
||||
g.db.execute(
|
||||
"INSERT INTO review_votes (review_id, voter_id, vote_type, created_at) VALUES (?, ?, ?, ?)",
|
||||
[review_id, voter_id, vote_type, datetime.utcnow().isoformat()],
|
||||
)
|
||||
# Increment count
|
||||
if vote_type == "helpful":
|
||||
g.db.execute("UPDATE reviews SET helpful_count = helpful_count + 1 WHERE id = ?", [review_id])
|
||||
else:
|
||||
g.db.execute("UPDATE reviews SET unhelpful_count = unhelpful_count + 1 WHERE id = ?", [review_id])
|
||||
g.db.commit()
|
||||
return jsonify({"data": {"status": "added", "vote": vote_type}})
|
||||
|
||||
@app.route("/api/v1/reviews/<int:review_id>/flag", methods=["POST"])
|
||||
def flag_review(review_id: int) -> Response:
|
||||
"""Flag a review as inappropriate."""
|
||||
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
reason = (data.get("reason") or "").strip()[:200]
|
||||
|
||||
if not reason:
|
||||
return error_response("VALIDATION_ERROR", "Reason is required")
|
||||
|
||||
# Update review status to flagged
|
||||
g.db.execute(
|
||||
"UPDATE reviews SET status = 'flagged', updated_at = ? WHERE id = ?",
|
||||
[datetime.utcnow().isoformat(), review_id],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Log the flag action
|
||||
log_audit("flag_review", "review", str(review_id), {"reason": reason})
|
||||
|
||||
return jsonify({"data": {"status": "flagged", "review_id": review_id}})
|
||||
|
||||
# ─── Issues API ───────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/issues", methods=["POST"])
|
||||
def submit_issue(owner: str, name: str) -> Response:
|
||||
"""Report an issue for a tool. Auth optional for security reports."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
tool = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
|
||||
[owner, name],
|
||||
)
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
issue_type = data.get("issue_type", "bug")
|
||||
severity = data.get("severity", "medium")
|
||||
title = (data.get("title") or "").strip()[:200]
|
||||
description = (data.get("description") or "").strip()[:5000]
|
||||
|
||||
if issue_type not in ("bug", "security", "compatibility"):
|
||||
return error_response("VALIDATION_ERROR", "issue_type must be: bug, security, compatibility")
|
||||
if severity not in ("low", "medium", "high", "critical"):
|
||||
return error_response("VALIDATION_ERROR", "severity must be: low, medium, high, critical")
|
||||
if not title:
|
||||
return error_response("VALIDATION_ERROR", "Title is required")
|
||||
|
||||
# Rate limit by IP
|
||||
ip = request.remote_addr or "unknown"
|
||||
limit_config = RATE_LIMITS["issue"]
|
||||
allowed, _ = rate_limiter.check(f"issue:{ip}", limit_config["limit"], limit_config["window"])
|
||||
if not allowed:
|
||||
return error_response("RATE_LIMITED", "Too many issue reports. Try again later.", 429)
|
||||
|
||||
# Get reporter if authenticated
|
||||
reporter_id = None
|
||||
user_slug, _ = get_current_user_context()
|
||||
if user_slug:
|
||||
pub = query_one(g.db, "SELECT id FROM publishers WHERE slug = ?", [user_slug])
|
||||
if pub:
|
||||
reporter_id = pub["id"]
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
g.db.execute(
|
||||
"""
|
||||
INSERT INTO tool_issues (tool_id, reporter_id, issue_type, severity, title, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
[tool["id"], reporter_id, issue_type, severity, title, description or None, now, now],
|
||||
)
|
||||
issue_id = g.db.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, tool["id"])
|
||||
|
||||
response = jsonify({
|
||||
"data": {
|
||||
"id": issue_id,
|
||||
"issue_type": issue_type,
|
||||
"severity": severity,
|
||||
"title": title,
|
||||
"status": "open",
|
||||
"created_at": now,
|
||||
}
|
||||
})
|
||||
response.status_code = 201
|
||||
return response
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/issues", methods=["GET"])
|
||||
def list_issues(owner: str, name: str) -> Response:
|
||||
"""List issues for a tool."""
|
||||
if not OWNER_RE.match(owner) or not TOOL_NAME_RE.match(name):
|
||||
return error_response("VALIDATION_ERROR", "Invalid owner or tool name")
|
||||
|
||||
tool = query_one(
|
||||
g.db,
|
||||
"SELECT id FROM tools WHERE owner = ? AND name = ? ORDER BY id DESC LIMIT 1",
|
||||
[owner, name],
|
||||
)
|
||||
if not tool:
|
||||
return error_response("TOOL_NOT_FOUND", f"Tool '{owner}/{name}' not found", 404)
|
||||
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
status_filter = request.args.get("status") # open, confirmed, fixed, wontfix, duplicate
|
||||
type_filter = request.args.get("type") # bug, security, compatibility
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
where_clauses = ["tool_id = ?"]
|
||||
params: List[Any] = [tool["id"]]
|
||||
|
||||
if status_filter:
|
||||
where_clauses.append("status = ?")
|
||||
params.append(status_filter)
|
||||
if type_filter:
|
||||
where_clauses.append("issue_type = ?")
|
||||
params.append(type_filter)
|
||||
|
||||
where_sql = "WHERE " + " AND ".join(where_clauses)
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
f"""
|
||||
SELECT i.*, p.display_name as reporter_name
|
||||
FROM tool_issues i
|
||||
LEFT JOIN publishers p ON i.reporter_id = p.id
|
||||
{where_sql}
|
||||
ORDER BY
|
||||
CASE severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
|
||||
created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
params + [per_page, offset],
|
||||
)
|
||||
|
||||
count_row = query_one(
|
||||
g.db, f"SELECT COUNT(*) as total FROM tool_issues {where_sql}", params
|
||||
)
|
||||
total = count_row["total"] if count_row else 0
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"issue_type": row["issue_type"],
|
||||
"severity": row["severity"],
|
||||
"title": row["title"],
|
||||
"status": row["status"],
|
||||
"reporter_name": row["reporter_name"] or "Anonymous",
|
||||
"created_at": row["created_at"],
|
||||
"resolved_at": row["resolved_at"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": data,
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/issues/<int:issue_id>", methods=["GET"])
|
||||
def get_issue(issue_id: int) -> Response:
|
||||
"""Get issue details."""
|
||||
row = query_one(
|
||||
g.db,
|
||||
"""
|
||||
SELECT i.*, p.display_name as reporter_name, r.display_name as resolver_name,
|
||||
t.owner, t.name as tool_name
|
||||
FROM tool_issues i
|
||||
LEFT JOIN publishers p ON i.reporter_id = p.id
|
||||
LEFT JOIN publishers r ON i.resolved_by = r.id
|
||||
JOIN tools t ON i.tool_id = t.id
|
||||
WHERE i.id = ?
|
||||
""",
|
||||
[issue_id],
|
||||
)
|
||||
if not row:
|
||||
return error_response("ISSUE_NOT_FOUND", "Issue not found", 404)
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"id": row["id"],
|
||||
"tool": f"{row['owner']}/{row['tool_name']}",
|
||||
"issue_type": row["issue_type"],
|
||||
"severity": row["severity"],
|
||||
"title": row["title"],
|
||||
"description": row["description"],
|
||||
"status": row["status"],
|
||||
"reporter_name": row["reporter_name"] or "Anonymous",
|
||||
"resolver_name": row["resolver_name"],
|
||||
"resolution_note": row["resolution_note"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
"resolved_at": row["resolved_at"],
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/issues/<int:issue_id>", methods=["PUT"])
|
||||
@require_token
|
||||
def update_issue(issue_id: int) -> Response:
|
||||
"""Update an issue (reporter or tool owner only)."""
|
||||
issue = query_one(
|
||||
g.db,
|
||||
"""
|
||||
SELECT i.*, t.owner as tool_owner
|
||||
FROM tool_issues i
|
||||
JOIN tools t ON i.tool_id = t.id
|
||||
WHERE i.id = ?
|
||||
""",
|
||||
[issue_id],
|
||||
)
|
||||
if not issue:
|
||||
return error_response("ISSUE_NOT_FOUND", "Issue not found", 404)
|
||||
|
||||
# Check permissions: reporter or tool owner
|
||||
is_reporter = issue["reporter_id"] == g.current_publisher["id"]
|
||||
is_owner = issue["tool_owner"] == g.current_publisher["slug"]
|
||||
is_mod = g.current_publisher["role"] in ("moderator", "admin")
|
||||
|
||||
if not (is_reporter or is_owner or is_mod):
|
||||
return error_response("FORBIDDEN", "You don't have permission to update this issue", 403)
|
||||
|
||||
data = request.get_json() or {}
|
||||
updates = []
|
||||
params = []
|
||||
|
||||
# Reporter can update title and description
|
||||
if is_reporter and "title" in data:
|
||||
updates.append("title = ?")
|
||||
params.append(data["title"].strip()[:200])
|
||||
if is_reporter and "description" in data:
|
||||
updates.append("description = ?")
|
||||
params.append(data["description"].strip()[:5000])
|
||||
|
||||
# Owner/mod can update status and severity
|
||||
if (is_owner or is_mod) and "severity" in data:
|
||||
severity = data["severity"]
|
||||
if severity in ("low", "medium", "high", "critical"):
|
||||
updates.append("severity = ?")
|
||||
params.append(severity)
|
||||
if (is_owner or is_mod) and "status" in data:
|
||||
status = data["status"]
|
||||
if status in ("open", "confirmed"):
|
||||
updates.append("status = ?")
|
||||
params.append(status)
|
||||
|
||||
if not updates:
|
||||
return error_response("VALIDATION_ERROR", "No valid fields to update")
|
||||
|
||||
updates.append("updated_at = ?")
|
||||
params.append(datetime.utcnow().isoformat())
|
||||
params.append(issue_id)
|
||||
|
||||
g.db.execute(f"UPDATE tool_issues SET {', '.join(updates)} WHERE id = ?", params)
|
||||
g.db.commit()
|
||||
|
||||
return jsonify({"data": {"status": "updated", "issue_id": issue_id}})
|
||||
|
||||
@app.route("/api/v1/issues/<int:issue_id>/resolve", methods=["POST"])
|
||||
@require_token
|
||||
def resolve_issue(issue_id: int) -> Response:
|
||||
"""Resolve an issue (tool owner or moderator)."""
|
||||
issue = query_one(
|
||||
g.db,
|
||||
"""
|
||||
SELECT i.*, t.owner as tool_owner, t.id as tool_id
|
||||
FROM tool_issues i
|
||||
JOIN tools t ON i.tool_id = t.id
|
||||
WHERE i.id = ?
|
||||
""",
|
||||
[issue_id],
|
||||
)
|
||||
if not issue:
|
||||
return error_response("ISSUE_NOT_FOUND", "Issue not found", 404)
|
||||
|
||||
is_owner = issue["tool_owner"] == g.current_publisher["slug"]
|
||||
is_mod = g.current_publisher["role"] in ("moderator", "admin")
|
||||
|
||||
if not (is_owner or is_mod):
|
||||
return error_response("FORBIDDEN", "Only tool owner or moderators can resolve issues", 403)
|
||||
|
||||
data = request.get_json() or {}
|
||||
resolution = data.get("resolution", "fixed")
|
||||
note = (data.get("note") or "").strip()[:500]
|
||||
|
||||
if resolution not in ("fixed", "wontfix", "duplicate"):
|
||||
return error_response("VALIDATION_ERROR", "resolution must be: fixed, wontfix, duplicate")
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
g.db.execute(
|
||||
"""
|
||||
UPDATE tool_issues
|
||||
SET status = ?, resolved_by = ?, resolved_at = ?, resolution_note = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
[resolution, g.current_publisher["id"], now, note or None, now, issue_id],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, issue["tool_id"])
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"status": resolution,
|
||||
"issue_id": issue_id,
|
||||
"resolved_at": now,
|
||||
}
|
||||
})
|
||||
|
||||
# ─── Publisher Stats API ──────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/publishers/<slug>/stats", methods=["GET"])
|
||||
def get_publisher_stats_endpoint(slug: str) -> Response:
|
||||
"""Get publisher reputation stats and badges."""
|
||||
publisher = query_one(
|
||||
g.db,
|
||||
"SELECT id, slug, display_name, verified, created_at FROM publishers WHERE slug = ?",
|
||||
[slug],
|
||||
)
|
||||
if not publisher:
|
||||
return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404)
|
||||
|
||||
stats = get_publisher_stats(g.db, publisher["id"])
|
||||
if not stats:
|
||||
stats = {
|
||||
"tool_count": 0,
|
||||
"total_downloads": 0,
|
||||
"average_rating": 0,
|
||||
"total_reviews": 0,
|
||||
"trust_score": 0,
|
||||
"badges": [],
|
||||
}
|
||||
|
||||
# Get badge details
|
||||
badge_details = []
|
||||
for badge_id in stats.get("badges", []):
|
||||
info = get_badge_info(badge_id)
|
||||
if info:
|
||||
badge_details.append({
|
||||
"id": badge_id,
|
||||
"name": info["name"],
|
||||
"icon": info["icon"],
|
||||
"color": info["color"],
|
||||
"description": info["description"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"slug": publisher["slug"],
|
||||
"display_name": publisher["display_name"],
|
||||
"verified": bool(publisher["verified"]),
|
||||
"member_since": publisher["created_at"],
|
||||
"tool_count": stats["tool_count"],
|
||||
"total_downloads": stats["total_downloads"],
|
||||
"total_downloads_formatted": format_count(stats["total_downloads"]),
|
||||
"average_rating": stats["average_rating"],
|
||||
"total_reviews": stats["total_reviews"],
|
||||
"trust_score": stats["trust_score"],
|
||||
"badges": badge_details,
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/publishers/<slug>/reviews", methods=["GET"])
|
||||
def list_publisher_reviews(slug: str) -> Response:
|
||||
"""List all reviews across publisher's tools."""
|
||||
publisher = query_one(g.db, "SELECT id FROM publishers WHERE slug = ?", [slug])
|
||||
if not publisher:
|
||||
return error_response("PUBLISHER_NOT_FOUND", "Publisher not found", 404)
|
||||
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT r.*, t.owner, t.name as tool_name, p.display_name as reviewer_name
|
||||
FROM reviews r
|
||||
JOIN tools t ON r.tool_id = t.id
|
||||
LEFT JOIN publishers p ON r.reviewer_id = p.id
|
||||
WHERE t.publisher_id = ? AND r.status = 'published'
|
||||
ORDER BY r.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[publisher["id"], per_page, offset],
|
||||
)
|
||||
|
||||
count_row = query_one(
|
||||
g.db,
|
||||
"""
|
||||
SELECT COUNT(*) as total FROM reviews r
|
||||
JOIN tools t ON r.tool_id = t.id
|
||||
WHERE t.publisher_id = ? AND r.status = 'published'
|
||||
""",
|
||||
[publisher["id"]],
|
||||
)
|
||||
total = count_row["total"] if count_row else 0
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"tool": f"{row['owner']}/{row['tool_name']}",
|
||||
"rating": row["rating"],
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"reviewer_name": row["reviewer_name"] or "Anonymous",
|
||||
"created_at": row["created_at"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": data,
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
# ─── Admin Reviews & Issues API ───────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/admin/reviews", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_list_reviews() -> Response:
|
||||
"""List reviews pending moderation (flagged)."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
status_filter = request.args.get("status", "flagged")
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
"""
|
||||
SELECT r.*, t.owner, t.name as tool_name, p.display_name as reviewer_name
|
||||
FROM reviews r
|
||||
JOIN tools t ON r.tool_id = t.id
|
||||
LEFT JOIN publishers p ON r.reviewer_id = p.id
|
||||
WHERE r.status = ?
|
||||
ORDER BY r.updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
[status_filter, per_page, offset],
|
||||
)
|
||||
|
||||
count_row = query_one(
|
||||
g.db, "SELECT COUNT(*) as total FROM reviews WHERE status = ?", [status_filter]
|
||||
)
|
||||
total = count_row["total"] if count_row else 0
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"tool": f"{row['owner']}/{row['tool_name']}",
|
||||
"rating": row["rating"],
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"reviewer_name": row["reviewer_name"] or "Anonymous",
|
||||
"status": row["status"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": data,
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
@app.route("/api/v1/admin/reviews/<int:review_id>/hide", methods=["POST"])
|
||||
@require_moderator
|
||||
def admin_hide_review(review_id: int) -> Response:
|
||||
"""Hide a review (moderation action)."""
|
||||
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
|
||||
|
||||
g.db.execute(
|
||||
"UPDATE reviews SET status = 'hidden', updated_at = ? WHERE id = ?",
|
||||
[datetime.utcnow().isoformat(), review_id],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, review["tool_id"])
|
||||
|
||||
log_audit("hide_review", "review", str(review_id), {"previous_status": review["status"]})
|
||||
|
||||
return jsonify({"data": {"status": "hidden", "review_id": review_id}})
|
||||
|
||||
@app.route("/api/v1/admin/reviews/<int:review_id>/restore", methods=["POST"])
|
||||
@require_moderator
|
||||
def admin_restore_review(review_id: int) -> Response:
|
||||
"""Restore a hidden review."""
|
||||
review = query_one(g.db, "SELECT * FROM reviews WHERE id = ?", [review_id])
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "Review not found", 404)
|
||||
|
||||
g.db.execute(
|
||||
"UPDATE reviews SET status = 'published', updated_at = ? WHERE id = ?",
|
||||
[datetime.utcnow().isoformat(), review_id],
|
||||
)
|
||||
g.db.commit()
|
||||
|
||||
# Refresh tool stats
|
||||
refresh_tool_stats(g.db, review["tool_id"])
|
||||
|
||||
log_audit("restore_review", "review", str(review_id), {"previous_status": review["status"]})
|
||||
|
||||
return jsonify({"data": {"status": "published", "review_id": review_id}})
|
||||
|
||||
@app.route("/api/v1/admin/issues", methods=["GET"])
|
||||
@require_moderator
|
||||
def admin_list_issues() -> Response:
|
||||
"""List all issues across tools."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = min(request.args.get("per_page", 20, type=int), 100)
|
||||
status_filter = request.args.get("status")
|
||||
type_filter = request.args.get("type")
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
where_clauses = []
|
||||
params: List[Any] = []
|
||||
|
||||
if status_filter:
|
||||
where_clauses.append("i.status = ?")
|
||||
params.append(status_filter)
|
||||
if type_filter:
|
||||
where_clauses.append("i.issue_type = ?")
|
||||
params.append(type_filter)
|
||||
|
||||
where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
|
||||
|
||||
rows = query_all(
|
||||
g.db,
|
||||
f"""
|
||||
SELECT i.*, t.owner, t.name as tool_name, p.display_name as reporter_name
|
||||
FROM tool_issues i
|
||||
JOIN tools t ON i.tool_id = t.id
|
||||
LEFT JOIN publishers p ON i.reporter_id = p.id
|
||||
{where_sql}
|
||||
ORDER BY
|
||||
CASE i.severity WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 ELSE 4 END,
|
||||
i.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
params + [per_page, offset],
|
||||
)
|
||||
|
||||
count_row = query_one(
|
||||
g.db, f"SELECT COUNT(*) as total FROM tool_issues i {where_sql}", params
|
||||
)
|
||||
total = count_row["total"] if count_row else 0
|
||||
|
||||
data = []
|
||||
for row in rows:
|
||||
data.append({
|
||||
"id": row["id"],
|
||||
"tool": f"{row['owner']}/{row['tool_name']}",
|
||||
"issue_type": row["issue_type"],
|
||||
"severity": row["severity"],
|
||||
"title": row["title"],
|
||||
"status": row["status"],
|
||||
"reporter_name": row["reporter_name"] or "Anonymous",
|
||||
"created_at": row["created_at"],
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
"data": data,
|
||||
"meta": paginate(page, per_page, total),
|
||||
})
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -292,6 +292,97 @@ CREATE TABLE IF NOT EXISTS collections (
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_name ON collections(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_maintainer ON collections(maintainer);
|
||||
|
||||
-- Reviews and Ratings System
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||
reviewer_id INTEGER NOT NULL REFERENCES publishers(id),
|
||||
rating INTEGER NOT NULL CHECK(rating >= 1 AND rating <= 5),
|
||||
title TEXT,
|
||||
content TEXT,
|
||||
helpful_count INTEGER DEFAULT 0,
|
||||
unhelpful_count INTEGER DEFAULT 0,
|
||||
status TEXT DEFAULT 'published',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(tool_id, reviewer_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_tool ON reviews(tool_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_reviewer ON reviews(reviewer_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS review_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
review_id INTEGER NOT NULL REFERENCES reviews(id) ON DELETE CASCADE,
|
||||
voter_id TEXT NOT NULL,
|
||||
vote_type TEXT NOT NULL CHECK(vote_type IN ('helpful', 'unhelpful')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(review_id, voter_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_review_votes_review ON review_votes(review_id);
|
||||
|
||||
-- Issue Tracking (bugs, security, compatibility)
|
||||
CREATE TABLE IF NOT EXISTS tool_issues (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||
reporter_id INTEGER REFERENCES publishers(id),
|
||||
issue_type TEXT NOT NULL CHECK(issue_type IN ('bug', 'security', 'compatibility')),
|
||||
severity TEXT DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')),
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
status TEXT DEFAULT 'open' CHECK(status IN ('open', 'confirmed', 'fixed', 'wontfix', 'duplicate')),
|
||||
resolved_by INTEGER REFERENCES publishers(id),
|
||||
resolved_at TIMESTAMP,
|
||||
resolution_note TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_tool ON tool_issues(tool_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_type ON tool_issues(issue_type, severity);
|
||||
|
||||
-- Tool Stats Cache (for fast queries)
|
||||
CREATE TABLE IF NOT EXISTS tool_stats (
|
||||
tool_id INTEGER PRIMARY KEY REFERENCES tools(id) ON DELETE CASCADE,
|
||||
average_rating REAL DEFAULT 0,
|
||||
rating_count INTEGER DEFAULT 0,
|
||||
rating_1 INTEGER DEFAULT 0,
|
||||
rating_2 INTEGER DEFAULT 0,
|
||||
rating_3 INTEGER DEFAULT 0,
|
||||
rating_4 INTEGER DEFAULT 0,
|
||||
rating_5 INTEGER DEFAULT 0,
|
||||
unique_users INTEGER DEFAULT 0,
|
||||
open_issues INTEGER DEFAULT 0,
|
||||
security_issues INTEGER DEFAULT 0,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Publisher Stats Cache (for fast queries)
|
||||
CREATE TABLE IF NOT EXISTS publisher_stats (
|
||||
publisher_id INTEGER PRIMARY KEY REFERENCES publishers(id) ON DELETE CASCADE,
|
||||
tool_count INTEGER DEFAULT 0,
|
||||
total_downloads INTEGER DEFAULT 0,
|
||||
average_rating REAL DEFAULT 0,
|
||||
total_reviews INTEGER DEFAULT 0,
|
||||
trust_score REAL DEFAULT 0,
|
||||
badges TEXT,
|
||||
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tool Usage Tracking (unique users)
|
||||
CREATE TABLE IF NOT EXISTS tool_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tool_id INTEGER NOT NULL REFERENCES tools(id) ON DELETE CASCADE,
|
||||
user_hash TEXT NOT NULL,
|
||||
first_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
use_count INTEGER DEFAULT 1,
|
||||
UNIQUE(tool_id, user_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_tool ON tool_usage(tool_id);
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
"""Stats calculation functions for tools and publishers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
# Badge definitions
|
||||
BADGES = {
|
||||
"verified": {
|
||||
"name": "Verified",
|
||||
"icon": "shield-check",
|
||||
"color": "blue",
|
||||
"description": "Verified publisher identity",
|
||||
},
|
||||
"trusted": {
|
||||
"name": "Trusted",
|
||||
"icon": "award",
|
||||
"color": "gold",
|
||||
"description": "Trust score above 80",
|
||||
},
|
||||
"prolific": {
|
||||
"name": "Prolific",
|
||||
"icon": "layers",
|
||||
"color": "purple",
|
||||
"description": "Published 10+ tools",
|
||||
},
|
||||
"veteran": {
|
||||
"name": "Veteran",
|
||||
"icon": "clock",
|
||||
"color": "gray",
|
||||
"description": "Member for 1+ year",
|
||||
},
|
||||
"popular": {
|
||||
"name": "Popular",
|
||||
"icon": "trending-up",
|
||||
"color": "green",
|
||||
"description": "1000+ total downloads",
|
||||
},
|
||||
"responsive": {
|
||||
"name": "Responsive",
|
||||
"icon": "message-circle",
|
||||
"color": "cyan",
|
||||
"description": "Resolves 90%+ of issues",
|
||||
},
|
||||
"top_rated": {
|
||||
"name": "Top Rated",
|
||||
"icon": "star",
|
||||
"color": "yellow",
|
||||
"description": "Average rating 4.5+",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def refresh_tool_stats(conn: sqlite3.Connection, tool_id: int) -> Dict[str, Any]:
|
||||
"""Recalculate and cache stats for a tool.
|
||||
|
||||
Returns the updated stats dict.
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get rating stats
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as rating_count,
|
||||
COALESCE(AVG(rating), 0) as average_rating,
|
||||
SUM(CASE WHEN rating = 1 THEN 1 ELSE 0 END) as rating_1,
|
||||
SUM(CASE WHEN rating = 2 THEN 1 ELSE 0 END) as rating_2,
|
||||
SUM(CASE WHEN rating = 3 THEN 1 ELSE 0 END) as rating_3,
|
||||
SUM(CASE WHEN rating = 4 THEN 1 ELSE 0 END) as rating_4,
|
||||
SUM(CASE WHEN rating = 5 THEN 1 ELSE 0 END) as rating_5
|
||||
FROM reviews
|
||||
WHERE tool_id = ? AND status = 'published'
|
||||
""", [tool_id])
|
||||
rating_row = cursor.fetchone()
|
||||
|
||||
# Get unique users count
|
||||
cursor.execute("""
|
||||
SELECT COUNT(DISTINCT user_hash) as unique_users
|
||||
FROM tool_usage
|
||||
WHERE tool_id = ?
|
||||
""", [tool_id])
|
||||
usage_row = cursor.fetchone()
|
||||
|
||||
# Get issue counts
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
SUM(CASE WHEN status = 'open' THEN 1 ELSE 0 END) as open_issues,
|
||||
SUM(CASE WHEN status = 'open' AND issue_type = 'security' THEN 1 ELSE 0 END) as security_issues
|
||||
FROM tool_issues
|
||||
WHERE tool_id = ?
|
||||
""", [tool_id])
|
||||
issues_row = cursor.fetchone()
|
||||
|
||||
stats = {
|
||||
"tool_id": tool_id,
|
||||
"average_rating": round(rating_row[1], 2) if rating_row else 0,
|
||||
"rating_count": rating_row[0] if rating_row else 0,
|
||||
"rating_1": rating_row[2] or 0 if rating_row else 0,
|
||||
"rating_2": rating_row[3] or 0 if rating_row else 0,
|
||||
"rating_3": rating_row[4] or 0 if rating_row else 0,
|
||||
"rating_4": rating_row[5] or 0 if rating_row else 0,
|
||||
"rating_5": rating_row[6] or 0 if rating_row else 0,
|
||||
"unique_users": usage_row[0] if usage_row else 0,
|
||||
"open_issues": issues_row[0] or 0 if issues_row else 0,
|
||||
"security_issues": issues_row[1] or 0 if issues_row else 0,
|
||||
"last_updated": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Upsert into tool_stats
|
||||
cursor.execute("""
|
||||
INSERT INTO tool_stats (
|
||||
tool_id, average_rating, rating_count,
|
||||
rating_1, rating_2, rating_3, rating_4, rating_5,
|
||||
unique_users, open_issues, security_issues, last_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(tool_id) DO UPDATE SET
|
||||
average_rating = excluded.average_rating,
|
||||
rating_count = excluded.rating_count,
|
||||
rating_1 = excluded.rating_1,
|
||||
rating_2 = excluded.rating_2,
|
||||
rating_3 = excluded.rating_3,
|
||||
rating_4 = excluded.rating_4,
|
||||
rating_5 = excluded.rating_5,
|
||||
unique_users = excluded.unique_users,
|
||||
open_issues = excluded.open_issues,
|
||||
security_issues = excluded.security_issues,
|
||||
last_updated = excluded.last_updated
|
||||
""", [
|
||||
tool_id, stats["average_rating"], stats["rating_count"],
|
||||
stats["rating_1"], stats["rating_2"], stats["rating_3"],
|
||||
stats["rating_4"], stats["rating_5"],
|
||||
stats["unique_users"], stats["open_issues"], stats["security_issues"],
|
||||
stats["last_updated"],
|
||||
])
|
||||
conn.commit()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def get_tool_stats(conn: sqlite3.Connection, tool_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached stats for a tool, refreshing if stale or missing."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM tool_stats WHERE tool_id = ?
|
||||
""", [tool_id])
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
# No cached stats, calculate fresh
|
||||
return refresh_tool_stats(conn, tool_id)
|
||||
|
||||
# Check if stale (older than 1 hour)
|
||||
try:
|
||||
last_updated = datetime.fromisoformat(row[11]) # last_updated column
|
||||
if datetime.utcnow() - last_updated > timedelta(hours=1):
|
||||
return refresh_tool_stats(conn, tool_id)
|
||||
except (ValueError, TypeError):
|
||||
return refresh_tool_stats(conn, tool_id)
|
||||
|
||||
return {
|
||||
"tool_id": row[0],
|
||||
"average_rating": row[1],
|
||||
"rating_count": row[2],
|
||||
"rating_1": row[3],
|
||||
"rating_2": row[4],
|
||||
"rating_3": row[5],
|
||||
"rating_4": row[6],
|
||||
"rating_5": row[7],
|
||||
"unique_users": row[8],
|
||||
"open_issues": row[9],
|
||||
"security_issues": row[10],
|
||||
"last_updated": row[11],
|
||||
}
|
||||
|
||||
|
||||
def calculate_trust_score(
|
||||
tool_count: int,
|
||||
total_downloads: int,
|
||||
average_rating: float,
|
||||
total_reviews: int,
|
||||
issues_resolved_pct: float,
|
||||
tenure_days: int,
|
||||
is_verified: bool,
|
||||
) -> float:
|
||||
"""Calculate publisher trust score (0-100).
|
||||
|
||||
Scoring breakdown:
|
||||
- Tenure: max 20 points (5 points per year, max 4 years)
|
||||
- Tools: max 20 points (2 points per tool, max 10 tools)
|
||||
- Rating: max 30 points ((avg_rating / 5) * 30, requires min 3 reviews)
|
||||
- Downloads: max 15 points (logarithmic scale)
|
||||
- Issue response: max 10 points (resolved % * 10)
|
||||
- Verified bonus: 5 points
|
||||
"""
|
||||
score = 0.0
|
||||
|
||||
# Tenure score (max 20)
|
||||
years = min(tenure_days / 365, 4)
|
||||
score += years * 5
|
||||
|
||||
# Tools contribution (max 20)
|
||||
score += min(tool_count, 10) * 2
|
||||
|
||||
# Rating score (max 30, requires minimum reviews)
|
||||
if total_reviews >= 3:
|
||||
score += (average_rating / 5) * 30
|
||||
elif total_reviews > 0:
|
||||
# Partial credit for few reviews
|
||||
score += (average_rating / 5) * 15
|
||||
|
||||
# Downloads score (max 15, logarithmic)
|
||||
if total_downloads > 0:
|
||||
import math
|
||||
# log10(1000) = 3, so 1000 downloads = 15 points
|
||||
download_score = min(math.log10(total_downloads + 1) * 5, 15)
|
||||
score += download_score
|
||||
|
||||
# Issue response score (max 10)
|
||||
score += issues_resolved_pct * 10
|
||||
|
||||
# Verified bonus (5 points)
|
||||
if is_verified:
|
||||
score += 5
|
||||
|
||||
return round(min(100, score), 1)
|
||||
|
||||
|
||||
def calculate_badges(
|
||||
is_verified: bool,
|
||||
trust_score: float,
|
||||
tool_count: int,
|
||||
tenure_days: int,
|
||||
total_downloads: int,
|
||||
issues_resolved_pct: float,
|
||||
average_rating: float,
|
||||
total_reviews: int,
|
||||
) -> List[str]:
|
||||
"""Determine which badges a publisher has earned."""
|
||||
earned = []
|
||||
|
||||
if is_verified:
|
||||
earned.append("verified")
|
||||
|
||||
if trust_score >= 80:
|
||||
earned.append("trusted")
|
||||
|
||||
if tool_count >= 10:
|
||||
earned.append("prolific")
|
||||
|
||||
if tenure_days >= 365:
|
||||
earned.append("veteran")
|
||||
|
||||
if total_downloads >= 1000:
|
||||
earned.append("popular")
|
||||
|
||||
if issues_resolved_pct >= 0.9:
|
||||
earned.append("responsive")
|
||||
|
||||
if average_rating >= 4.5 and total_reviews >= 5:
|
||||
earned.append("top_rated")
|
||||
|
||||
return earned
|
||||
|
||||
|
||||
def refresh_publisher_stats(conn: sqlite3.Connection, publisher_id: int) -> Dict[str, Any]:
|
||||
"""Recalculate and cache stats for a publisher.
|
||||
|
||||
Returns the updated stats dict.
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get publisher info
|
||||
cursor.execute("""
|
||||
SELECT verified, created_at FROM publishers WHERE id = ?
|
||||
""", [publisher_id])
|
||||
pub_row = cursor.fetchone()
|
||||
if not pub_row:
|
||||
return {}
|
||||
|
||||
is_verified = bool(pub_row[0])
|
||||
try:
|
||||
created_at = datetime.fromisoformat(pub_row[1].replace("Z", "+00:00").replace("+00:00", ""))
|
||||
tenure_days = (datetime.utcnow() - created_at).days
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
tenure_days = 0
|
||||
|
||||
# Get tool stats
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(DISTINCT t.id) as tool_count,
|
||||
COALESCE(SUM(t.downloads), 0) as total_downloads
|
||||
FROM tools t
|
||||
WHERE t.publisher_id = ? AND t.moderation_status = 'approved'
|
||||
""", [publisher_id])
|
||||
tools_row = cursor.fetchone()
|
||||
tool_count = tools_row[0] if tools_row else 0
|
||||
total_downloads = tools_row[1] if tools_row else 0
|
||||
|
||||
# Get aggregate rating across all tools
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_reviews,
|
||||
COALESCE(AVG(r.rating), 0) as average_rating
|
||||
FROM reviews r
|
||||
JOIN tools t ON r.tool_id = t.id
|
||||
WHERE t.publisher_id = ? AND r.status = 'published'
|
||||
""", [publisher_id])
|
||||
reviews_row = cursor.fetchone()
|
||||
total_reviews = reviews_row[0] if reviews_row else 0
|
||||
average_rating = round(reviews_row[1], 2) if reviews_row else 0
|
||||
|
||||
# Get issue resolution rate
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
COUNT(*) as total_issues,
|
||||
SUM(CASE WHEN i.status IN ('fixed', 'wontfix', 'duplicate') THEN 1 ELSE 0 END) as resolved_issues
|
||||
FROM tool_issues i
|
||||
JOIN tools t ON i.tool_id = t.id
|
||||
WHERE t.publisher_id = ?
|
||||
""", [publisher_id])
|
||||
issues_row = cursor.fetchone()
|
||||
total_issues = issues_row[0] if issues_row else 0
|
||||
resolved_issues = issues_row[1] or 0 if issues_row else 0
|
||||
issues_resolved_pct = resolved_issues / total_issues if total_issues > 0 else 1.0
|
||||
|
||||
# Calculate trust score
|
||||
trust_score = calculate_trust_score(
|
||||
tool_count=tool_count,
|
||||
total_downloads=total_downloads,
|
||||
average_rating=average_rating,
|
||||
total_reviews=total_reviews,
|
||||
issues_resolved_pct=issues_resolved_pct,
|
||||
tenure_days=tenure_days,
|
||||
is_verified=is_verified,
|
||||
)
|
||||
|
||||
# Calculate badges
|
||||
badges = calculate_badges(
|
||||
is_verified=is_verified,
|
||||
trust_score=trust_score,
|
||||
tool_count=tool_count,
|
||||
tenure_days=tenure_days,
|
||||
total_downloads=total_downloads,
|
||||
issues_resolved_pct=issues_resolved_pct,
|
||||
average_rating=average_rating,
|
||||
total_reviews=total_reviews,
|
||||
)
|
||||
|
||||
stats = {
|
||||
"publisher_id": publisher_id,
|
||||
"tool_count": tool_count,
|
||||
"total_downloads": total_downloads,
|
||||
"average_rating": average_rating,
|
||||
"total_reviews": total_reviews,
|
||||
"trust_score": trust_score,
|
||||
"badges": badges,
|
||||
"tenure_days": tenure_days,
|
||||
"is_verified": is_verified,
|
||||
"last_updated": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
# Upsert into publisher_stats
|
||||
cursor.execute("""
|
||||
INSERT INTO publisher_stats (
|
||||
publisher_id, tool_count, total_downloads, average_rating,
|
||||
total_reviews, trust_score, badges, last_updated
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(publisher_id) DO UPDATE SET
|
||||
tool_count = excluded.tool_count,
|
||||
total_downloads = excluded.total_downloads,
|
||||
average_rating = excluded.average_rating,
|
||||
total_reviews = excluded.total_reviews,
|
||||
trust_score = excluded.trust_score,
|
||||
badges = excluded.badges,
|
||||
last_updated = excluded.last_updated
|
||||
""", [
|
||||
publisher_id, tool_count, total_downloads, average_rating,
|
||||
total_reviews, trust_score, json.dumps(badges),
|
||||
stats["last_updated"],
|
||||
])
|
||||
conn.commit()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def get_publisher_stats(conn: sqlite3.Connection, publisher_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""Get cached stats for a publisher, refreshing if stale or missing."""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT * FROM publisher_stats WHERE publisher_id = ?
|
||||
""", [publisher_id])
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
# No cached stats, calculate fresh
|
||||
return refresh_publisher_stats(conn, publisher_id)
|
||||
|
||||
# Check if stale (older than 1 hour)
|
||||
try:
|
||||
last_updated = datetime.fromisoformat(row[7]) # last_updated column
|
||||
if datetime.utcnow() - last_updated > timedelta(hours=1):
|
||||
return refresh_publisher_stats(conn, publisher_id)
|
||||
except (ValueError, TypeError):
|
||||
return refresh_publisher_stats(conn, publisher_id)
|
||||
|
||||
badges = []
|
||||
try:
|
||||
badges = json.loads(row[6]) if row[6] else []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"publisher_id": row[0],
|
||||
"tool_count": row[1],
|
||||
"total_downloads": row[2],
|
||||
"average_rating": row[3],
|
||||
"total_reviews": row[4],
|
||||
"trust_score": row[5],
|
||||
"badges": badges,
|
||||
"last_updated": row[7],
|
||||
}
|
||||
|
||||
|
||||
def track_tool_usage(conn: sqlite3.Connection, tool_id: int, client_id: str) -> None:
|
||||
"""Track a tool usage (download/run) for unique user counting."""
|
||||
import hashlib
|
||||
|
||||
# Hash the client_id for privacy
|
||||
user_hash = hashlib.sha256(client_id.encode()).hexdigest()[:16]
|
||||
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
INSERT INTO tool_usage (tool_id, user_hash, first_used_at, last_used_at, use_count)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
|
||||
ON CONFLICT(tool_id, user_hash) DO UPDATE SET
|
||||
last_used_at = CURRENT_TIMESTAMP,
|
||||
use_count = use_count + 1
|
||||
""", [tool_id, user_hash])
|
||||
conn.commit()
|
||||
|
||||
|
||||
def format_count(count: int) -> str:
|
||||
"""Format a count for display (e.g., 1234 -> '1.2K')."""
|
||||
if count < 1000:
|
||||
return str(count)
|
||||
elif count < 10000:
|
||||
return f"{count / 1000:.1f}K"
|
||||
elif count < 1000000:
|
||||
return f"{count // 1000}K"
|
||||
else:
|
||||
return f"{count / 1000000:.1f}M"
|
||||
|
||||
|
||||
def get_badge_info(badge_id: str) -> Optional[Dict[str, str]]:
|
||||
"""Get display info for a badge."""
|
||||
return BADGES.get(badge_id)
|
||||
Reference in New Issue
Block a user