Pinscope open-source core
Agentic schematic validation: datasheet extraction via Claude Console Skills, netlist/BOM design graph, per-IC direct datasheet review with page citations, capacitor derating, Next.js report UI. Extracted from the Pinscope cloud codebase. Auth and billing live in the private gateway repo behind stable seams (billing_hook.py, adapter files listed in CLAUDE.md).
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
"""User feedback / ticket system.
|
||||
|
||||
Users can submit feedback tickets (bugs, rule reports, feature requests).
|
||||
Tickets are stored as individual JSON files with JSONL indexes for fast listing.
|
||||
|
||||
Storage layout:
|
||||
admin/feedback/tickets/{ticket_id}.json
|
||||
admin/feedback/index/by_user/{user_id}.jsonl
|
||||
admin/feedback/index/by_project/{project_id}.jsonl
|
||||
admin/feedback/index/all.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.routers.deps import get_storage, get_user_id
|
||||
from backend.services.storage import StorageBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage key helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TICKETS_PREFIX = "admin/feedback/tickets/"
|
||||
_INDEX_BY_USER = "admin/feedback/index/by_user/"
|
||||
_INDEX_BY_PROJECT = "admin/feedback/index/by_project/"
|
||||
_INDEX_ALL = "admin/feedback/index/all.jsonl"
|
||||
|
||||
|
||||
def _ticket_key(ticket_id: str) -> str:
|
||||
return f"{_TICKETS_PREFIX}{ticket_id}.json"
|
||||
|
||||
|
||||
def _user_index_key(user_id: str) -> str:
|
||||
return f"{_INDEX_BY_USER}{user_id}.jsonl"
|
||||
|
||||
|
||||
def _project_index_key(project_id: str) -> str:
|
||||
return f"{_INDEX_BY_PROJECT}{project_id}.jsonl"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FeedbackType = Literal["bug", "rule_feedback", "feature_request"]
|
||||
FeedbackStatus = Literal["open", "acknowledged", "resolved"]
|
||||
|
||||
|
||||
class FeedbackTicket(BaseModel):
|
||||
ticket_id: str
|
||||
user_id: str
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
project_id: str | None = None
|
||||
project_name: str | None = None
|
||||
type: FeedbackType
|
||||
status: FeedbackStatus = "open"
|
||||
finding_id: str | None = None
|
||||
finding_text: str | None = None
|
||||
finding_designator: str | None = None
|
||||
finding_mpn: str | None = None
|
||||
finding_status: str | None = None
|
||||
message: str
|
||||
admin_notes: str | None = None
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class CreateFeedbackRequest(BaseModel):
|
||||
type: FeedbackType
|
||||
message: str = Field(..., min_length=1, max_length=5000)
|
||||
project_id: str | None = None
|
||||
project_name: str | None = None
|
||||
user_name: str | None = None
|
||||
user_email: str | None = None
|
||||
finding_id: str | None = None
|
||||
finding_text: str | None = None
|
||||
finding_designator: str | None = None
|
||||
finding_mpn: str | None = None
|
||||
finding_status: str | None = None
|
||||
|
||||
|
||||
class UpdateFeedbackRequest(BaseModel):
|
||||
status: FeedbackStatus | None = None
|
||||
admin_notes: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _append_index(storage: StorageBackend, key: str, entry: dict) -> None:
|
||||
existing = ""
|
||||
if storage.exists(key):
|
||||
existing = storage.read_text(key)
|
||||
line = json.dumps(entry) + "\n"
|
||||
storage.write_text(key, existing + line)
|
||||
|
||||
|
||||
def _read_index(storage: StorageBackend, key: str) -> list[dict]:
|
||||
if not storage.exists(key):
|
||||
return []
|
||||
text = storage.read_text(key)
|
||||
entries: list[dict] = []
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
return entries
|
||||
|
||||
|
||||
def _read_ticket(storage: StorageBackend, ticket_id: str) -> FeedbackTicket | None:
|
||||
key = _ticket_key(ticket_id)
|
||||
if not storage.exists(key):
|
||||
return None
|
||||
try:
|
||||
data = storage.read_json(key)
|
||||
return FeedbackTicket(**data)
|
||||
except Exception:
|
||||
logger.warning("Failed to read ticket %s", ticket_id)
|
||||
return None
|
||||
|
||||
|
||||
def _read_tickets_from_index(
|
||||
storage: StorageBackend,
|
||||
index_key: str,
|
||||
*,
|
||||
status: str | None = None,
|
||||
ticket_type: str | None = None,
|
||||
project_id: str | None = None,
|
||||
) -> list[FeedbackTicket]:
|
||||
index_entries = _read_index(storage, index_key)
|
||||
tickets: list[FeedbackTicket] = []
|
||||
for entry in reversed(index_entries):
|
||||
tid = entry.get("ticket_id")
|
||||
if not tid:
|
||||
continue
|
||||
ticket = _read_ticket(storage, tid)
|
||||
if not ticket:
|
||||
continue
|
||||
if status and ticket.status != status:
|
||||
continue
|
||||
if ticket_type and ticket.type != ticket_type:
|
||||
continue
|
||||
if project_id and ticket.project_id != project_id:
|
||||
continue
|
||||
tickets.append(ticket)
|
||||
return tickets
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post("/feedback", response_model=FeedbackTicket)
|
||||
async def create_feedback(body: CreateFeedbackRequest, request: Request):
|
||||
storage = get_storage(request)
|
||||
user_id = get_user_id(request)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
ticket_id = uuid.uuid4().hex[:12]
|
||||
|
||||
ticket = FeedbackTicket(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
user_name=body.user_name,
|
||||
user_email=body.user_email,
|
||||
project_id=body.project_id,
|
||||
project_name=body.project_name,
|
||||
type=body.type,
|
||||
status="open",
|
||||
finding_id=body.finding_id,
|
||||
finding_text=body.finding_text,
|
||||
finding_designator=body.finding_designator,
|
||||
finding_mpn=body.finding_mpn,
|
||||
finding_status=body.finding_status,
|
||||
message=body.message,
|
||||
admin_notes=None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
storage.write_json(_ticket_key(ticket_id), ticket.model_dump())
|
||||
|
||||
index_entry = {"ticket_id": ticket_id, "created_at": now}
|
||||
_append_index(storage, _user_index_key(user_id), index_entry)
|
||||
_append_index(storage, _INDEX_ALL, index_entry)
|
||||
if body.project_id:
|
||||
_append_index(storage, _project_index_key(body.project_id), index_entry)
|
||||
|
||||
# Notify the admin inbox (fire-and-forget, identical pattern to
|
||||
# pipeline-started). Errors are swallowed inside the email service.
|
||||
try:
|
||||
from backend.services.email import send_feedback_received_email
|
||||
await send_feedback_received_email(
|
||||
ticket_id=ticket_id,
|
||||
user_id=user_id,
|
||||
feedback_type=body.type,
|
||||
message=body.message,
|
||||
submitter_name=body.user_name,
|
||||
submitter_email=body.user_email,
|
||||
project_name=body.project_name,
|
||||
project_id=body.project_id,
|
||||
finding_designator=body.finding_designator,
|
||||
finding_mpn=body.finding_mpn,
|
||||
finding_status=body.finding_status,
|
||||
finding_text=body.finding_text,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to enqueue feedback-received email for %s", ticket_id)
|
||||
|
||||
return ticket
|
||||
|
||||
|
||||
@router.get("/feedback", response_model=list[FeedbackTicket])
|
||||
async def list_my_feedback(request: Request, status: str | None = None):
|
||||
storage = get_storage(request)
|
||||
user_id = get_user_id(request)
|
||||
return _read_tickets_from_index(
|
||||
storage, _user_index_key(user_id), status=status,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/admin/feedback", response_model=list[FeedbackTicket])
|
||||
async def list_all_feedback(
|
||||
request: Request,
|
||||
status: str | None = None,
|
||||
type: str | None = None,
|
||||
project_id: str | None = None,
|
||||
):
|
||||
from backend.routers.admin import _require_admin
|
||||
|
||||
await _require_admin(request)
|
||||
storage = get_storage(request)
|
||||
return _read_tickets_from_index(
|
||||
storage, _INDEX_ALL, status=status, ticket_type=type, project_id=project_id,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/feedback/{ticket_id}", response_model=FeedbackTicket)
|
||||
async def update_feedback(ticket_id: str, body: UpdateFeedbackRequest, request: Request):
|
||||
from backend.routers.admin import _require_admin
|
||||
|
||||
await _require_admin(request)
|
||||
storage = get_storage(request)
|
||||
|
||||
ticket = _read_ticket(storage, ticket_id)
|
||||
if not ticket:
|
||||
raise HTTPException(404, "Ticket not found")
|
||||
|
||||
prev_admin_notes = (ticket.admin_notes or "").strip()
|
||||
|
||||
if body.status is not None:
|
||||
ticket.status = body.status
|
||||
if body.admin_notes is not None:
|
||||
ticket.admin_notes = body.admin_notes
|
||||
ticket.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
storage.write_json(_ticket_key(ticket_id), ticket.model_dump())
|
||||
|
||||
# If admin_notes changed to a new, non-empty value, notify the submitter.
|
||||
new_admin_notes = (ticket.admin_notes or "").strip()
|
||||
if new_admin_notes and new_admin_notes != prev_admin_notes:
|
||||
try:
|
||||
from backend.services.email import send_feedback_reply_email
|
||||
await send_feedback_reply_email(
|
||||
user_id=ticket.user_id,
|
||||
reply_text=new_admin_notes,
|
||||
original_message=ticket.message,
|
||||
recipient_name=ticket.user_name,
|
||||
recipient_email=ticket.user_email,
|
||||
project_name=ticket.project_name,
|
||||
finding_designator=ticket.finding_designator,
|
||||
finding_mpn=ticket.finding_mpn,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to enqueue feedback-reply email for ticket %s", ticket_id
|
||||
)
|
||||
|
||||
return ticket
|
||||
Reference in New Issue
Block a user