From 604b473806379c9970a0039163991051c058f700 Mon Sep 17 00:00:00 2001 From: rob Date: Wed, 14 Jan 2026 03:12:31 -0400 Subject: [PATCH] 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 --- src/cmdforge/registry/app.py | 914 ++++++++++++++++++ src/cmdforge/registry/db.py | 91 ++ src/cmdforge/registry/stats.py | 463 +++++++++ src/cmdforge/web/routes.py | 123 +++ .../web/templates/components/tool_card.html | 36 +- .../web/templates/dashboard/tools.html | 33 + .../web/templates/pages/publisher.html | 87 +- .../web/templates/pages/tool_detail.html | 490 ++++++++++ .../web/templates/pages/tool_issues.html | 182 ++++ .../web/templates/pages/tool_reviews.html | 156 +++ 10 files changed, 2566 insertions(+), 9 deletions(-) create mode 100644 src/cmdforge/registry/stats.py create mode 100644 src/cmdforge/web/templates/pages/tool_issues.html create mode 100644 src/cmdforge/web/templates/pages/tool_reviews.html diff --git a/src/cmdforge/registry/app.py b/src/cmdforge/registry/app.py index df14380..e469b91 100644 --- a/src/cmdforge/registry/app.py +++ b/src/cmdforge/registry/app.py @@ -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///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///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///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/", 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/", 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//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//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///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///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/", 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/", 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//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//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//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//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//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 diff --git a/src/cmdforge/registry/db.py b/src/cmdforge/registry/db.py index 7c94074..9541173 100644 --- a/src/cmdforge/registry/db.py +++ b/src/cmdforge/registry/db.py @@ -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); """ diff --git a/src/cmdforge/registry/stats.py b/src/cmdforge/registry/stats.py new file mode 100644 index 0000000..f77286a --- /dev/null +++ b/src/cmdforge/registry/stats.py @@ -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) diff --git a/src/cmdforge/web/routes.py b/src/cmdforge/web/routes.py index e8da9f5..fb274c5 100644 --- a/src/cmdforge/web/routes.py +++ b/src/cmdforge/web/routes.py @@ -365,11 +365,31 @@ def tool_detail(owner: str, name: str): publisher = _load_publisher(owner) versions = _load_tool_versions(owner, name) + + # Load rating summary + _, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating") + rating = rating_payload.get("data", {}) + + # Load reviews (first page) + _, reviews_payload = _api_get(f"/api/v1/tools/{owner}/{name}/reviews", params={"per_page": 5}) + reviews = reviews_payload.get("data", []) + reviews_meta = reviews_payload.get("meta", {}) + + # Load issues summary (open only) + _, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params={"status": "open", "per_page": 5}) + issues = issues_payload.get("data", []) + issues_meta = issues_payload.get("meta", {}) + return render_template( "pages/tool_detail.html", tool=tool, publisher=publisher, versions=versions, + rating=rating, + reviews=reviews, + reviews_total=reviews_meta.get("total", 0), + issues=issues, + issues_total=issues_meta.get("total", 0), ) @@ -387,11 +407,31 @@ def tool_version(owner: str, name: str, version: str): publisher = _load_publisher(owner) versions = _load_tool_versions(owner, name) + + # Load rating summary + _, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating") + rating = rating_payload.get("data", {}) + + # Load reviews (first page) + _, reviews_payload = _api_get(f"/api/v1/tools/{owner}/{name}/reviews", params={"per_page": 5}) + reviews = reviews_payload.get("data", []) + reviews_meta = reviews_payload.get("meta", {}) + + # Load issues summary (open only) + _, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params={"status": "open", "per_page": 5}) + issues = issues_payload.get("data", []) + issues_meta = issues_payload.get("meta", {}) + return render_template( "pages/tool_detail.html", tool=tool, publisher=publisher, versions=versions, + rating=rating, + reviews=reviews, + reviews_total=reviews_meta.get("total", 0), + issues=issues, + issues_total=issues_meta.get("total", 0), ) @@ -401,10 +441,16 @@ def publisher(slug: str): if not publisher_row: return render_template("errors/404.html"), 404 tools = _load_publisher_tools(slug) + + # Load publisher stats and badges + _, stats_payload = _api_get(f"/api/v1/publishers/{slug}/stats") + stats = stats_payload.get("data", {}) + return render_template( "pages/publisher.html", publisher=publisher_row, tools=tools, + stats=stats, ) @@ -415,6 +461,73 @@ def _publisher_alias(slug: str): web_bp.add_url_rule("/publishers/", endpoint="publisher_profile", view_func=_publisher_alias) +@web_bp.route("/tools///reviews", endpoint="tool_reviews") +def tool_reviews(owner: str, name: str): + """Full reviews page for a tool.""" + status, payload = _api_get(f"/api/v1/tools/{owner}/{name}") + if status == 404: + return render_template("errors/404.html"), 404 + if status != 200: + return render_template("errors/500.html"), 500 + tool = payload.get("data", {}) + + page = request.args.get("page", 1, type=int) + sort = request.args.get("sort", "recent") + + _, reviews_payload = _api_get( + f"/api/v1/tools/{owner}/{name}/reviews", + params={"page": page, "per_page": 20, "sort": sort} + ) + reviews = reviews_payload.get("data", []) + meta = reviews_payload.get("meta", {}) + + _, rating_payload = _api_get(f"/api/v1/tools/{owner}/{name}/rating") + rating = rating_payload.get("data", {}) + + return render_template( + "pages/tool_reviews.html", + tool=tool, + reviews=reviews, + rating=rating, + pagination=_build_pagination(meta), + current_sort=sort, + ) + + +@web_bp.route("/tools///issues", endpoint="tool_issues") +def tool_issues(owner: str, name: str): + """Full issues page for a tool.""" + status, payload = _api_get(f"/api/v1/tools/{owner}/{name}") + if status == 404: + return render_template("errors/404.html"), 404 + if status != 200: + return render_template("errors/500.html"), 500 + tool = payload.get("data", {}) + + page = request.args.get("page", 1, type=int) + status_filter = request.args.get("status") + type_filter = request.args.get("type") + + params = {"page": page, "per_page": 20} + if status_filter: + params["status"] = status_filter + if type_filter: + params["type"] = type_filter + + _, issues_payload = _api_get(f"/api/v1/tools/{owner}/{name}/issues", params=params) + issues = issues_payload.get("data", []) + meta = issues_payload.get("meta", {}) + + return render_template( + "pages/tool_issues.html", + tool=tool, + issues=issues, + pagination=_build_pagination(meta), + current_status=status_filter, + current_type=type_filter, + ) + + def _render_dashboard_overview(): redirect_response = _require_login() if redirect_response: @@ -452,6 +565,16 @@ def dashboard_tools(): user = _load_current_publisher() or session.get("user", {}) status, payload = _api_get("/api/v1/me/tools", token=token) tools = payload.get("data", []) if status == 200 else [] + + # Load rating/issue data for each tool + for tool in tools: + _, rating_payload = _api_get(f"/api/v1/tools/{tool.get('owner')}/{tool.get('name')}/rating") + rating_data = rating_payload.get("data", {}) + tool["rating"] = rating_data.get("average_rating", 0) + tool["rating_count"] = rating_data.get("rating_count", 0) + tool["open_issues"] = rating_data.get("open_issues", 0) + tool["security_issues"] = rating_data.get("security_issues", 0) + token_status, token_payload = _api_get("/api/v1/tokens", token=token) tokens = token_payload.get("data", []) if token_status == 200 else [] stats = { diff --git a/src/cmdforge/web/templates/components/tool_card.html b/src/cmdforge/web/templates/components/tool_card.html index cfe9c67..802b702 100644 --- a/src/cmdforge/web/templates/components/tool_card.html +++ b/src/cmdforge/web/templates/components/tool_card.html @@ -1,5 +1,5 @@ {# Tool card macro #} -{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none) %} +{% macro tool_card(tool=none, owner=none, name=none, description=none, category=none, downloads=none, version=none, tags=none, rating=none, rating_count=none, has_issues=none) %} {% if tool is none %} {% set tool = { "owner": owner, @@ -8,7 +8,10 @@ "category": category, "downloads": downloads, "version": version, - "tags": tags + "tags": tags, + "rating": rating, + "rating_count": rating_count, + "has_issues": has_issues } %} {% endif %}
@@ -59,12 +62,29 @@
- - - - - {{ tool.downloads|default(0) }} downloads - +
+ + + + + {{ tool.downloads|default(0)|format_number if tool.downloads else 0 }} + + {% if tool.rating and tool.rating > 0 %} + + + + + {{ "%.1f"|format(tool.rating) }}{% if tool.rating_count %}({{ tool.rating_count }}){% endif %} + + {% endif %} + {% if tool.has_issues %} + + + + + + {% endif %} +
v{{ tool.version }}
diff --git a/src/cmdforge/web/templates/dashboard/tools.html b/src/cmdforge/web/templates/dashboard/tools.html index 8abdb0e..3598379 100644 --- a/src/cmdforge/web/templates/dashboard/tools.html +++ b/src/cmdforge/web/templates/dashboard/tools.html @@ -34,6 +34,12 @@ Downloads + + Rating + + + Issues + Status @@ -68,6 +74,33 @@ {{ tool.downloads|format_number }} + + {% if tool.rating and tool.rating > 0 %} +
+ + + + {{ "%.1f"|format(tool.rating) }} + ({{ tool.rating_count }}) +
+ {% else %} + No ratings + {% endif %} + + + {% if tool.open_issues %} +
+ {{ tool.open_issues }} + {% if tool.security_issues %} + + {{ tool.security_issues }} security + + {% endif %} +
+ {% else %} + None + {% endif %} + {% if tool.deprecated %} diff --git a/src/cmdforge/web/templates/pages/publisher.html b/src/cmdforge/web/templates/pages/publisher.html index 2990341..67d351b 100644 --- a/src/cmdforge/web/templates/pages/publisher.html +++ b/src/cmdforge/web/templates/pages/publisher.html @@ -32,7 +32,7 @@

{{ publisher.bio }}

{% endif %} - diff --git a/src/cmdforge/web/templates/pages/tool_detail.html b/src/cmdforge/web/templates/pages/tool_detail.html index 37cd669..44ef200 100644 --- a/src/cmdforge/web/templates/pages/tool_detail.html +++ b/src/cmdforge/web/templates/pages/tool_detail.html @@ -132,6 +132,153 @@ {% endif %} + + +
+ + + {% if reviews %} +
+ {% for review in reviews %} +
+
+
+
+ {% for i in range(1, 6) %} + + + + {% endfor %} +
+ {% if review.title %} +

{{ review.title }}

+ {% endif %} +
+ {{ review.created_at|timeago }} +
+ {% if review.content %} +

{{ review.content }}

+ {% endif %} +
+ + by {{ review.reviewer_name }} + +
+ + +
+
+
+ {% endfor %} +
+ {% if reviews_total > 5 %} + + {% endif %} + {% else %} +

+ No reviews yet. {% if session.get('auth_token') %}Be the first to review this tool!{% endif %} +

+ {% endif %} +
+ + +
+
+

Issues

+ +
+ + {% if issues %} +
+ {% for issue in issues %} +
+
+ + {{ issue.issue_type }} + +
+
+

{{ issue.title }}

+

+ + {{ issue.severity }} + + · {{ issue.created_at|timeago }} + · by {{ issue.reporter_name }} +

+
+
+ + {{ issue.status }} + +
+
+ {% endfor %} +
+ {% if issues_total > 5 %} + + {% endif %} + {% else %} +

+ No issues reported for this tool. +

+ {% endif %} +
@@ -206,9 +353,98 @@ {% endif %} + {% if rating.unique_users %} +
+
Users
+
{{ rating.unique_users|format_number }}
+
+ {% endif %} + +
+

Rating

+ {% if rating.rating_count and rating.rating_count > 0 %} +
+
+ {% set avg = rating.average_rating|default(0)|float %} + {% for i in range(1, 6) %} + + + + {% endfor %} +
+ {{ "%.1f"|format(avg) }} +
+

{{ rating.rating_count }} review{{ 's' if rating.rating_count != 1 else '' }}

+ +
+ {% for star in range(5, 0, -1) %} + {% set count = rating.distribution[star|string]|default(0) %} + {% set pct = (count / rating.rating_count * 100)|int if rating.rating_count else 0 %} +
+ {{ star }} + + + +
+
+
+ {{ count }} +
+ {% endfor %} +
+ {% else %} +

No reviews yet

+ {% endif %} + + {{ 'Write a review' if session.get('auth_token') else 'View all reviews' }} → + +
+ + + {% if issues_total > 0 or rating.security_issues %} +
+

Known Issues

+ {% if rating.security_issues %} +
+
+ + + + {{ rating.security_issues }} security issue{{ 's' if rating.security_issues != 1 else '' }} +
+
+ {% endif %} +
+
+
Open issues
+
{{ rating.open_issues|default(0) }}
+
+
+ {% if issues %} +
    + {% for issue in issues[:3] %} +
  • + + {{ issue.severity }} + + {{ issue.title|truncate(40) }} +
  • + {% endfor %} +
+ {% endif %} + + View all issues → + +
+ {% endif %} +

Publisher

@@ -299,6 +535,122 @@
+ + + + + + {% endblock %} diff --git a/src/cmdforge/web/templates/pages/tool_issues.html b/src/cmdforge/web/templates/pages/tool_issues.html new file mode 100644 index 0000000..9425ce5 --- /dev/null +++ b/src/cmdforge/web/templates/pages/tool_issues.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} + +{% block title %}Issues - {{ tool.owner }}/{{ tool.name }} - CmdForge{% endblock %} + +{% block content %} +
+ +
+
+ +
+
+ +
+ +
+

Issues for {{ tool.name }}

+ + + + + Report Issue + +
+ + +
+
+
+ + +
+
+ + +
+ {% if current_status or current_type %} + + Clear filters + + {% endif %} +
+
+ + + {% if issues %} +
+ {% for issue in issues %} +
+
+
+ + {{ issue.issue_type }} + +
+
+
+

{{ issue.title }}

+ + {{ issue.severity }} + +
+

+ Reported {{ issue.created_at|timeago }} by {{ issue.reporter_name }} + {% if issue.resolved_at %} + · Resolved {{ issue.resolved_at|timeago }} + {% endif %} +

+
+
+ + {% if issue.status == 'open' %} + + + + {% elif issue.status == 'confirmed' %} + + + + {% else %} + + + + {% endif %} + {{ issue.status }} + +
+
+
+ {% endfor %} +
+ + + {% if pagination.pages > 1 %} +
+ {% set url_params = {} %} + {% if current_status %}{% set _ = url_params.update({'status': current_status}) %}{% endif %} + {% if current_type %}{% set _ = url_params.update({'type': current_type}) %}{% endif %} + + {% if pagination.has_prev %} + + Previous + + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + + Next + + {% endif %} +
+ {% endif %} + {% else %} +
+ + + +

+ {% if current_status or current_type %} + No issues match your filters. + {% else %} + No issues reported for this tool. + {% endif %} +

+
+ {% endif %} +
+
+ + +{% endblock %} diff --git a/src/cmdforge/web/templates/pages/tool_reviews.html b/src/cmdforge/web/templates/pages/tool_reviews.html new file mode 100644 index 0000000..f2b18c6 --- /dev/null +++ b/src/cmdforge/web/templates/pages/tool_reviews.html @@ -0,0 +1,156 @@ +{% extends "base.html" %} + +{% block title %}Reviews - {{ tool.owner }}/{{ tool.name }} - CmdForge{% endblock %} + +{% block content %} +
+ +
+
+ +
+
+ +
+ +
+

Reviews for {{ tool.name }}

+ {% if session.get('auth_token') %} + + Write Review + + {% endif %} +
+ + + {% if rating.rating_count and rating.rating_count > 0 %} +
+
+
+
{{ "%.1f"|format(rating.average_rating) }}
+
+ {% set avg = rating.average_rating|default(0)|float %} + {% for i in range(1, 6) %} + + + + {% endfor %} +
+

{{ rating.rating_count }} reviews

+
+
+ {% for star in range(5, 0, -1) %} + {% set count = rating.distribution[star|string]|default(0) %} + {% set pct = (count / rating.rating_count * 100)|int if rating.rating_count else 0 %} +
+ {{ star }} + + + +
+
+
+ {{ count }} +
+ {% endfor %} +
+
+
+ {% endif %} + + + + + + {% if reviews %} +
+ {% for review in reviews %} +
+
+
+
+ {% for i in range(1, 6) %} + + + + {% endfor %} +
+ {% if review.title %} +

{{ review.title }}

+ {% endif %} +
+ {{ review.created_at|timeago }} +
+ {% if review.content %} +

{{ review.content }}

+ {% endif %} +
+ + by {{ review.reviewer_name }} + +
+ + + + + {{ review.helpful_count }} found helpful + +
+
+
+ {% endfor %} +
+ + + {% if pagination.pages > 1 %} +
+ {% if pagination.has_prev %} + + Previous + + {% endif %} + Page {{ pagination.page }} of {{ pagination.pages }} + {% if pagination.has_next %} + + Next + + {% endif %} +
+ {% endif %} + {% else %} +
+ + + +

No reviews yet for this tool.

+ {% if session.get('auth_token') %} + + Be the first to review + + {% endif %} +
+ {% endif %} +
+
+{% endblock %}