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:
+72
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -188,6 +188,24 @@ class Withdrawal(Base):
|
||||
confirmed_at: Mapped[datetime | None] = mapped_column(default=None)
|
||||
|
||||
|
||||
class BugReport(Base):
|
||||
__tablename__ = "bug_reports"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
description: Mapped[str] = mapped_column(Text)
|
||||
contact: Mapped[str | None] = mapped_column(String(256), default=None)
|
||||
# Set when the reporter was logged in at submission time; the report page is
|
||||
# reachable both logged-in and logged-out (like GET /rounds/current), so this
|
||||
# stays nullable rather than requiring auth just to file a report.
|
||||
user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), default=None)
|
||||
# open -> read -> resolved, admin-driven (app/api/routes/admin.py). "read" is a
|
||||
# distinct step from "resolved" so a reporter checking their own status (only
|
||||
# possible when logged in — see GET /bug-reports/mine) can tell "an admin has
|
||||
# seen this" apart from "this has actually been fixed".
|
||||
status: Mapped[str] = mapped_column(String(16), default="open")
|
||||
created_at: Mapped[datetime] = mapped_column(default=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import app.rounds.confirmation # noqa: F401 (registers the "payout" confirmati
|
||||
import app.withdrawals.confirmation # noqa: F401 (registers the "withdrawal" confirmation handler)
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.bets import router as bets_router
|
||||
from app.api.routes.bug_reports import router as bug_reports_router
|
||||
from app.api.routes.qr import router as qr_router
|
||||
from app.api.routes.rounds import router as rounds_router
|
||||
from app.api.routes.users import router as users_router
|
||||
@@ -99,6 +100,7 @@ app.include_router(users_router)
|
||||
app.include_router(bets_router)
|
||||
app.include_router(withdrawals_router)
|
||||
app.include_router(admin_router)
|
||||
app.include_router(bug_reports_router)
|
||||
app.include_router(qr_router)
|
||||
app.include_router(rounds_router)
|
||||
|
||||
|
||||
@@ -133,6 +133,7 @@ table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
|
||||
th, td { text-align: left; padding: 8px 6px; border-bottom: 1px solid var(--color-border); vertical-align: top; }
|
||||
th { color: var(--color-muted-foreground); font-weight: 500; }
|
||||
td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; max-width: 200px; }
|
||||
td.payload-cell { max-width: 360px; white-space: pre-wrap; word-break: break-word; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
|
||||
.badge {
|
||||
@@ -144,6 +145,13 @@ td.addr, td.txid { font-family: 'Fira Code', monospace; word-break: break-all; m
|
||||
background: #FEF3C7; color: #92400E; border-color: #F59E0B;
|
||||
}
|
||||
|
||||
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
|
||||
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
|
||||
.bug-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||
.bug-actions button { width: auto; margin-top: 0; min-height: 30px; padding: 4px 10px; font-size: 0.78rem; }
|
||||
|
||||
button.reveal {
|
||||
width: auto; margin-top: 0; padding: 4px 10px; min-height: 30px; font-size: 0.78rem;
|
||||
background: var(--color-destructive-bg); color: var(--color-destructive); border: 1px solid var(--color-destructive);
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<span class="nav-tab" id="nav-round" onclick="switchView('round')">Round</span>
|
||||
<span class="nav-tab" id="nav-pending" onclick="switchView('pending')">Transazioni pendenti</span>
|
||||
<span class="nav-tab" id="nav-audit" onclick="switchView('audit')">Audit log</span>
|
||||
<span class="nav-tab" id="nav-bugreports" onclick="switchView('bugreports')">Segnalazioni bug</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="chain-status-pill">
|
||||
<span class="status-dot" id="chain-status-dot"></span>
|
||||
@@ -153,6 +154,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="view" id="view-bugreports">
|
||||
<h2 class="section-title">Segnalazioni bug</h2>
|
||||
<p class="hint">Segnalazioni inviate dagli utenti tramite la pagina "Segnala un bug".</p>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Descrizione</th><th>Contatto</th><th>Utente</th><th>Quando</th><th>Stato</th></tr>
|
||||
</thead>
|
||||
<tbody id="bugreports-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
+46
-3
@@ -89,8 +89,8 @@ function stopChainStatusPolling() {
|
||||
chainStatusInterval = null;
|
||||
}
|
||||
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog };
|
||||
const VIEWS = ['parametri', 'utenti', 'round', 'pending', 'audit', 'bugreports'];
|
||||
const VIEW_LOADERS = { utenti: loadUsers, round: loadRounds, pending: loadPending, audit: loadAuditLog, bugreports: loadBugReports };
|
||||
let currentAdminView = 'parametri';
|
||||
|
||||
function switchView(name) {
|
||||
@@ -109,7 +109,7 @@ function showDashboard() {
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog()]);
|
||||
await Promise.all([adminLoadConfig(), loadUsers(), loadRounds(), loadPending(), loadAuditLog(), loadBugReports()]);
|
||||
}
|
||||
|
||||
async function adminLogin() {
|
||||
@@ -346,6 +346,49 @@ async function loadAuditLog() {
|
||||
}
|
||||
}
|
||||
|
||||
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Letta', resolved: 'Risolta' };
|
||||
|
||||
function bugReportBadge(status) {
|
||||
return `<span class="badge bug-status-${escapeHtml(status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[status] || status)}</span>`;
|
||||
}
|
||||
|
||||
async function loadBugReports() {
|
||||
try {
|
||||
const reports = await callAdmin('GET', '/admin/bug-reports');
|
||||
const tbody = document.getElementById('bugreports-tbody');
|
||||
tbody.innerHTML = reports.map((r) => `
|
||||
<tr>
|
||||
<td>${r.id}</td>
|
||||
<td class="payload-cell">${escapeHtml(r.description)}</td>
|
||||
<td>${r.contact ? escapeHtml(r.contact) : '—'}</td>
|
||||
<td>${r.username ? escapeHtml(r.username) : '—'}</td>
|
||||
<td>${fmtDate(r.created_at)}</td>
|
||||
<td>
|
||||
${bugReportBadge(r.status)}
|
||||
<div class="bug-actions">
|
||||
${r.status === 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'read', this)">Segna come letta</button>` : ''}
|
||||
${r.status !== 'resolved' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'resolved', this)">Segna come risolta</button>` : ''}
|
||||
${r.status !== 'open' ? `<button class="secondary" onclick="setBugReportStatus(${r.id}, 'open', this)">Riapri</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" class="hint">Nessuna segnalazione ricevuta.</td></tr>';
|
||||
} catch (e) {
|
||||
toast('Errore nel caricamento segnalazioni: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function setBugReportStatus(reportId, newStatus, button) {
|
||||
await withLoading(button, '…', async () => {
|
||||
try {
|
||||
await callAdmin('POST', '/admin/bug-reports/' + reportId + '/status', { status: newStatus });
|
||||
await loadBugReports();
|
||||
} catch (e) {
|
||||
toast('Errore: ' + e.message, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('admin-token').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') adminLogin();
|
||||
});
|
||||
|
||||
+103
-1
@@ -9,8 +9,110 @@
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<h1>Segnala un bug</h1>
|
||||
<p>Questa pagina è un placeholder. Il modulo per la segnalazione dei bug sarà disponibile qui a breve.</p>
|
||||
<p>Hai trovato un problema? Descrivilo qui sotto: la segnalazione arriva direttamente al pannello di amministrazione.</p>
|
||||
|
||||
<div class="card" id="report-form">
|
||||
<label for="bug-description">Cosa è successo?</label>
|
||||
<textarea id="bug-description" rows="6" maxlength="5000" placeholder="Descrivi il bug: cosa stavi facendo, cosa ti aspettavi e cosa è successo invece."></textarea>
|
||||
|
||||
<label for="bug-contact">Contatto (opzionale)</label>
|
||||
<input id="bug-contact" type="text" maxlength="256" placeholder="Email o altro recapito, se vuoi essere ricontattato">
|
||||
|
||||
<button onclick="submitBugReport()" id="bug-submit-btn">Invia segnalazione</button>
|
||||
</div>
|
||||
|
||||
<div class="card hidden" id="my-reports-card">
|
||||
<h2>Le tue segnalazioni</h2>
|
||||
<p class="hint">Solo le segnalazioni inviate da questo account, con lo stato aggiornato dall'amministrazione.</p>
|
||||
<div id="my-reports-list"></div>
|
||||
</div>
|
||||
|
||||
<p><a class="link" href="/">← Torna alla home</a></p>
|
||||
</div>
|
||||
|
||||
<div id="toast-container" aria-live="polite"></div>
|
||||
|
||||
<script>
|
||||
function toast(message, type) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast ' + type;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => el.remove(), 4000);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
const BUG_REPORT_STATUS_LABELS = { open: 'Da leggere', read: 'Letta', resolved: 'Risolta' };
|
||||
|
||||
async function submitBugReport() {
|
||||
const btn = document.getElementById('bug-submit-btn');
|
||||
const description = document.getElementById('bug-description').value.trim();
|
||||
const contact = document.getElementById('bug-contact').value.trim();
|
||||
if (!description) {
|
||||
toast('Descrivi il bug prima di inviare.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const token = localStorage.getItem('plm_token');
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
btn.disabled = true;
|
||||
const original = btn.textContent;
|
||||
btn.textContent = 'Invio…';
|
||||
try {
|
||||
const res = await fetch('/bug-reports', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ description, contact: contact || null }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail?.message || data.detail || res.statusText);
|
||||
document.getElementById('bug-description').value = '';
|
||||
document.getElementById('bug-contact').value = '';
|
||||
toast('Grazie! Segnalazione inviata.', 'success');
|
||||
loadMyBugReports();
|
||||
} catch (e) {
|
||||
toast('Errore nell\'invio: ' + e.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = original;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyBugReports() {
|
||||
const token = localStorage.getItem('plm_token');
|
||||
const card = document.getElementById('my-reports-card');
|
||||
if (!token) {
|
||||
card.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/bug-reports/mine', { headers: { Authorization: 'Bearer ' + token } });
|
||||
if (!res.ok) {
|
||||
card.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
const reports = await res.json();
|
||||
card.classList.remove('hidden');
|
||||
const list = document.getElementById('my-reports-list');
|
||||
list.innerHTML = reports.map((r) => `
|
||||
<div class="my-report-row">
|
||||
<span class="badge bug-status-${escapeHtml(r.status)}">${escapeHtml(BUG_REPORT_STATUS_LABELS[r.status] || r.status)}</span>
|
||||
<span class="my-report-desc">${escapeHtml(r.description)}</span>
|
||||
<span class="my-report-date">${new Date(r.created_at).toLocaleString('it-IT')}</span>
|
||||
</div>
|
||||
`).join('') || '<p class="hint">Non hai ancora inviato segnalazioni.</p>';
|
||||
} catch (e) {
|
||||
card.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
loadMyBugReports();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+20
-3
@@ -74,16 +74,17 @@ h1, h2, h3 { font-family: inherit; letter-spacing: -0.01em; }
|
||||
label { display: block; font-size: 0.85rem; font-weight: 500; color: var(--color-muted-foreground); margin-top: 14px; margin-bottom: 6px; }
|
||||
label:first-child { margin-top: 0; }
|
||||
|
||||
input {
|
||||
input, textarea {
|
||||
width: 100%; min-height: 44px; padding: 10px 12px; font-size: 0.95rem; font-family: inherit;
|
||||
border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-surface);
|
||||
color: var(--color-foreground); transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
input:focus {
|
||||
textarea { resize: vertical; }
|
||||
input:focus, textarea:focus {
|
||||
outline: none; border-color: var(--color-ring);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-ring) 25%, transparent);
|
||||
}
|
||||
input:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||
input:disabled, textarea:disabled { background: var(--color-surface-inset); color: var(--color-muted-foreground); }
|
||||
|
||||
button {
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
@@ -253,6 +254,22 @@ button.link:hover, a.link:hover { filter: none; color: var(--color-foreground);
|
||||
.toast.error { background: var(--color-destructive-bg); color: var(--color-destructive); }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.badge {
|
||||
display: inline-block; font-size: 0.72rem; font-weight: 600; padding: 2px 8px;
|
||||
border-radius: 999px; background: var(--color-background); border: 1px solid var(--color-border);
|
||||
}
|
||||
.badge.bug-status-open { background: #FEF3C7; color: #92400E; border-color: #F59E0B; }
|
||||
.badge.bug-status-read { background: var(--color-background); color: var(--color-muted-foreground); }
|
||||
.badge.bug-status-resolved { background: var(--color-success-bg); color: var(--color-success); border-color: var(--color-success); }
|
||||
|
||||
.my-report-row {
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
|
||||
padding: 10px 0; border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.my-report-row:last-child { border-bottom: none; }
|
||||
.my-report-desc { flex: 1 1 200px; font-size: 0.9rem; }
|
||||
.my-report-date { font-size: 0.8rem; color: var(--color-muted-foreground); white-space: nowrap; }
|
||||
|
||||
/* --- landing hero (shown only when logged out) --- */
|
||||
body {
|
||||
position: relative;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""replace bug_reports.resolved with a three-state status
|
||||
|
||||
Revision ID: be71fdac734e
|
||||
Revises: ee8508d98d34
|
||||
Create Date: 2026-07-31 15:33:51.780062
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'be71fdac734e'
|
||||
down_revision: Union[str, Sequence[str], None] = 'ee8508d98d34'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema, preserving existing rows: resolved=True -> 'resolved', else 'open'.
|
||||
|
||||
'read' has no equivalent in the old boolean, so nothing backfills into it —
|
||||
every previously-open report starts the new lifecycle at 'open', which is
|
||||
correct (nobody had acknowledged it yet)."""
|
||||
op.add_column('bug_reports', sa.Column('status', sa.String(length=16), nullable=True))
|
||||
op.execute("UPDATE bug_reports SET status = CASE WHEN resolved THEN 'resolved' ELSE 'open' END")
|
||||
with op.batch_alter_table('bug_reports') as batch_op:
|
||||
batch_op.alter_column('status', nullable=False)
|
||||
batch_op.drop_column('resolved')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema. 'read' collapses back into resolved=False — the same loss
|
||||
of information any boolean-from-enum downgrade has."""
|
||||
op.add_column('bug_reports', sa.Column('resolved', sa.BOOLEAN(), nullable=True))
|
||||
op.execute("UPDATE bug_reports SET resolved = (status = 'resolved')")
|
||||
with op.batch_alter_table('bug_reports') as batch_op:
|
||||
batch_op.alter_column('resolved', nullable=False)
|
||||
batch_op.drop_column('status')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add bug_reports table
|
||||
|
||||
Revision ID: ee8508d98d34
|
||||
Revises: 87a0c640355c
|
||||
Create Date: 2026-07-31 15:14:14.288552
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ee8508d98d34'
|
||||
down_revision: Union[str, Sequence[str], None] = '87a0c640355c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('bug_reports',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=False),
|
||||
sa.Column('contact', sa.String(length=256), nullable=True),
|
||||
sa.Column('user_id', sa.Integer(), nullable=True),
|
||||
sa.Column('resolved', sa.Boolean(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('bug_reports')
|
||||
# ### end Alembic commands ###
|
||||
@@ -0,0 +1,181 @@
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(monkeypatch, tmp_path):
|
||||
monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db")
|
||||
monkeypatch.setattr(settings, "admin_token", "test-admin-token")
|
||||
monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret")
|
||||
monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode())
|
||||
monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc"))
|
||||
|
||||
import app.wallet.hd as hd
|
||||
|
||||
hd._account_key = None
|
||||
hd.generate_master_key()
|
||||
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from app.db import base as db_base
|
||||
|
||||
import app.db.models # noqa: F401
|
||||
|
||||
db_base.engine = create_async_engine(settings.database_url)
|
||||
db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False)
|
||||
|
||||
from app.db import session as db_session
|
||||
|
||||
db_session.AsyncSessionLocal = db_base.AsyncSessionLocal
|
||||
|
||||
async with db_base.engine.begin() as conn:
|
||||
await conn.run_sync(db_base.Base.metadata.create_all)
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.routes.admin import router as admin_router
|
||||
from app.api.routes.bug_reports import router as bug_reports_router
|
||||
from app.auth.routes import router as auth_router
|
||||
from app.electrum.listener import ElectrumListener
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(auth_router)
|
||||
app.include_router(bug_reports_router)
|
||||
app.include_router(admin_router)
|
||||
app.state.electrum_listener = ElectrumListener(lambda endpoint: None, db_base.AsyncSessionLocal)
|
||||
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
await db_base.engine.dispose()
|
||||
|
||||
|
||||
_ADMIN_HEADERS = {"X-Admin-Token": "test-admin-token"}
|
||||
|
||||
|
||||
async def _register(client, username="alice", password="original-password"):
|
||||
resp = await client.post("/auth/register", json={"username": username, "password": password})
|
||||
assert resp.status_code == 201
|
||||
return resp.json()["access_token"]
|
||||
|
||||
|
||||
async def test_anonymous_bug_report_has_no_user(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "the bet button does nothing"})
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
reports = resp.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["description"] == "the bet button does nothing"
|
||||
assert reports[0]["user_id"] is None
|
||||
assert reports[0]["username"] is None
|
||||
assert reports[0]["status"] == "open"
|
||||
|
||||
|
||||
async def test_logged_in_bug_report_is_attributed_to_the_user(client):
|
||||
token = await _register(client)
|
||||
|
||||
resp = await client.post(
|
||||
"/bug-reports",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"description": "withdrawal amount looks wrong", "contact": "alice@example.com"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
resp = await client.get("/admin/bug-reports", headers=_ADMIN_HEADERS)
|
||||
reports = resp.json()
|
||||
assert reports[0]["username"] == "alice"
|
||||
assert reports[0]["contact"] == "alice@example.com"
|
||||
|
||||
|
||||
async def test_user_can_see_own_report_status(client):
|
||||
token = await _register(client)
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
resp = await client.post("/bug-reports", headers=headers, json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
reports = resp.json()
|
||||
assert len(reports) == 1
|
||||
assert reports[0]["id"] == report_id
|
||||
assert reports[0]["status"] == "open"
|
||||
|
||||
await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
resp = await client.get("/bug-reports/mine", headers=headers)
|
||||
assert resp.json()[0]["status"] == "read"
|
||||
|
||||
|
||||
async def test_bug_reports_mine_requires_auth(client):
|
||||
resp = await client.get("/bug-reports/mine")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_bug_reports_mine_only_returns_own_reports(client):
|
||||
alice_token = await _register(client, username="alice")
|
||||
bob_token = await _register(client, username="bob", password="bob-password")
|
||||
|
||||
await client.post(
|
||||
"/bug-reports", headers={"Authorization": f"Bearer {alice_token}"}, json={"description": "alice's bug"}
|
||||
)
|
||||
|
||||
resp = await client.get("/bug-reports/mine", headers={"Authorization": f"Bearer {bob_token}"})
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
async def test_blank_description_is_rejected(client):
|
||||
resp = await client.post("/bug-reports", json={"description": " "})
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_admin_bug_reports_requires_token(client):
|
||||
resp = await client.get("/admin/bug-reports")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
async def test_admin_can_move_through_open_read_resolved(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "read"
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "resolved"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "resolved"
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "open"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "open"
|
||||
|
||||
|
||||
async def test_update_status_rejects_unknown_value(client):
|
||||
resp = await client.post("/bug-reports", json={"description": "some bug"})
|
||||
report_id = resp.json()["id"]
|
||||
|
||||
resp = await client.post(
|
||||
f"/admin/bug-reports/{report_id}/status", headers=_ADMIN_HEADERS, json={"status": "bogus"}
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
async def test_update_status_unknown_report_is_404(client):
|
||||
resp = await client.post(
|
||||
"/admin/bug-reports/999/status", headers=_ADMIN_HEADERS, json={"status": "read"}
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user