Add Request Changes feature for admin tool moderation
- Add POST /api/v1/admin/tools/{id}/request-changes endpoint
- Sets moderation_status to 'changes_requested' with feedback
- Extend /me/tools/{name}/status to return feedback when status is
changes_requested or rejected
- Add Request Changes button and modal in admin pending UI
- Make changes modal draggable like other modals
This allows admins to send feedback to publishers instead of just
approving or rejecting tools outright.
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9029286d4c
commit
cd4c0682e8
|
|
@ -2001,7 +2001,7 @@ def create_app() -> Flask:
|
||||||
row = query_one(
|
row = query_one(
|
||||||
g.db,
|
g.db,
|
||||||
"""
|
"""
|
||||||
SELECT name, version, moderation_status, config_hash, published_at
|
SELECT name, version, moderation_status, moderation_note, config_hash, published_at
|
||||||
FROM tools
|
FROM tools
|
||||||
WHERE owner = ? AND name = ?
|
WHERE owner = ? AND name = ?
|
||||||
ORDER BY published_at DESC
|
ORDER BY published_at DESC
|
||||||
|
|
@ -2013,15 +2013,19 @@ def create_app() -> Flask:
|
||||||
if not row:
|
if not row:
|
||||||
return error_response("TOOL_NOT_FOUND", f"Tool '{name}' not found", 404)
|
return error_response("TOOL_NOT_FOUND", f"Tool '{name}' not found", 404)
|
||||||
|
|
||||||
return jsonify({
|
result = {
|
||||||
"data": {
|
"name": row["name"],
|
||||||
"name": row["name"],
|
"version": row["version"],
|
||||||
"version": row["version"],
|
"status": row["moderation_status"],
|
||||||
"status": row["moderation_status"],
|
"config_hash": row["config_hash"],
|
||||||
"config_hash": row["config_hash"],
|
"published_at": row["published_at"],
|
||||||
"published_at": row["published_at"],
|
}
|
||||||
}
|
|
||||||
})
|
# Include feedback if status is changes_requested or rejected
|
||||||
|
if row["moderation_status"] in ("changes_requested", "rejected") and row["moderation_note"]:
|
||||||
|
result["feedback"] = row["moderation_note"]
|
||||||
|
|
||||||
|
return jsonify({"data": result})
|
||||||
|
|
||||||
@app.route("/api/v1/tools/<owner>/<name>/deprecate", methods=["POST"])
|
@app.route("/api/v1/tools/<owner>/<name>/deprecate", methods=["POST"])
|
||||||
@require_token
|
@require_token
|
||||||
|
|
@ -2665,6 +2669,41 @@ def create_app() -> Flask:
|
||||||
|
|
||||||
return jsonify({"data": {"status": "rejected", "tool_id": tool_id}})
|
return jsonify({"data": {"status": "rejected", "tool_id": tool_id}})
|
||||||
|
|
||||||
|
@app.route("/api/v1/admin/tools/<int:tool_id>/request-changes", methods=["POST"])
|
||||||
|
@require_moderator
|
||||||
|
def admin_request_changes(tool_id: int) -> Response:
|
||||||
|
"""Request changes from tool publisher before approval."""
|
||||||
|
data = request.get_json() or {}
|
||||||
|
feedback = (data.get("feedback") or data.get("reason") or "").strip()
|
||||||
|
|
||||||
|
if not feedback:
|
||||||
|
return error_response("VALIDATION_ERROR", "Feedback is required", 400)
|
||||||
|
|
||||||
|
tool = query_one(g.db, "SELECT * FROM tools WHERE id = ?", [tool_id])
|
||||||
|
if not tool:
|
||||||
|
return error_response("TOOL_NOT_FOUND", "Tool not found", 404)
|
||||||
|
|
||||||
|
g.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE tools
|
||||||
|
SET moderation_status = 'changes_requested',
|
||||||
|
moderation_note = ?,
|
||||||
|
moderated_by = ?,
|
||||||
|
moderated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
[feedback, g.current_publisher["slug"], datetime.utcnow().isoformat(), tool_id],
|
||||||
|
)
|
||||||
|
g.db.commit()
|
||||||
|
|
||||||
|
log_audit("request_changes", "tool", str(tool_id), {
|
||||||
|
"tool": f"{tool['owner']}/{tool['name']}",
|
||||||
|
"version": tool["version"],
|
||||||
|
"feedback": feedback[:200], # Truncate for audit log
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"data": {"status": "changes_requested", "tool_id": tool_id}})
|
||||||
|
|
||||||
@app.route("/api/v1/admin/scrutiny", methods=["GET"])
|
@app.route("/api/v1/admin/scrutiny", methods=["GET"])
|
||||||
@require_moderator
|
@require_moderator
|
||||||
def admin_scrutiny_audit() -> Response:
|
def admin_scrutiny_audit() -> Response:
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,8 @@
|
||||||
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
{{ tool.published_at[:10] if tool.published_at else 'Unknown' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
<button onclick="approveTool({{ tool.id }})" class="text-green-600 hover:text-green-900 mr-3">Approve</button>
|
<button onclick="approveTool({{ tool.id }})" class="text-green-600 hover:text-green-900 mr-2">Approve</button>
|
||||||
|
<button onclick="requestChanges({{ tool.id }})" class="text-yellow-600 hover:text-yellow-900 mr-2">Changes</button>
|
||||||
<button onclick="rejectTool({{ tool.id }})" class="text-red-600 hover:text-red-900">Reject</button>
|
<button onclick="rejectTool({{ tool.id }})" class="text-red-600 hover:text-red-900">Reject</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -193,11 +194,33 @@
|
||||||
<div class="px-6 py-4 flex justify-end space-x-3 border-t bg-gray-50 rounded-b-lg flex-shrink-0">
|
<div class="px-6 py-4 flex justify-end space-x-3 border-t bg-gray-50 rounded-b-lg flex-shrink-0">
|
||||||
<button onclick="closeDetailModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Close</button>
|
<button onclick="closeDetailModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Close</button>
|
||||||
<button id="detail-approve-btn" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700">Approve</button>
|
<button id="detail-approve-btn" class="px-4 py-2 bg-green-600 text-white text-sm font-medium rounded-md hover:bg-green-700">Approve</button>
|
||||||
|
<button id="detail-changes-btn" class="px-4 py-2 bg-yellow-500 text-white text-sm font-medium rounded-md hover:bg-yellow-600">Request Changes</button>
|
||||||
<button id="detail-reject-btn" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Reject</button>
|
<button id="detail-reject-btn" class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700">Reject</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Changes Modal -->
|
||||||
|
<div id="changes-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
||||||
|
<div id="changes-modal-content" class="relative top-20 mx-auto border-2 border-gray-300 w-[32rem] shadow-2xl rounded-lg bg-white">
|
||||||
|
<div id="changes-modal-header" class="cursor-move select-none bg-gray-50 px-5 py-4 rounded-t-lg border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Request Changes</h3>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">This feedback will be visible to the publisher</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-5">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Feedback for publisher:</label>
|
||||||
|
<textarea id="changes-feedback" rows="5" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-yellow-500" placeholder="Describe what changes are needed..."></textarea>
|
||||||
|
<div class="mt-2 text-xs text-gray-500">
|
||||||
|
Tip: Include specific issues and suggestions for improvement
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end space-x-3">
|
||||||
|
<button onclick="closeChangesModal()" class="px-4 py-2 text-sm font-medium text-gray-700 hover:text-gray-500">Cancel</button>
|
||||||
|
<button onclick="confirmRequestChanges()" class="px-4 py-2 bg-yellow-500 text-white text-sm font-medium rounded-md hover:bg-yellow-600">Send Feedback</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Reject Modal -->
|
<!-- Reject Modal -->
|
||||||
<div id="reject-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
<div id="reject-modal" class="fixed inset-0 bg-gray-900 bg-opacity-60 hidden overflow-y-auto h-full w-full z-50">
|
||||||
<div id="reject-modal-content" class="relative top-20 mx-auto border-2 border-gray-300 w-96 shadow-2xl rounded-lg bg-white">
|
<div id="reject-modal-content" class="relative top-20 mx-auto border-2 border-gray-300 w-96 shadow-2xl rounded-lg bg-white">
|
||||||
|
|
@ -386,6 +409,10 @@ async function viewTool(toolId) {
|
||||||
closeDetailModal();
|
closeDetailModal();
|
||||||
rejectTool(toolId);
|
rejectTool(toolId);
|
||||||
};
|
};
|
||||||
|
document.getElementById('detail-changes-btn').onclick = () => {
|
||||||
|
closeDetailModal();
|
||||||
|
requestChanges(toolId);
|
||||||
|
};
|
||||||
|
|
||||||
document.getElementById('detail-loading').classList.add('hidden');
|
document.getElementById('detail-loading').classList.add('hidden');
|
||||||
document.getElementById('detail-content').classList.remove('hidden');
|
document.getElementById('detail-content').classList.remove('hidden');
|
||||||
|
|
@ -488,6 +515,55 @@ async function confirmReject() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestChanges(toolId) {
|
||||||
|
currentToolId = toolId;
|
||||||
|
document.getElementById('changes-modal').classList.remove('hidden');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.getElementById('changes-feedback').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeChangesModal() {
|
||||||
|
document.getElementById('changes-modal').classList.add('hidden');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
const modal = document.getElementById('changes-modal-content');
|
||||||
|
modal.style.position = '';
|
||||||
|
modal.style.left = '';
|
||||||
|
modal.style.top = '';
|
||||||
|
modal.style.margin = '';
|
||||||
|
modal.style.transform = '';
|
||||||
|
currentToolId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRequestChanges() {
|
||||||
|
const feedback = document.getElementById('changes-feedback').value.trim();
|
||||||
|
if (!feedback) {
|
||||||
|
alert('Feedback is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/tools/${currentToolId}/request-changes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer {{ session.get("auth_token") }}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ feedback })
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
closeChangesModal();
|
||||||
|
const row = document.getElementById(`tool-row-${currentToolId}`);
|
||||||
|
if (row) row.remove();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(data.error?.message || 'Failed to request changes');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Request changes error:', error);
|
||||||
|
alert('Error: ' + (error.message || 'Unknown error occurred'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draggable modal functionality
|
// Draggable modal functionality
|
||||||
function makeDraggable(modalContentId, headerHandleId) {
|
function makeDraggable(modalContentId, headerHandleId) {
|
||||||
const modal = document.getElementById(modalContentId);
|
const modal = document.getElementById(modalContentId);
|
||||||
|
|
@ -539,6 +615,7 @@ function makeDraggable(modalContentId, headerHandleId) {
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
makeDraggable('detail-modal-content', 'detail-modal-header');
|
makeDraggable('detail-modal-content', 'detail-modal-header');
|
||||||
makeDraggable('reject-modal-content', 'reject-modal-header');
|
makeDraggable('reject-modal-content', 'reject-modal-header');
|
||||||
|
makeDraggable('changes-modal-content', 'changes-modal-header');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue