Files
notes-qpid-pro/app/routers/feedback.py
T

241 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from app.auth import require_user
from app.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/feedback", tags=["Feedback & Bug Tracker"])
# Paths to markdown reports files for seamless Antigravity AI reading
DATA_REPORTS_FILE = Path(__file__).resolve().parent.parent.parent / "data" / "USER_REPORTS.md"
MEMORY_REPORTS_FILE = Path("/home/ai-projects/memory/USER_REPORTS.md")
class FeedbackCreateRequest(BaseModel):
category: str = Field(default="bug", description="Category: 'bug', 'feature', 'note'")
message: str = Field(..., min_length=1, max_length=5000)
context_url: Optional[str] = Field(default="", max_length=500)
context_data: Optional[Dict[str, Any]] = None
class FeedbackStatusUpdateRequest(BaseModel):
status: str = Field(..., pattern="^(new|in_progress|resolved|closed)$")
def sync_reports_to_markdown(conn):
"""
Synchronizes feedback_reports from SQLite to /home/ai-projects/memory/USER_REPORTS.md.
This enables Antigravity and AI agents to instantly inspect user-reported bugs and feature requests.
"""
try:
rows = conn.execute("""
SELECT f.id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
ORDER BY f.id DESC
""").fetchall()
lines = [
"# 📋 Пользовательские репорты & Запросы на доработку (study.qpid.pro)",
"",
"> 🔄 Данный файл автоматически синхронизируется с виджетом обратной связи на сайте.",
"> AI-ассистент (Antigravity) считывает этот файл для анализа найденных багов и планирования задач.",
"",
f"**Всего репортов:** {len(rows)} | **Последняя синхронизация:** сейчас",
"",
"## 🔴 Активные репорты (Требуют внимания / В работе)",
"",
]
active_rows = [r for r in rows if r["status"] in ("new", "in_progress")]
resolved_rows = [r for r in rows if r["status"] in ("resolved", "closed")]
if not active_rows:
lines.append("🎉 *Активных багов или задач нет! Все известные репорты закрыты.*")
lines.append("")
else:
for r in active_rows:
cat_emoji = "🐛 БАГ" if r["category"] == "bug" else ("💡 ФИЧА" if r["category"] == "feature" else "📝 ЗАМЕТКА")
status_icon = "🆕 Новый" if r["status"] == "new" else "⏳ В работе"
ctx_extra = ""
if r["context_data_json"]:
try:
c_dict = json.loads(r["context_data_json"])
parts = []
if "screen" in c_dict: parts.append(f"Экран: {c_dict['screen']}")
if "device" in c_dict: parts.append(f"Устройство: {c_dict['device']}")
if parts: ctx_extra = f" ({', '.join(parts)})"
except Exception:
pass
lines.append(f"### #{r['id']} [{cat_emoji}] — {status_icon} (`{r['created_at']}`)")
lines.append(f"- **Пользователь**: `{r['username']}`")
if r["context_url"]:
lines.append(f"- **Раздел/Страница**: `{r['context_url']}`{ctx_extra}")
lines.append(f"- **Сообщение**:\n> {r['message'].strip()}")
lines.append("")
lines.extend([
"---",
"",
"## ✅ Решенные / Закрытые задачи",
"",
])
if not resolved_rows:
lines.append("*Пока нет закрытых репортов.*")
lines.append("")
else:
for r in resolved_rows:
cat_emoji = "🐛" if r["category"] == "bug" else ("💡" if r["category"] == "feature" else "📝")
lines.append(f"- [x] **#{r['id']} {cat_emoji} ({r['created_at']})**: {r['message'].strip()} *(Страница: {r['context_url'] or 'общая'})*")
lines.append("")
content = "\n".join(lines)
for target in [DATA_REPORTS_FILE, MEMORY_REPORTS_FILE]:
try:
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
except Exception as e:
logger.debug(f"Could not write to {target}: {e}")
except Exception as e:
logger.warning(f"Could not sync reports to markdown: {e}")
@router.get("", response_model=List[Dict[str, Any]])
def list_feedback(
category: Optional[str] = Query(None),
status_filter: Optional[str] = Query(None, alias="status"),
user: dict = Depends(require_user),
):
"""Lists feedback reports. Admins see all reports; regular users see their own."""
is_admin = bool(user.get("is_admin"))
with get_db() as conn:
query = """
SELECT f.id, f.user_id, f.category, f.message, f.context_url, f.context_data_json, f.status, f.created_at, f.updated_at, u.username
FROM feedback_reports f
JOIN users u ON f.user_id = u.id
WHERE 1=1
"""
params = []
if not is_admin:
query += " AND f.user_id = ?"
params.append(user["id"])
if category:
query += " AND f.category = ?"
params.append(category)
if status_filter:
query += " AND f.status = ?"
params.append(status_filter)
query += " ORDER BY f.id DESC LIMIT 100"
rows = conn.execute(query, params).fetchall()
result = []
for r in rows:
ctx_data = {}
if r["context_data_json"]:
try:
ctx_data = json.loads(r["context_data_json"])
except Exception:
pass
result.append({
"id": r["id"],
"user_id": r["user_id"],
"username": r["username"],
"category": r["category"],
"message": r["message"],
"context_url": r["context_url"],
"context_data": ctx_data,
"status": r["status"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
})
return result
@router.post("", status_code=status.HTTP_201_CREATED)
def create_feedback(
payload: FeedbackCreateRequest,
user: dict = Depends(require_user),
):
"""Submits a new bug, feature request, or note from the website chat widget."""
allowed_categories = {"bug", "feature", "note"}
category = payload.category if payload.category in allowed_categories else "bug"
ctx_json = json.dumps(payload.context_data or {}, ensure_ascii=False)
with get_db() as conn:
cursor = conn.execute(
"""
INSERT INTO feedback_reports (user_id, category, message, context_url, context_data_json, status)
VALUES (?, ?, ?, ?, ?, 'new')
""",
(user["id"], category, payload.message.strip(), payload.context_url or "", ctx_json),
)
report_id = cursor.lastrowid
sync_reports_to_markdown(conn)
return {
"ok": True,
"id": report_id,
"category": category,
"status": "new",
"message": "Репорт успешно отправлен и передан в систему задач",
}
@router.patch("/{report_id}")
def update_feedback_status(
report_id: int,
payload: FeedbackStatusUpdateRequest,
user: dict = Depends(require_user),
):
"""Updates the status of a report (e.g. 'in_progress', 'resolved')."""
with get_db() as conn:
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Репорт не найден")
is_admin = bool(user.get("is_admin"))
if not is_admin and existing["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="Недостаточно прав")
conn.execute(
"UPDATE feedback_reports SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(payload.status, report_id),
)
sync_reports_to_markdown(conn)
return {"ok": True, "id": report_id, "status": payload.status}
@router.delete("/{report_id}")
def delete_feedback(
report_id: int,
user: dict = Depends(require_user),
):
"""Deletes a report."""
with get_db() as conn:
existing = conn.execute("SELECT id, user_id FROM feedback_reports WHERE id = ?", (report_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Репорт не найден")
is_admin = bool(user.get("is_admin"))
if not is_admin and existing["user_id"] != user["id"]:
raise HTTPException(status_code=403, detail="Недостаточно прав")
conn.execute("DELETE FROM feedback_reports WHERE id = ?", (report_id,))
sync_reports_to_markdown(conn)
return {"ok": True, "id": report_id}