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:
rob
2026-01-14 03:12:31 -04:00
co-authored by Claude Opus 4.5
parent d2c668dc99
commit 604b473806
10 changed files with 2566 additions and 9 deletions
+914
View File
@@ -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
+91
View File
@@ -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);
"""
+463
View File
@@ -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)