"""Ideas API routes for VoIdea.""" from typing import Annotated from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy.ext.asyncio import AsyncSession from fastapi.responses import PlainTextResponse from uuid import uuid4 from app.core.dependencies import get_current_user, get_db, get_optional_user from app.models.idea import Idea from app.models.user import User from app.schemas.idea import ( AnalysisResultResponse, FunnelTransitionRequest, IdeaAnalyzeResponse, IdeaCreate, IdeaResponse, IdeaUpdate, ) from app.services.analysis_service import AnalysisService from app.services.collaboration_service import CollaborationService from app.services.disk_service import DiskService from app.services.export_service import export_content from app.services.idea_service import IdeaService from app.services.presentation_service import PresentationService router = APIRouter() def _idea_to_response(idea) -> IdeaResponse: return IdeaResponse( id=str(idea.id), user_id=str(idea.user_id), title=idea.title, content=idea.content, status=idea.status, tags=idea.tags, is_public=idea.is_public, public_slug=idea.public_slug, created_at=idea.created_at, updated_at=idea.updated_at, ) @router.get("/", response_model=list[IdeaResponse]) async def list_ideas( user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=100), ): service = IdeaService(db) ideas = await service.list_by_user(str(user.id), skip=skip, limit=limit) return [_idea_to_response(idea) for idea in ideas] @router.post("/", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED) async def create_idea( body: IdeaCreate, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) idea = await service.create( user_id=str(user.id), title=body.title, content=body.content, tags=body.tags, is_public=body.is_public, ) return _idea_to_response(idea) @router.get("/{idea_id}", response_model=IdeaResponse) async def get_idea( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) idea = await service.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") return _idea_to_response(idea) @router.patch("/{idea_id}", response_model=IdeaResponse) async def update_idea( idea_id: str, body: IdeaUpdate, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) idea = await service.update( idea_id, str(user.id), title=body.title, content=body.content, status=body.status, tags=body.tags, is_public=body.is_public, ) if not idea: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") return _idea_to_response(idea) @router.delete("/{idea_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_idea( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) deleted = await service.delete(idea_id, str(user.id)) if not deleted: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") @router.post("/{idea_id}/analyze", response_model=IdeaAnalyzeResponse) async def analyze_idea( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): idea_service = IdeaService(db) idea = await idea_service.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") analysis_service = AnalysisService(db) result = await analysis_service.start_analysis(idea_id) return IdeaAnalyzeResponse( status="started", idea_id=idea_id, task_count=result["task_count"], tasks=result["tasks"], ) @router.get("/{idea_id}/analysis", response_model=list[AnalysisResultResponse]) async def get_analysis_results( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), role: str = Query(None, description="Filter by agent role"), ): idea_service = IdeaService(db) idea = await idea_service.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") analysis_service = AnalysisService(db) results = await analysis_service.get_analysis_results(idea_id, role=role) return [AnalysisResultResponse(**r) for r in results] @router.get("/{idea_id}/export") async def export_idea( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), format: str = Query("md", regex="^(json|md|html)$"), ): idea_service = IdeaService(db) idea = await idea_service.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") metadata = { "ID": str(idea.id), "Статус": idea.status, "Теги": ", ".join(idea.tags) if idea.tags else "—", "Создано": idea.created_at.isoformat() if idea.created_at else "—", } body, content_type, ext = export_content(format, idea.title, idea.content, metadata) filename = f"{idea.title[:50]}.{ext}".replace(" ", "_") from fastapi.responses import Response return Response( content=body, media_type=content_type, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/{idea_id}/presentation") async def generate_presentation( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), roles: str = Query("", description="Comma-separated role names (empty=all)"), destination: str = Query("download", regex="^(download|drive)$"), drive_provider: str = Query("", description="Specific drive provider (google/yandex/apple)"), ): """Generate HTML presentation for an idea. Args: destination: "download" = return as file, "drive" = upload to cloud drive roles: Comma-separated agent roles (empty = all available) drive_provider: Specific provider to upload to (empty = auto-detect) """ idea_svc = IdeaService(db) idea = await idea_svc.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") role_list = [r.strip() for r in roles.split(",") if r.strip()] if roles else None pres_svc = PresentationService(db) html = await pres_svc.generate(idea_id, roles=role_list, user_name=user.display_name) if not html: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") filename = f"prezentaciya_{idea.title[:40].replace(' ', '_')}.html" if destination == "drive": disk_svc = DiskService(db) content_bytes = html.encode("utf-8") if drive_provider: success = await disk_svc.upload_to_provider( user, drive_provider, filename, content_bytes ) if not success: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Failed to upload to {drive_provider}", ) return {"success": True, "provider": drive_provider, "filename": filename} else: result = await disk_svc.upload_to_any(user, filename, content_bytes) if not result: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No connected drive. Connect a cloud drive first.", ) return {"success": True, "provider": result["provider"], "filename": filename} from fastapi.responses import Response return Response( content=html, media_type="text/html; charset=utf-8", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/{idea_id}/share", response_model=IdeaResponse) async def toggle_idea_share( idea_id: str, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) idea = await service.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") idea.is_public = not idea.is_public if idea.is_public and not idea.public_slug: idea.public_slug = uuid4().hex[:16] elif not idea.is_public: idea.public_slug = None await db.commit() await db.refresh(idea) return _idea_to_response(idea) @router.post("/{idea_id}/funnel", response_model=IdeaResponse) async def transition_funnel( idea_id: str, body: FunnelTransitionRequest, user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) idea = await service.get_by_id(idea_id) if not idea or str(idea.user_id) != str(user.id): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found") target = body.target_status valid_states = Idea.FUNNEL_STATES if target not in valid_states: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid funnel status. Valid: {', '.join(valid_states)}", ) idea.funnel_status = target await db.commit() await db.refresh(idea) collab = CollaborationService(db) await collab.log_activity( UUID(idea_id), user.id, "funnel", {"from": idea.funnel_status, "to": target}, ) return _idea_to_response(idea) @router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED) async def create_demo_idea( user: Annotated[User, Depends(get_current_user)], db: AsyncSession = Depends(get_db), ): service = IdeaService(db) idea = await service.create( user_id=str(user.id), title="Моя первая идея (демо)", content="Пример идеи для знакомства с VoIdeaAI. Замените этот текст на свою идею.\n\n" "VoIdeaAI поможет проанализировать её с разных сторон: бизнес, финансы, " "право, технологии и маркетинг. Просто нажмите «Анализ» на странице идеи.", tags=["demo", "привет"], is_public=False, ) return _idea_to_response(idea) @router.get("/shared/{slug}", response_model=IdeaResponse) async def get_shared_idea( slug: str, db: AsyncSession = Depends(get_db), ): result = await db.execute( select(Idea).where(Idea.public_slug == slug, Idea.is_public == True) ) idea = result.scalar_one_or_none() if not idea: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found or not shared") return _idea_to_response(idea)