from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc from typing import List, Optional from datetime import datetime from pydantic import BaseModel from app.db.session import get_db from app.models.models import Finding, FindingStatus, FindingSeverity from app.schemas.schemas import FindingOut, FindingCreate, FindingStatusUpdate from app.core.security import require_executive_or_above, require_it_or_above from app.services.ai_translator import translate_finding_async, answer_finding_question router = APIRouter(prefix="/findings", tags=["findings"]) @router.get("", response_model=List[FindingOut]) async def list_findings( tenant_id: str = Query(...), severity: Optional[str] = None, status: Optional[str] = None, category: Optional[str] = None, limit: int = 50, offset: int = 0, payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id: raise HTTPException(status_code=403, detail="Access denied") query = select(Finding).where(Finding.tenant_id == tenant_id) if severity: query = query.where(Finding.severity == severity) if status: query = query.where(Finding.status == status) if category: query = query.where(Finding.category == category) query = query.order_by(desc(Finding.created_at)).limit(limit).offset(offset) result = await db.execute(query) return result.scalars().all() @router.get("/{finding_id}", response_model=FindingOut) async def get_finding( finding_id: str, payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Finding).where(Finding.id == finding_id)) finding = result.scalar_one_or_none() if not finding: raise HTTPException(status_code=404, detail="Finding not found") if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id: raise HTTPException(status_code=403, detail="Access denied") return finding @router.post("", response_model=FindingOut, status_code=201) async def create_finding( tenant_id: str = Query(...), body: FindingCreate = ..., payload: dict = Depends(require_it_or_above), db: AsyncSession = Depends(get_db), ): if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != tenant_id: raise HTTPException(status_code=403, detail="Access denied") finding = Finding(tenant_id=tenant_id, **body.model_dump()) db.add(finding) await db.commit() await db.refresh(finding) return finding @router.patch("/{finding_id}/status", response_model=FindingOut) async def update_finding_status( finding_id: str, body: FindingStatusUpdate, payload: dict = Depends(require_it_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Finding).where(Finding.id == finding_id)) finding = result.scalar_one_or_none() if not finding: raise HTTPException(status_code=404, detail="Finding not found") if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id: raise HTTPException(status_code=403, detail="Access denied") finding.status = body.status if body.resolution_note: finding.resolution_note = body.resolution_note if body.assignee_email: finding.assignee_email = body.assignee_email if body.due_date: finding.due_date = body.due_date if body.status == FindingStatus.resolved: finding.resolved_at = datetime.utcnow() if body.status == FindingStatus.verified: finding.verified_at = datetime.utcnow() if not body.resolution_note: raise HTTPException(status_code=400, detail="A resolution note is required to verify a finding") finding.updated_at = datetime.utcnow() await db.commit() await db.refresh(finding) # Trigger async risk score recalculation (fire and forget) from app.services.risk_calculator import recalculate_risk_score import asyncio asyncio.create_task(recalculate_risk_score(finding.tenant_id)) return finding @router.patch("/{finding_id}/top-risk", response_model=FindingOut) async def toggle_top_risk( finding_id: str, is_top_risk: bool = Query(...), payload: dict = Depends(require_it_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Finding).where(Finding.id == finding_id)) finding = result.scalar_one_or_none() if not finding: raise HTTPException(status_code=404, detail="Not found") finding.is_top_risk = is_top_risk await db.commit() await db.refresh(finding) return finding @router.post("/{finding_id}/ai-translate") async def translate_finding( finding_id: str, payload: dict = Depends(require_it_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Finding).where(Finding.id == finding_id)) finding = result.scalar_one_or_none() if not finding: raise HTTPException(status_code=404, detail="Finding not found") if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id: raise HTTPException(status_code=403, detail="Access denied") import asyncio asyncio.create_task(translate_finding_async(finding_id)) return {"status": "Translation requested"} class AIQuestionRequest(BaseModel): question: str @router.post("/{finding_id}/ai-question") async def ask_ai_about_finding( finding_id: str, request: AIQuestionRequest, payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): result = await db.execute(select(Finding).where(Finding.id == finding_id)) finding = result.scalar_one_or_none() if not finding: raise HTTPException(status_code=404, detail="Finding not found") if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id: raise HTTPException(status_code=403, detail="Access denied") answer = await answer_finding_question(finding, request.question) return {"answer": answer} @router.get("/{finding_id}/ai-explain") async def explain_finding_via_findings_route( finding_id: str, payload: dict = Depends(require_executive_or_above), db: AsyncSession = Depends(get_db), ): """ Get cached AI translation/explanation for a finding. Returns the AI-generated summary, business impact, remediation steps, and priority. If AI translation is not yet generated, returns 202 Accepted with a request to trigger translation. """ result = await db.execute(select(Finding).where(Finding.id == finding_id)) finding = result.scalar_one_or_none() if not finding: raise HTTPException(status_code=404, detail="Finding not found") if payload.get("role") != "trustos_admin" and payload.get("tenant_id") != finding.tenant_id: raise HTTPException(status_code=403, detail="Access denied") # Return cached AI translation if available if finding.ai_summary: return { "finding_id": finding_id, "summary": finding.ai_summary, "business_impact": finding.ai_business_impact, "impact_level": finding.ai_impact_level, "remediation_steps": finding.ai_remediation_steps, "fix_priority": finding.ai_fix_priority, "generated_at": finding.ai_generated_at, "status": "available", } # If not generated yet, trigger async generation and return 202 import asyncio asyncio.create_task(translate_finding_async(finding_id)) return { "finding_id": finding_id, "status": "processing", "message": "AI translation is being generated. Please check back shortly.", }