Add tool rating & review from My Tools page
- Server: add GET /api/v1/tools/<owner>/<name>/my-review endpoint - Client: add get_tool_rating, get_my_review, submit/update/delete review methods - GUI: new ReviewDialog with star selector, title, content, submit/update/delete - Tools page: rating display in details panel, Rate/Edit Rating button with context-sensitive enable/disable, background workers, rating cache - Fix qualified name usage throughout tools page for owner-prefixed tools Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b00115a52e
commit
14b3f3d856
|
|
@ -0,0 +1,206 @@
|
|||
"""Review dialog for rating registry tools."""
|
||||
|
||||
from typing import Optional, Dict
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QLineEdit, QPlainTextEdit, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
|
||||
class ReviewDialog(QDialog):
|
||||
"""Dialog for submitting or editing a tool review."""
|
||||
|
||||
# Set when user clicks Delete - caller checks this after reject()
|
||||
delete_requested = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent,
|
||||
owner: str,
|
||||
name: str,
|
||||
existing_review: Optional[Dict] = None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._owner = owner
|
||||
self._name = name
|
||||
self._existing = existing_review
|
||||
self._rating = existing_review["rating"] if existing_review else 0
|
||||
self.delete_requested = False
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
editing = self._existing is not None
|
||||
self.setWindowTitle(f"{'Edit Review' if editing else 'Rate Tool'} - {self._owner}/{self._name}")
|
||||
self.setMinimumSize(420, 380)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(14)
|
||||
|
||||
# Title
|
||||
title = QLabel(f"{'Edit your review' if editing else 'Rate'} {self._owner}/{self._name}")
|
||||
title.setStyleSheet("font-size: 15px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Star selector
|
||||
star_label = QLabel("Rating:")
|
||||
star_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(star_label)
|
||||
|
||||
star_row = QHBoxLayout()
|
||||
star_row.setSpacing(4)
|
||||
self._star_buttons = []
|
||||
for i in range(1, 6):
|
||||
btn = QPushButton("☆")
|
||||
btn.setFixedSize(36, 36)
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
""")
|
||||
btn.setCursor(Qt.PointingHandCursor)
|
||||
btn.clicked.connect(lambda checked, n=i: self._set_rating(n))
|
||||
self._star_buttons.append(btn)
|
||||
star_row.addWidget(btn)
|
||||
star_row.addStretch()
|
||||
layout.addLayout(star_row)
|
||||
|
||||
# Pre-fill rating
|
||||
if self._rating:
|
||||
self._set_rating(self._rating)
|
||||
|
||||
# Title input
|
||||
title_label = QLabel("Title (optional):")
|
||||
title_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
self.title_input = QLineEdit()
|
||||
self.title_input.setMaxLength(100)
|
||||
self.title_input.setPlaceholderText("Brief summary of your review")
|
||||
self.title_input.setMinimumHeight(28)
|
||||
if self._existing and self._existing.get("title"):
|
||||
self.title_input.setText(self._existing["title"])
|
||||
layout.addWidget(self.title_input)
|
||||
|
||||
# Content input
|
||||
content_label = QLabel("Review (optional, min 10 chars if provided):")
|
||||
content_label.setStyleSheet("font-weight: 500;")
|
||||
layout.addWidget(content_label)
|
||||
|
||||
self.content_input = QPlainTextEdit()
|
||||
self.content_input.setPlaceholderText("Share your experience with this tool...")
|
||||
self.content_input.setMinimumHeight(100)
|
||||
self.content_input.setMaximumHeight(180)
|
||||
if self._existing and self._existing.get("content"):
|
||||
self.content_input.setPlainText(self._existing["content"])
|
||||
layout.addWidget(self.content_input)
|
||||
|
||||
# Character count
|
||||
self._char_count = QLabel("0 / 2000")
|
||||
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
self._char_count.setAlignment(Qt.AlignRight)
|
||||
self.content_input.textChanged.connect(self._update_char_count)
|
||||
self._update_char_count()
|
||||
layout.addWidget(self._char_count)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
btn_layout = QHBoxLayout()
|
||||
|
||||
if editing:
|
||||
self.btn_delete = QPushButton("Delete Review")
|
||||
self.btn_delete.setObjectName("danger")
|
||||
self.btn_delete.clicked.connect(self._on_delete)
|
||||
btn_layout.addWidget(self.btn_delete)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
btn_cancel = QPushButton("Cancel")
|
||||
btn_cancel.setObjectName("secondary")
|
||||
btn_cancel.clicked.connect(self.reject)
|
||||
btn_layout.addWidget(btn_cancel)
|
||||
|
||||
submit_text = "Update" if editing else "Submit"
|
||||
self.btn_submit = QPushButton(submit_text)
|
||||
self.btn_submit.clicked.connect(self._on_submit)
|
||||
btn_layout.addWidget(self.btn_submit)
|
||||
|
||||
layout.addLayout(btn_layout)
|
||||
|
||||
def _set_rating(self, n: int):
|
||||
self._rating = n
|
||||
for i, btn in enumerate(self._star_buttons):
|
||||
if i < n:
|
||||
btn.setText("★")
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 20px;
|
||||
border: 1px solid #d69e2e;
|
||||
border-radius: 4px;
|
||||
background: #fefcbf;
|
||||
color: #d69e2e;
|
||||
}
|
||||
QPushButton:hover { background: #fef3c7; }
|
||||
""")
|
||||
else:
|
||||
btn.setText("☆")
|
||||
btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
font-size: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #f7fafc;
|
||||
}
|
||||
QPushButton:hover { background: #edf2f7; }
|
||||
""")
|
||||
|
||||
def _update_char_count(self):
|
||||
count = len(self.content_input.toPlainText())
|
||||
self._char_count.setText(f"{count} / 2000")
|
||||
if count > 2000:
|
||||
self._char_count.setStyleSheet("color: #e53e3e; font-size: 11px;")
|
||||
else:
|
||||
self._char_count.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
|
||||
def _on_submit(self):
|
||||
if self._rating < 1:
|
||||
QMessageBox.warning(self, "Validation", "Please select a rating (1-5 stars).")
|
||||
return
|
||||
|
||||
content = self.content_input.toPlainText().strip()
|
||||
if content and len(content) < 10:
|
||||
QMessageBox.warning(self, "Validation", "Review content must be at least 10 characters.")
|
||||
return
|
||||
|
||||
if len(content) > 2000:
|
||||
QMessageBox.warning(self, "Validation", "Review content must be 2000 characters or less.")
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def _on_delete(self):
|
||||
reply = QMessageBox.question(
|
||||
self, "Delete Review",
|
||||
"Are you sure you want to delete your review?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.delete_requested = True
|
||||
self.reject()
|
||||
|
||||
def get_review_data(self) -> Dict:
|
||||
"""Get the review data entered by the user."""
|
||||
return {
|
||||
"rating": self._rating,
|
||||
"title": self.title_input.text().strip(),
|
||||
"content": self.content_input.toPlainText().strip()[:2000],
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from typing import Optional, Tuple, Dict, Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
|
@ -154,6 +154,138 @@ def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]:
|
|||
return ("local", None)
|
||||
|
||||
|
||||
def get_tool_registry_info(tool_name: str) -> Optional[Tuple[str, str]]:
|
||||
"""
|
||||
Get registry owner/name for a tool if it's registry-sourced.
|
||||
|
||||
Returns:
|
||||
(owner, name) tuple if tool is from registry, None if local-only.
|
||||
"""
|
||||
tools_dir = get_tools_dir()
|
||||
config_path = tools_dir / tool_name / "config.yaml"
|
||||
if not config_path.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
config_data = yaml.safe_load(config_path.read_text()) or {}
|
||||
if not config_data.get("registry_hash"):
|
||||
return None
|
||||
|
||||
# Check if stored under owner subdir: ~/.cmdforge/<owner>/<name>/
|
||||
if "/" in tool_name:
|
||||
owner, name = tool_name.split("/", 1)
|
||||
return (owner, name)
|
||||
|
||||
# Flat dir: check source.author or installed_from in config
|
||||
source = config_data.get("source", {})
|
||||
if isinstance(source, dict) and source.get("author"):
|
||||
return (source["author"], tool_name)
|
||||
if config_data.get("installed_from"):
|
||||
parts = config_data["installed_from"].split("/", 1)
|
||||
if len(parts) == 2:
|
||||
return (parts[0], parts[1])
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
class RatingWorker(QThread):
|
||||
"""Background worker to fetch rating summary and user's review for a tool."""
|
||||
finished = Signal(str, dict) # tool_name, result dict
|
||||
error = Signal(str, str) # tool_name, error message
|
||||
|
||||
def __init__(self, tool_name: str, owner: str, name: str):
|
||||
super().__init__()
|
||||
self.tool_name = tool_name
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
|
||||
def run(self):
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result: Dict[str, Any] = {"rating": None, "my_review": None}
|
||||
|
||||
try:
|
||||
result["rating"] = client.get_tool_rating(self.owner, self.name)
|
||||
except RegistryError:
|
||||
pass
|
||||
|
||||
if client.token:
|
||||
try:
|
||||
result["my_review"] = client.get_my_review(self.owner, self.name)
|
||||
except RegistryError:
|
||||
pass
|
||||
|
||||
self.finished.emit(self.tool_name, result)
|
||||
except Exception as e:
|
||||
self.error.emit(self.tool_name, str(e))
|
||||
|
||||
|
||||
class SubmitReviewWorker(QThread):
|
||||
"""Background worker to submit or update a review."""
|
||||
finished = Signal(str) # success message
|
||||
error = Signal(str) # error message
|
||||
|
||||
def __init__(self, owner: str, name: str, rating: int, title: str, content: str,
|
||||
review_id: Optional[int] = None):
|
||||
super().__init__()
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
self.rating = rating
|
||||
self.title = title
|
||||
self.content = content
|
||||
self.review_id = review_id # None = new, int = update
|
||||
|
||||
def run(self):
|
||||
from ...registry_client import RegistryClient, RegistryError, RateLimitError
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
if self.review_id is not None:
|
||||
client.update_review(self.review_id, self.rating, self.title, self.content)
|
||||
self.finished.emit("Review updated successfully")
|
||||
else:
|
||||
client.submit_review(self.owner, self.name, self.rating, self.title, self.content)
|
||||
self.finished.emit("Review submitted successfully")
|
||||
except RateLimitError as e:
|
||||
minutes = max(1, e.retry_after // 60)
|
||||
self.error.emit(f"Too many reviews. Try again in {minutes} minute(s).")
|
||||
except RegistryError as e:
|
||||
self.error.emit(e.message)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class DeleteReviewWorker(QThread):
|
||||
"""Background worker to delete a review."""
|
||||
finished = Signal(str)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, review_id: int):
|
||||
super().__init__()
|
||||
self.review_id = review_id
|
||||
|
||||
def run(self):
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
client.delete_review(self.review_id)
|
||||
self.finished.emit("Review deleted successfully")
|
||||
except RegistryError as e:
|
||||
self.error.emit(e.message)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class ToolsPage(QWidget):
|
||||
"""Main tools management page."""
|
||||
|
||||
|
|
@ -165,6 +297,11 @@ class ToolsPage(QWidget):
|
|||
self._syncing = False # Prevent re-sync during update
|
||||
self._poll_timer = None # Timer for automatic status polling
|
||||
self._has_pending_tools = False # Track if we need to poll
|
||||
self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review}
|
||||
self._rating_worker = None
|
||||
self._review_worker = None
|
||||
self._my_slug: Optional[str] = None # Cached current user slug
|
||||
self._my_slug_fetched = False
|
||||
self._setup_ui()
|
||||
self.refresh()
|
||||
|
||||
|
|
@ -264,6 +401,13 @@ class ToolsPage(QWidget):
|
|||
self.btn_delete.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_delete)
|
||||
|
||||
self.btn_rate = QPushButton("Rate Tool")
|
||||
self.btn_rate.setObjectName("secondary")
|
||||
self.btn_rate.clicked.connect(self._rate_tool)
|
||||
self.btn_rate.setEnabled(False)
|
||||
self.btn_rate.setToolTip("Local tools can't be rated")
|
||||
btn_layout.addWidget(self.btn_rate)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
# Connect/Publish button
|
||||
|
|
@ -498,7 +642,8 @@ class ToolsPage(QWidget):
|
|||
tool = load_tool(tool_name)
|
||||
if tool:
|
||||
self._current_tool = tool
|
||||
self._show_tool_info(tool)
|
||||
self._show_tool_info(tool, tool_name)
|
||||
self._fetch_rating_if_needed(tool_name)
|
||||
self._update_buttons()
|
||||
|
||||
def _get_qualified_name(self) -> Optional[str]:
|
||||
|
|
@ -525,8 +670,15 @@ class ToolsPage(QWidget):
|
|||
pass
|
||||
return None
|
||||
|
||||
def _show_tool_info(self, tool: Tool):
|
||||
"""Display tool information."""
|
||||
def _show_tool_info(self, tool: Tool, qualified_name: Optional[str] = None):
|
||||
"""Display tool information.
|
||||
|
||||
Args:
|
||||
tool: The Tool object.
|
||||
qualified_name: The qualified name (e.g. 'official/foo') used for
|
||||
config path lookups. Falls back to tool.name if not provided.
|
||||
"""
|
||||
qname = qualified_name or tool.name
|
||||
lines = []
|
||||
|
||||
# Name and description
|
||||
|
|
@ -535,7 +687,7 @@ class ToolsPage(QWidget):
|
|||
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
||||
|
||||
# Publish state
|
||||
state, registry_hash = get_tool_publish_state(tool.name)
|
||||
state, registry_hash = get_tool_publish_state(qname)
|
||||
if state == "published":
|
||||
lines.append(
|
||||
"<p style='background: #c6f6d5; color: #276749; padding: 6px 10px; "
|
||||
|
|
@ -561,7 +713,7 @@ class ToolsPage(QWidget):
|
|||
"⚠ Changes requested - please address feedback and republish</p>"
|
||||
)
|
||||
# Show feedback if available
|
||||
feedback = self._get_tool_feedback(tool.name)
|
||||
feedback = self._get_tool_feedback(qname)
|
||||
if feedback:
|
||||
feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
||||
lines.append(
|
||||
|
|
@ -576,7 +728,7 @@ class ToolsPage(QWidget):
|
|||
"✗ Rejected by moderator</p>"
|
||||
)
|
||||
# Show feedback if available
|
||||
feedback = self._get_tool_feedback(tool.name)
|
||||
feedback = self._get_tool_feedback(qname)
|
||||
if feedback:
|
||||
feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "<br>")
|
||||
lines.append(
|
||||
|
|
@ -585,6 +737,43 @@ class ToolsPage(QWidget):
|
|||
f"<strong>Reason:</strong><br>{feedback_escaped}</div>"
|
||||
)
|
||||
|
||||
# Rating section (for registry-sourced tools)
|
||||
registry_info = get_tool_registry_info(qname)
|
||||
if registry_info:
|
||||
cached = self._rating_cache.get(qname)
|
||||
if cached:
|
||||
rating_data = cached.get("rating")
|
||||
my_review = cached.get("my_review")
|
||||
if rating_data:
|
||||
avg = rating_data.get("average_rating", 0)
|
||||
count = rating_data.get("rating_count", 0)
|
||||
if count > 0:
|
||||
filled = round(avg)
|
||||
stars_html = "".join(
|
||||
"★" if i < filled else "☆" for i in range(5)
|
||||
)
|
||||
lines.append(
|
||||
f"<p style='color: #d69e2e; font-size: 14px; margin-bottom: 4px;'>"
|
||||
f"{stars_html} <span style='color: #4a5568; font-size: 12px;'>"
|
||||
f"{avg:.1f}/5 from {count} review{'s' if count != 1 else ''}</span></p>"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
"<p style='color: #718096; font-size: 12px; margin-bottom: 4px;'>"
|
||||
"No ratings yet</p>"
|
||||
)
|
||||
if my_review:
|
||||
my_stars = "★" * my_review["rating"] + "☆" * (5 - my_review["rating"])
|
||||
lines.append(
|
||||
f"<p style='color: #805ad5; font-size: 12px; margin-bottom: 8px;'>"
|
||||
f"Your rating: {my_stars}</p>"
|
||||
)
|
||||
elif registry_info:
|
||||
lines.append(
|
||||
"<p style='color: #718096; font-size: 12px; margin-bottom: 4px;'>"
|
||||
"Loading ratings...</p>"
|
||||
)
|
||||
|
||||
# Source info
|
||||
if tool.source:
|
||||
source_type = tool.source.type
|
||||
|
|
@ -654,6 +843,9 @@ class ToolsPage(QWidget):
|
|||
# Not connected - Connect button is always enabled
|
||||
self.btn_publish.setEnabled(True)
|
||||
|
||||
# Rate button state
|
||||
self._update_rate_button()
|
||||
|
||||
def _create_tool(self):
|
||||
"""Create a new tool."""
|
||||
self.main_window.open_tool_builder()
|
||||
|
|
@ -734,12 +926,196 @@ class ToolsPage(QWidget):
|
|||
if result:
|
||||
self.main_window.show_status(f"Published '{tool_name}'")
|
||||
|
||||
def _update_rate_button(self):
|
||||
"""Update the Rate button state based on current selection."""
|
||||
if not self._current_tool:
|
||||
self.btn_rate.setEnabled(False)
|
||||
self.btn_rate.setText("Rate Tool")
|
||||
self.btn_rate.setToolTip("Select a tool to rate")
|
||||
return
|
||||
|
||||
tool_name = self._get_qualified_name()
|
||||
if not tool_name:
|
||||
self.btn_rate.setEnabled(False)
|
||||
self.btn_rate.setText("Rate Tool")
|
||||
self.btn_rate.setToolTip("Select a tool to rate")
|
||||
return
|
||||
|
||||
registry_info = get_tool_registry_info(tool_name)
|
||||
|
||||
if not registry_info:
|
||||
self.btn_rate.setEnabled(False)
|
||||
self.btn_rate.setText("Rate Tool")
|
||||
self.btn_rate.setToolTip("Local tools can't be rated")
|
||||
return
|
||||
|
||||
config = load_config()
|
||||
if not config.registry.token:
|
||||
self.btn_rate.setEnabled(False)
|
||||
self.btn_rate.setText("Rate Tool")
|
||||
self.btn_rate.setToolTip("Sign in to rate")
|
||||
return
|
||||
|
||||
owner, name = registry_info
|
||||
|
||||
# Check if this is the user's own tool
|
||||
if self._my_slug and owner == self._my_slug:
|
||||
self.btn_rate.setEnabled(False)
|
||||
self.btn_rate.setText("Rate Tool")
|
||||
self.btn_rate.setToolTip("You can't rate your own tool")
|
||||
return
|
||||
|
||||
# Check if user has an existing review
|
||||
cached = self._rating_cache.get(tool_name)
|
||||
if cached and cached.get("my_review"):
|
||||
self.btn_rate.setText("Edit Rating")
|
||||
else:
|
||||
self.btn_rate.setText("Rate Tool")
|
||||
|
||||
self.btn_rate.setEnabled(True)
|
||||
self.btn_rate.setToolTip("")
|
||||
|
||||
def _fetch_my_slug(self):
|
||||
"""Fetch and cache the current user's slug."""
|
||||
if self._my_slug_fetched:
|
||||
return
|
||||
self._my_slug_fetched = True
|
||||
|
||||
config = load_config()
|
||||
if not config.registry.token:
|
||||
return
|
||||
|
||||
try:
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
me = client.get_me()
|
||||
self._my_slug = me.get("slug")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fetch_rating_if_needed(self, tool_name: str):
|
||||
"""Fetch rating data for a registry tool if not cached."""
|
||||
registry_info = get_tool_registry_info(tool_name)
|
||||
if not registry_info:
|
||||
return
|
||||
|
||||
# Ensure we have the user slug (non-blocking first time; runs in main thread once)
|
||||
if not self._my_slug_fetched:
|
||||
self._fetch_my_slug()
|
||||
|
||||
if tool_name in self._rating_cache:
|
||||
# Already cached - just update buttons
|
||||
self._update_rate_button()
|
||||
return
|
||||
|
||||
owner, name = registry_info
|
||||
|
||||
# Stop any existing rating worker
|
||||
if self._rating_worker and self._rating_worker.isRunning():
|
||||
self._rating_worker.wait(500)
|
||||
|
||||
self._rating_worker = RatingWorker(tool_name, owner, name)
|
||||
self._rating_worker.finished.connect(self._on_rating_fetched)
|
||||
self._rating_worker.error.connect(self._on_rating_error)
|
||||
self._rating_worker.start()
|
||||
|
||||
def _on_rating_fetched(self, tool_name: str, result: Dict):
|
||||
"""Handle rating data fetched from registry."""
|
||||
self._rating_cache[tool_name] = result
|
||||
|
||||
# If this is still the selected tool, refresh its display
|
||||
qualified = self._get_qualified_name()
|
||||
current_name = qualified or (self._current_tool.name if self._current_tool else None)
|
||||
if current_name == tool_name and self._current_tool:
|
||||
self._show_tool_info(self._current_tool, tool_name)
|
||||
self._update_rate_button()
|
||||
|
||||
def _on_rating_error(self, tool_name: str, error: str):
|
||||
"""Handle rating fetch error (non-critical)."""
|
||||
pass
|
||||
|
||||
def _rate_tool(self):
|
||||
"""Open the review dialog for the selected tool."""
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
tool_name = self._get_qualified_name() or self._current_tool.name
|
||||
registry_info = get_tool_registry_info(tool_name)
|
||||
if not registry_info:
|
||||
return
|
||||
|
||||
owner, name = registry_info
|
||||
cached = self._rating_cache.get(tool_name, {})
|
||||
existing_review = cached.get("my_review")
|
||||
|
||||
from ..dialogs.review_dialog import ReviewDialog
|
||||
dialog = ReviewDialog(self, owner, name, existing_review)
|
||||
result = dialog.exec()
|
||||
|
||||
if dialog.delete_requested and existing_review:
|
||||
# User wants to delete their review
|
||||
self._delete_review(tool_name, existing_review["id"])
|
||||
elif result:
|
||||
# User submitted/updated
|
||||
data = dialog.get_review_data()
|
||||
review_id = existing_review["id"] if existing_review else None
|
||||
self._submit_review(tool_name, owner, name, data, review_id)
|
||||
|
||||
def _submit_review(self, tool_name: str, owner: str, name: str,
|
||||
data: Dict, review_id: Optional[int]):
|
||||
"""Submit or update a review in background."""
|
||||
if self._review_worker and self._review_worker.isRunning():
|
||||
self._review_worker.wait(1000)
|
||||
|
||||
self._review_worker = SubmitReviewWorker(
|
||||
owner, name, data["rating"], data["title"], data["content"],
|
||||
review_id=review_id
|
||||
)
|
||||
self._review_worker.finished.connect(
|
||||
lambda msg: self._on_review_action_done(tool_name, msg)
|
||||
)
|
||||
self._review_worker.error.connect(
|
||||
lambda err: self._on_review_action_error(err)
|
||||
)
|
||||
self._review_worker.start()
|
||||
|
||||
def _delete_review(self, tool_name: str, review_id: int):
|
||||
"""Delete a review in background."""
|
||||
if self._review_worker and self._review_worker.isRunning():
|
||||
self._review_worker.wait(1000)
|
||||
|
||||
self._review_worker = DeleteReviewWorker(review_id)
|
||||
self._review_worker.finished.connect(
|
||||
lambda msg: self._on_review_action_done(tool_name, msg)
|
||||
)
|
||||
self._review_worker.error.connect(
|
||||
lambda err: self._on_review_action_error(err)
|
||||
)
|
||||
self._review_worker.start()
|
||||
|
||||
def _on_review_action_done(self, tool_name: str, message: str):
|
||||
"""Handle review submit/update/delete success."""
|
||||
self.main_window.show_status(message)
|
||||
|
||||
# Invalidate cache and re-fetch
|
||||
self._rating_cache.pop(tool_name, None)
|
||||
|
||||
qualified = self._get_qualified_name()
|
||||
current_name = qualified or (self._current_tool.name if self._current_tool else None)
|
||||
if current_name == tool_name:
|
||||
self._fetch_rating_if_needed(tool_name)
|
||||
|
||||
def _on_review_action_error(self, error: str):
|
||||
"""Handle review action error."""
|
||||
self.main_window.show_status(f"Review error: {error}")
|
||||
|
||||
def _sync_tool_status(self):
|
||||
"""Sync the moderation status of the selected tool from the registry."""
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
tool_name = self._current_tool.name
|
||||
tool_name = self._get_qualified_name() or self._current_tool.name
|
||||
config_path = get_tools_dir() / tool_name / "config.yaml"
|
||||
|
||||
if not config_path.exists():
|
||||
|
|
|
|||
|
|
@ -4318,6 +4318,40 @@ def create_app() -> Flask:
|
|||
|
||||
# ─── Reviews & Ratings API ────────────────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/my-review", methods=["GET"])
|
||||
@require_token
|
||||
def get_my_review(owner: str, name: str) -> Response:
|
||||
"""Get the current user's review 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)
|
||||
|
||||
review = query_one(
|
||||
g.db,
|
||||
"SELECT id, rating, title, content, created_at, updated_at FROM reviews WHERE tool_id = ? AND reviewer_id = ?",
|
||||
[tool["id"], g.current_publisher["id"]],
|
||||
)
|
||||
if not review:
|
||||
return error_response("REVIEW_NOT_FOUND", "No review found", 404)
|
||||
|
||||
return jsonify({
|
||||
"data": {
|
||||
"id": review["id"],
|
||||
"rating": review["rating"],
|
||||
"title": review["title"],
|
||||
"content": review["content"],
|
||||
"created_at": review["created_at"],
|
||||
"updated_at": review["updated_at"],
|
||||
}
|
||||
})
|
||||
|
||||
@app.route("/api/v1/tools/<owner>/<name>/reviews", methods=["POST"])
|
||||
@require_token
|
||||
def submit_review(owner: str, name: str) -> Response:
|
||||
|
|
|
|||
|
|
@ -821,6 +821,138 @@ class RegistryClient:
|
|||
|
||||
return response.json().get("data", {})
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Reviews & Ratings
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
def get_tool_rating(self, owner: str, name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get rating summary for a tool.
|
||||
|
||||
Args:
|
||||
owner: Tool owner
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Dict with average_rating, rating_count, distribution, etc.
|
||||
"""
|
||||
response = self._request("GET", f"/tools/{owner}/{name}/rating")
|
||||
|
||||
if response.status_code == 404:
|
||||
raise RegistryError(
|
||||
code="TOOL_NOT_FOUND",
|
||||
message=f"Tool '{owner}/{name}' not found",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def get_my_review(self, owner: str, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get the current user's review for a tool.
|
||||
|
||||
Args:
|
||||
owner: Tool owner
|
||||
name: Tool name
|
||||
|
||||
Returns:
|
||||
Review dict or None if no review exists
|
||||
"""
|
||||
response = self._request("GET", f"/tools/{owner}/{name}/my-review", require_auth=True)
|
||||
|
||||
if response.status_code == 404:
|
||||
# No review found - not an error
|
||||
return None
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def submit_review(
|
||||
self, owner: str, name: str, rating: int, title: str = "", content: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Submit a review for a tool.
|
||||
|
||||
Args:
|
||||
owner: Tool owner
|
||||
name: Tool name
|
||||
rating: Rating 1-5
|
||||
title: Optional review title
|
||||
content: Optional review content
|
||||
|
||||
Returns:
|
||||
Dict with review id and data
|
||||
"""
|
||||
payload: Dict[str, Any] = {"rating": rating}
|
||||
if title:
|
||||
payload["title"] = title
|
||||
if content:
|
||||
payload["content"] = content
|
||||
|
||||
response = self._request(
|
||||
"POST", f"/tools/{owner}/{name}/reviews",
|
||||
json_data=payload,
|
||||
require_auth=True
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def update_review(
|
||||
self, review_id: int, rating: int, title: str = "", content: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update an existing review.
|
||||
|
||||
Args:
|
||||
review_id: Review ID
|
||||
rating: Rating 1-5
|
||||
title: Review title
|
||||
content: Review content
|
||||
|
||||
Returns:
|
||||
Dict with status
|
||||
"""
|
||||
payload: Dict[str, Any] = {"rating": rating, "title": title, "content": content}
|
||||
|
||||
response = self._request(
|
||||
"PUT", f"/reviews/{review_id}",
|
||||
json_data=payload,
|
||||
require_auth=True
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def delete_review(self, review_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a review.
|
||||
|
||||
Args:
|
||||
review_id: Review ID
|
||||
|
||||
Returns:
|
||||
Dict with status
|
||||
"""
|
||||
response = self._request(
|
||||
"DELETE", f"/reviews/{review_id}",
|
||||
require_auth=True
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
self._handle_error_response(response)
|
||||
|
||||
return response.json().get("data", {})
|
||||
|
||||
def get_popular_tools(self, limit: int = 10) -> List[ToolInfo]:
|
||||
"""
|
||||
Get most popular tools.
|
||||
|
|
|
|||
Loading…
Reference in New Issue