Reports were anonymous and one-way. Tie each report to the submitting device
(X-Device-Id), add an admin response back-channel, and let the app fetch replies for
its own device:
- migration 0023: bug_reports gains device_id, response, responded_at, response_seen.
- create_bug_report captures device_id.
- GET /bug-reports/mine (device-scoped) returns this device's reports + unseen count.
- POST /bug-reports/mine/seen clears the unseen flag.
- POST /bug-reports/{id}/reply sets the admin response (authored during triage).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
243 lines
7.8 KiB
Python
243 lines
7.8 KiB
Python
"""Bug reports — mobile FAB → POST /bug-reports → admin lista przez admin_html.
|
|
|
|
POST nie wymaga obecnego scene_id (user może raportować z FavoritesScreen,
|
|
SearchScreen itp.). Screenshot opcjonalny — niektóre ekrany nie warto kapturować.
|
|
|
|
Limit body 1.5MB (FastAPI default jest hojny, ale dla rozsądku ograniczamy).
|
|
Screenshot to PNG/JPEG z react-native-view-shot, base64 — typowe rozmiary:
|
|
- mały ekran scene-list: ~200-400KB
|
|
- duży scene-detail z thumbnail: ~600KB-1MB
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import desc, func, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.device import get_device_id
|
|
from app.auth import require_api_key
|
|
from app.db import get_session
|
|
from app.models.bug_report import BugReport
|
|
from app.models.movie import Movie
|
|
from app.models.scene import Scene
|
|
|
|
router = APIRouter(tags=["bug-reports"], dependencies=[Depends(require_api_key)])
|
|
|
|
|
|
_MAX_SCREENSHOT_BYTES = 1_500_000 # raw base64 chars; ~1.1MB binary po dekodowaniu
|
|
|
|
|
|
class BugReportCreate(BaseModel):
|
|
message: str = Field(min_length=1, max_length=5000)
|
|
screen_name: str | None = Field(default=None, max_length=64)
|
|
app_version: str | None = Field(default=None, max_length=32)
|
|
scene_id: uuid.UUID | None = None
|
|
screenshot_b64: str | None = Field(default=None, max_length=_MAX_SCREENSHOT_BYTES)
|
|
|
|
|
|
class BugReportOut(BaseModel):
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
screen_name: str | None
|
|
app_version: str | None
|
|
scene_id: uuid.UUID | None
|
|
movie_id: uuid.UUID | None
|
|
message: str
|
|
has_screenshot: bool
|
|
resolved: bool
|
|
|
|
|
|
class BugReportListOut(BaseModel):
|
|
items: list[BugReportOut]
|
|
total: int
|
|
|
|
|
|
class BugReplyIn(BaseModel):
|
|
response: str = Field(min_length=1, max_length=5000)
|
|
|
|
|
|
class MyBugReportOut(BaseModel):
|
|
id: uuid.UUID
|
|
created_at: datetime
|
|
screen_name: str | None
|
|
message: str
|
|
response: str | None
|
|
responded_at: datetime | None
|
|
response_seen: bool
|
|
|
|
|
|
class MyBugReportListOut(BaseModel):
|
|
items: list[MyBugReportOut]
|
|
unseen: int
|
|
|
|
|
|
@router.post("/bug-reports", status_code=status.HTTP_201_CREATED)
|
|
def create_bug_report(
|
|
payload: BugReportCreate,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> dict[str, str]:
|
|
# Smart-route entity_id: mobile Player używa `sceneId` param zarówno dla
|
|
# scen jak i movies (legacy progress tracking hack). Bez tego INSERT FK
|
|
# violation crashował 500 (zgłoszone 2026-05-10). Sprawdź obie tabele.
|
|
scene_id: uuid.UUID | None = None
|
|
movie_id: uuid.UUID | None = None
|
|
if payload.scene_id is not None:
|
|
if session.get(Scene, payload.scene_id) is not None:
|
|
scene_id = payload.scene_id
|
|
elif session.get(Movie, payload.scene_id) is not None:
|
|
movie_id = payload.scene_id
|
|
# else: ID nie istnieje już nigdzie (deleted) — drop oba na null
|
|
|
|
br = BugReport(
|
|
id=uuid.uuid4(),
|
|
device_id=device_id,
|
|
message=payload.message.strip(),
|
|
screen_name=payload.screen_name,
|
|
app_version=payload.app_version,
|
|
scene_id=scene_id,
|
|
movie_id=movie_id,
|
|
screenshot_b64=payload.screenshot_b64,
|
|
)
|
|
session.add(br)
|
|
session.commit()
|
|
return {"id": str(br.id)}
|
|
|
|
|
|
@router.get("/bug-reports/mine", response_model=MyBugReportListOut)
|
|
def list_my_bug_reports(
|
|
session: Annotated[Session, Depends(get_session)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> MyBugReportListOut:
|
|
"""Zgłoszenia TEGO urządzenia (+ ewentualna odpowiedź admina). Apka pokazuje je
|
|
w 'Your messages' pod FAB; `unseen` = liczba nieprzeczytanych odpowiedzi → kropka."""
|
|
rows = session.scalars(
|
|
select(BugReport)
|
|
.where(BugReport.device_id == device_id)
|
|
.order_by(desc(BugReport.created_at))
|
|
.limit(50)
|
|
).all()
|
|
items = [
|
|
MyBugReportOut(
|
|
id=r.id,
|
|
created_at=r.created_at,
|
|
screen_name=r.screen_name,
|
|
message=r.message,
|
|
response=r.response,
|
|
responded_at=r.responded_at,
|
|
response_seen=r.response_seen,
|
|
)
|
|
for r in rows
|
|
]
|
|
unseen = sum(1 for r in rows if r.response is not None and not r.response_seen)
|
|
return MyBugReportListOut(items=items, unseen=unseen)
|
|
|
|
|
|
@router.post("/bug-reports/mine/seen")
|
|
def mark_my_replies_seen(
|
|
session: Annotated[Session, Depends(get_session)],
|
|
device_id: Annotated[str, Depends(get_device_id)],
|
|
) -> dict[str, int]:
|
|
"""Oznacz wszystkie odpowiedzi dla tego urządzenia jako przeczytane (gasi kropkę)."""
|
|
rows = session.scalars(
|
|
select(BugReport).where(
|
|
BugReport.device_id == device_id,
|
|
BugReport.response.is_not(None),
|
|
BugReport.response_seen.is_(False),
|
|
)
|
|
).all()
|
|
for r in rows:
|
|
r.response_seen = True
|
|
session.commit()
|
|
return {"marked": len(rows)}
|
|
|
|
|
|
@router.post("/bug-reports/{bug_id}/reply")
|
|
def reply_bug_report(
|
|
bug_id: uuid.UUID,
|
|
payload: BugReplyIn,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
) -> dict[str, str]:
|
|
"""Admin/triage: zapisz odpowiedź do usera. Pojawi się w jego 'Your messages'
|
|
(kropka na FAB). Resetuje response_seen, żeby user dostał notyfikację."""
|
|
br = session.get(BugReport, bug_id)
|
|
if br is None:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
br.response = payload.response.strip()
|
|
br.responded_at = datetime.now(timezone.utc)
|
|
br.response_seen = False
|
|
session.commit()
|
|
return {"status": "replied"}
|
|
|
|
|
|
@router.get("/bug-reports", response_model=BugReportListOut)
|
|
def list_bug_reports(
|
|
session: Annotated[Session, Depends(get_session)],
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
include_resolved: bool = False,
|
|
) -> BugReportListOut:
|
|
q = select(BugReport).order_by(desc(BugReport.created_at))
|
|
cnt_q = select(func.count(BugReport.id))
|
|
if not include_resolved:
|
|
q = q.where(BugReport.resolved.is_(False))
|
|
cnt_q = cnt_q.where(BugReport.resolved.is_(False))
|
|
rows = session.scalars(q.limit(limit).offset(offset)).all()
|
|
total = session.scalar(cnt_q) or 0
|
|
items = [
|
|
BugReportOut(
|
|
id=r.id,
|
|
created_at=r.created_at,
|
|
screen_name=r.screen_name,
|
|
app_version=r.app_version,
|
|
scene_id=r.scene_id,
|
|
movie_id=r.movie_id,
|
|
message=r.message,
|
|
has_screenshot=bool(r.screenshot_b64),
|
|
resolved=r.resolved,
|
|
)
|
|
for r in rows
|
|
]
|
|
return BugReportListOut(items=items, total=total)
|
|
|
|
|
|
@router.get("/bug-reports/{bug_id}/screenshot")
|
|
def get_bug_report_screenshot(
|
|
bug_id: uuid.UUID,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
) -> dict[str, str | None]:
|
|
"""Zwraca base64-encoded screenshot (jeśli jest) — admin UI go renderuje."""
|
|
br = session.get(BugReport, bug_id)
|
|
if br is None:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
return {"screenshot_b64": br.screenshot_b64}
|
|
|
|
|
|
@router.post("/bug-reports/{bug_id}/resolve")
|
|
def resolve_bug_report(
|
|
bug_id: uuid.UUID,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
) -> dict[str, str]:
|
|
br = session.get(BugReport, bug_id)
|
|
if br is None:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
br.resolved = True
|
|
session.commit()
|
|
return {"status": "resolved"}
|
|
|
|
|
|
@router.delete("/bug-reports/{bug_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_bug_report(
|
|
bug_id: uuid.UUID,
|
|
session: Annotated[Session, Depends(get_session)],
|
|
) -> None:
|
|
br = session.get(BugReport, bug_id)
|
|
if br is None:
|
|
raise HTTPException(status_code=404, detail="not found")
|
|
session.delete(br)
|
|
session.commit()
|