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