goon/app/api/bug_reports.py
goon-foss a4486980a4 style: myslniki em/en z komentarzy i docstringow (calosc repo)
1745 wystapien w 291 plikach (backend, scripts, mobile). Dominowal jeden ksztalt,
' — ' laczace zdania, wiec poszedl przecinek; myslnik otwierajacy mysl zdjety;
zakresy 0–5 i A–Z na dywiz.

Podzial komentarz/kod robil skaner stanow, nie regex, zeby nie ruszyc myslnika
stojacego w stringu albo w wyrazeniu regularnym. Potwierdzone maszynowo: po wycieciu
komentarzy z wersji HEAD i z dysku obie sa identyczne we wszystkich 291 plikach,
czyli poza komentarzami nie zmienilo sie nic. 233 pliki .py parsuja sie bez bledu,
tsc ma te same 11 bledow w tych samych 3 plikach co przed zmiana.

Dwie rzeczy wymagaly poprawki po automacie: wyrownane listy definicji, gdzie myslnik
byl separatorem kolumny (37 miejsc, kolumna przywrocona), i trzy przeniesienia mysli
do nowej linii, gdzie zostawal wiszacy przecinek na poczatku (poprawione recznie).

Bundla nie republikuje: Hermes i tak nie niesie komentarzy, wiec dla uzytkownika
to zero zmiany.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 11:51:57 +02:00

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()