from fastapi import APIRouter, Depends, Request, status from pydantic import BaseModel, Field, field_validator from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.client_ip import client_ip from app.api.timeutil import isoformat_utc from app.auth.dependencies import get_current_user, get_optional_user from app.auth.routes import _rate_limited_error, _rate_limiters 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=2000) 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, request: Request, user: User | None = Depends(get_optional_user), session: AsyncSession = Depends(get_session), ) -> BugReportResponse: # B-94 (SEC-55): a quota per IP, checked before the insert and consumed only by a report # that was stored (validation failures never get here). quota = _rate_limiters(request).bug_reports ip_key = f"ip:{client_ip(request)}" retry_after = quota.retry_after(ip_key) if retry_after > 0: raise _rate_limited_error(retry_after) 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() quota.record(ip_key) 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 ]