Add user bug reporting with admin triage (open/read/resolved)

Turns the /report-bug placeholder into a real form (POST /bug-reports,
optionally attributed to the logged-in user) and adds a "Segnalazioni bug"
section to /admin to view and triage them. A logged-in reporter can also
check their own report's status via GET /bug-reports/mine, since anonymous
submissions have no user to show a history to.

Status is a three-state lifecycle (open -> read -> resolved) rather than a
plain boolean, so an admin can acknowledge a report distinctly from actually
fixing it. The schema went through two migrations because the first one
(add bug_reports table) had already been applied against the running
instance with a `resolved` boolean before the three-state design was
decided, so a follow-up migration backfills it into `status` instead of
rewriting already-applied history.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 15:50:40 +02:00
co-authored by Claude Sonnet 5
parent 977bb762c7
commit ee4e845c89
12 changed files with 628 additions and 8 deletions
+72 -1
View File
@@ -10,7 +10,7 @@ from app.api.timeutil import isoformat_utc
from app.audit.log import write_audit_log
from app.auth.security import hash_password
from app.config import settings
from app.db.models import AuditLog, PendingTransaction, Round, User
from app.db.models import AuditLog, BugReport, PendingTransaction, Round, User
from app.db.session import get_session
from app.rounds.config import get_round_config
from app.wallet.address import is_valid_plm_address
@@ -346,3 +346,74 @@ async def list_pending_transactions(
)
for p in entries
]
_BUG_REPORT_STATUSES = ("open", "read", "resolved")
class AdminBugReportResponse(BaseModel):
id: int
description: str
contact: str | None
user_id: int | None
username: str | None
status: str
created_at: str
def _bug_report_response(report: BugReport, username: str | None) -> AdminBugReportResponse:
return AdminBugReportResponse(
id=report.id,
description=report.description,
contact=report.contact,
user_id=report.user_id,
username=username,
status=report.status,
created_at=isoformat_utc(report.created_at),
)
@router.get(
"/bug-reports", response_model=list[AdminBugReportResponse], dependencies=[Depends(require_admin)]
)
async def list_bug_reports(
session: AsyncSession = Depends(get_session), limit: int = Query(default=200, ge=1, le=500)
) -> list[AdminBugReportResponse]:
reports = (await session.scalars(select(BugReport).order_by(BugReport.id.desc()).limit(limit))).all()
user_ids = {r.user_id for r in reports if r.user_id is not None}
usernames = {}
if user_ids:
users = (await session.scalars(select(User).where(User.id.in_(user_ids)))).all()
usernames = {u.id: u.username for u in users}
return [
_bug_report_response(r, usernames.get(r.user_id) if r.user_id is not None else None)
for r in reports
]
class BugReportStatusUpdate(BaseModel):
status: str = Field(pattern="^(open|read|resolved)$")
@router.post(
"/bug-reports/{report_id}/status",
response_model=AdminBugReportResponse,
dependencies=[Depends(require_admin)],
)
async def update_bug_report_status(
report_id: int, body: BugReportStatusUpdate, session: AsyncSession = Depends(get_session)
) -> AdminBugReportResponse:
report = await session.get(BugReport, report_id)
if report is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "bug report not found")
report.status = body.status
await session.commit()
username = None
if report.user_id is not None:
user = await session.get(User, report.user_id)
username = user.username if user is not None else None
return _bug_report_response(report, username)
+79
View File
@@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.timeutil import isoformat_utc
from app.auth.dependencies import get_current_user, get_optional_user
from app.db.models import BugReport, User
from app.db.session import get_session
router = APIRouter(prefix="/bug-reports", tags=["bug-reports"])
class BugReportCreate(BaseModel):
description: str = Field(min_length=1, max_length=5000)
contact: str | None = Field(default=None, max_length=256)
@field_validator("description")
@classmethod
def _description_not_blank(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("description must not be blank")
return value
@field_validator("contact")
@classmethod
def _contact_stripped(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
class BugReportResponse(BaseModel):
id: int
@router.post("", response_model=BugReportResponse, status_code=status.HTTP_201_CREATED)
async def create_bug_report(
body: BugReportCreate,
user: User | None = Depends(get_optional_user),
session: AsyncSession = Depends(get_session),
) -> BugReportResponse:
report = BugReport(
description=body.description,
contact=body.contact,
user_id=user.id if user is not None else None,
)
session.add(report)
await session.commit()
return BugReportResponse(id=report.id)
class MyBugReportResponse(BaseModel):
id: int
description: str
status: str
created_at: str
@router.get("/mine", response_model=list[MyBugReportResponse])
async def list_my_bug_reports(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> list[MyBugReportResponse]:
"""The one user-facing history view for bug reports (anonymous submissions have
no user to attribute this to, so this only ever covers ones filed while logged in)."""
reports = (
await session.scalars(
select(BugReport).where(BugReport.user_id == user.id).order_by(BugReport.id.desc())
)
).all()
return [
MyBugReportResponse(
id=r.id, description=r.description, status=r.status, created_at=isoformat_utc(r.created_at)
)
for r in reports
]