Files

337 lines
12 KiB
Python

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