Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# api Module - VoIdea
|
||||
|
||||
## Overview
|
||||
|
||||
[Auto-generated documentation]
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
@@ -0,0 +1 @@
|
||||
"""VoIdea - API module."""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""VoIdea - API v1 routers."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.users import router as users_router
|
||||
from app.api.v1.ideas import router as ideas_router
|
||||
from app.api.v1.agents import router as agents_router
|
||||
from app.api.v1.sync import router as sync_router
|
||||
from app.api.v1.admin import router as admin_router
|
||||
from app.api.v1.voice import router as voice_router
|
||||
from app.api.v1.config import router as config_router
|
||||
from app.api.v1.feedback import router as feedback_router
|
||||
from app.api.v1.tariffs import router as tariffs_router
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
|
||||
api_v1_router.include_router(auth_router, prefix="/auth", tags=["auth"])
|
||||
api_v1_router.include_router(users_router, prefix="/users", tags=["users"])
|
||||
api_v1_router.include_router(ideas_router, prefix="/ideas", tags=["ideas"])
|
||||
api_v1_router.include_router(agents_router, prefix="/agents", tags=["agents"])
|
||||
api_v1_router.include_router(sync_router, prefix="/sync", tags=["sync"])
|
||||
api_v1_router.include_router(admin_router, prefix="/admin", tags=["admin"])
|
||||
api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
|
||||
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
|
||||
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
|
||||
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
|
||||
|
||||
__all__ = ["api_v1_router"]
|
||||
@@ -0,0 +1,839 @@
|
||||
"""Admin API routes for VoIdea.
|
||||
|
||||
Role-based access:
|
||||
- owner: full access including service management
|
||||
- admin: full management except services & system
|
||||
- moderator: permission-based (checked via require_permission)
|
||||
"""
|
||||
|
||||
import json
|
||||
import platform
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.dependencies import get_current_user, get_db, require_admin, require_owner, require_permission
|
||||
from app.models.agent import AgentConfig
|
||||
from app.models.backlog import BacklogTask
|
||||
from app.models.bot_command import BotCommand
|
||||
from app.models.feedback import Feedback
|
||||
from app.models.log import LogEntry
|
||||
from app.models.user import User
|
||||
from app.schemas.admin import (
|
||||
AgentInfoResponse,
|
||||
AgentUpdateRequest,
|
||||
FeatureCreate,
|
||||
FeatureResponse,
|
||||
FeatureUpdate,
|
||||
LogEntryResponse,
|
||||
ServiceActionRequest,
|
||||
ServiceActionResult,
|
||||
ServiceStatusResponse,
|
||||
SystemHealth,
|
||||
SystemInfoResponse,
|
||||
UserAdminUpdate,
|
||||
)
|
||||
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
|
||||
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
|
||||
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
|
||||
from app.schemas.user import UserResponse
|
||||
from app.schemas.pipeline import PipelineConfigResponse, PipelineConfigUpdate, PipelineStatsEntry, PipelineStatsSummary
|
||||
from app.services.debug_service import DebugService
|
||||
from app.services.feedback_service import FeedbackService
|
||||
from app.services.pipeline_service import PipelineService
|
||||
from app.services.tariff_service import TariffService
|
||||
from app.services.two_factor_service import is_2fa_globally_enabled, set_2fa_globally_enabled
|
||||
from app.services.user_service import UserService
|
||||
|
||||
settings = get_settings()
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
# ── Helpers ──
|
||||
|
||||
def _user_to_response(u: User) -> UserResponse:
|
||||
return UserResponse(
|
||||
id=str(u.id),
|
||||
email=u.email,
|
||||
display_name=u.display_name,
|
||||
avatar_url=u.avatar_url,
|
||||
is_active=u.is_active,
|
||||
is_superuser=u.is_superuser,
|
||||
role=u.role or "user",
|
||||
is_owner=u.is_owner,
|
||||
permissions=u.permissions,
|
||||
accepted_terms_at=u.accepted_terms_at,
|
||||
accepted_terms_version=u.accepted_terms_version,
|
||||
oauth_provider=u.oauth_provider,
|
||||
created_at=u.created_at,
|
||||
updated_at=u.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _log_to_response(l: LogEntry) -> LogEntryResponse:
|
||||
import json
|
||||
details = None
|
||||
if l.details:
|
||||
try:
|
||||
details = json.loads(l.details)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
details = {"raw": l.details}
|
||||
return LogEntryResponse(
|
||||
id=str(l.id),
|
||||
level=l.level,
|
||||
source=l.source,
|
||||
message=l.message,
|
||||
details=details,
|
||||
user_id=str(l.user_id) if l.user_id else None,
|
||||
created_at=l.created_at,
|
||||
)
|
||||
|
||||
|
||||
def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
|
||||
return AgentInfoResponse(
|
||||
agent_name=a.agent_name,
|
||||
description=getattr(a, "description", ""),
|
||||
is_enabled=a.is_enabled,
|
||||
version=a.version,
|
||||
last_run_at=a.last_run_at,
|
||||
)
|
||||
|
||||
|
||||
def _feature_to_response(t: BacklogTask) -> FeatureResponse:
|
||||
return FeatureResponse(
|
||||
id=str(t.id),
|
||||
title=t.title,
|
||||
description=t.description,
|
||||
priority=t.priority,
|
||||
status=t.status,
|
||||
category=t.category or "feature",
|
||||
source_agent=t.source_agent,
|
||||
created_at=t.created_at,
|
||||
updated_at=t.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ── Users ──
|
||||
|
||||
@router.get("/users", response_model=list[UserResponse])
|
||||
async def list_users(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=200),
|
||||
search: Optional[str] = Query(None),
|
||||
):
|
||||
service = UserService(db)
|
||||
users = await service.list_users(skip=skip, limit=limit, search=search)
|
||||
return [_user_to_response(u) for u in users]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=UserResponse)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
body: UserAdminUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
result = await service.admin_update_user(user_id, updates)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return _user_to_response(result)
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
success = await service.hard_delete(user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
|
||||
|
||||
# ── Agents ──
|
||||
|
||||
@router.get("/agents", response_model=list[AgentInfoResponse])
|
||||
async def list_agents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(select(AgentConfig).order_by(AgentConfig.agent_name))
|
||||
agents = result.scalars().all()
|
||||
return [_agent_to_response(a) for a in agents]
|
||||
|
||||
|
||||
@router.patch("/agents/{agent_name}", response_model=AgentInfoResponse)
|
||||
async def update_agent(
|
||||
agent_name: str,
|
||||
body: AgentUpdateRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(AgentConfig).where(AgentConfig.agent_name == agent_name)
|
||||
)
|
||||
agent = result.scalar_one_or_none()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||
|
||||
if body.description is not None:
|
||||
agent.description = body.description
|
||||
if body.is_enabled is not None:
|
||||
agent.is_enabled = body.is_enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(agent)
|
||||
return _agent_to_response(agent)
|
||||
|
||||
|
||||
# ── Logs ──
|
||||
|
||||
@router.get("/logs", response_model=list[LogEntryResponse])
|
||||
async def list_logs(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
level: Optional[str] = Query(None, description="ERROR | WARNING | INFO | DEBUG"),
|
||||
source: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
query = select(LogEntry).order_by(LogEntry.created_at.desc())
|
||||
if level:
|
||||
query = query.where(LogEntry.level == level.upper())
|
||||
if source:
|
||||
query = query.where(LogEntry.source.ilike(f"%{source}%"))
|
||||
result = await db.execute(query.offset(skip).limit(limit))
|
||||
logs = result.scalars().all()
|
||||
return [_log_to_response(l) for l in logs]
|
||||
|
||||
|
||||
# ── Feedback ──
|
||||
|
||||
@router.get("/feedback", response_model=list[FeedbackResponse])
|
||||
async def list_feedback(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
status_filter: Optional[str] = Query(None, alias="status"),
|
||||
):
|
||||
svc = FeedbackService(db)
|
||||
items = await svc.list_feedback(status=status_filter)
|
||||
return [
|
||||
FeedbackResponse(
|
||||
id=str(f.id),
|
||||
user_id=str(f.user_id) if f.user_id else None,
|
||||
text=f.text,
|
||||
page_url=f.page_url,
|
||||
status=f.status,
|
||||
created_at=f.created_at,
|
||||
updated_at=f.updated_at,
|
||||
)
|
||||
for f in items
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/feedback/{feedback_id}", response_model=FeedbackResponse)
|
||||
async def update_feedback(
|
||||
feedback_id: str,
|
||||
body: FeedbackUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = FeedbackService(db)
|
||||
fb = await svc.update_status(UUID(feedback_id), body.status)
|
||||
if not fb:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
|
||||
return FeedbackResponse(
|
||||
id=str(fb.id),
|
||||
user_id=str(fb.user_id) if fb.user_id else None,
|
||||
text=fb.text,
|
||||
page_url=fb.page_url,
|
||||
status=fb.status,
|
||||
created_at=fb.created_at,
|
||||
updated_at=fb.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/feedback/{feedback_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_feedback(
|
||||
feedback_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = FeedbackService(db)
|
||||
success = await svc.delete(UUID(feedback_id))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feedback not found")
|
||||
|
||||
|
||||
# ── 2FA Global Toggle ──
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def get_2fa_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
globally_enabled = await is_2fa_globally_enabled(db)
|
||||
return {"globally_enabled": globally_enabled}
|
||||
|
||||
|
||||
@router.post("/2fa/toggle")
|
||||
async def toggle_2fa(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if user.role not in ("admin", "owner") and not user.is_superuser:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только администратор может управлять 2FA")
|
||||
current = await is_2fa_globally_enabled(db)
|
||||
await set_2fa_globally_enabled(db, not current)
|
||||
return {"globally_enabled": not current}
|
||||
|
||||
|
||||
# ── Debug Mode ──
|
||||
|
||||
@router.get("/debug/config")
|
||||
async def get_debug_config(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = DebugService(db)
|
||||
return await svc.get_config()
|
||||
|
||||
|
||||
@router.patch("/debug/config")
|
||||
async def update_debug_config(
|
||||
body: dict,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = DebugService(db)
|
||||
return await svc.update_config(body)
|
||||
|
||||
|
||||
@router.post("/debug/enable")
|
||||
async def enable_debug_mode(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not user.is_owner:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может включить debug mode")
|
||||
svc = DebugService(db)
|
||||
return await svc.enable_debug_mode()
|
||||
|
||||
|
||||
@router.post("/debug/disable")
|
||||
async def disable_debug_mode(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not user.is_owner:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может выключить debug mode")
|
||||
svc = DebugService(db)
|
||||
return await svc.disable_debug_mode()
|
||||
|
||||
|
||||
@router.get("/debug/status")
|
||||
async def get_debug_status(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = DebugService(db)
|
||||
return await svc.get_status()
|
||||
|
||||
|
||||
@router.post("/debug/cleanup")
|
||||
async def cleanup_logs(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not user.is_owner:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Только владелец может очистить логи")
|
||||
svc = DebugService(db)
|
||||
return await svc.cleanup_logs(force=True)
|
||||
|
||||
|
||||
# ── Log Export ──
|
||||
|
||||
@router.get("/logs/export")
|
||||
async def export_logs(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
level: Optional[str] = None,
|
||||
source: Optional[str] = None,
|
||||
):
|
||||
from fastapi.responses import Response
|
||||
|
||||
query = select(LogEntry).order_by(LogEntry.created_at.desc())
|
||||
if level:
|
||||
query = query.where(LogEntry.level == level.upper())
|
||||
if source:
|
||||
query = query.where(LogEntry.source.ilike(f"%{source}%"))
|
||||
result = await db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
export_data = []
|
||||
for l in logs:
|
||||
details = None
|
||||
if l.details:
|
||||
try:
|
||||
details = json.loads(l.details)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
details = {"raw": l.details}
|
||||
export_data.append({
|
||||
"id": str(l.id),
|
||||
"level": l.level,
|
||||
"source": l.source,
|
||||
"message": l.message,
|
||||
"details": details,
|
||||
"user_id": str(l.user_id) if l.user_id else None,
|
||||
"created_at": l.created_at.isoformat() if l.created_at else None,
|
||||
})
|
||||
|
||||
return Response(
|
||||
content=json.dumps(export_data, ensure_ascii=False, default=str),
|
||||
media_type="application/json",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=voidea-logs-{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Tariffs ──
|
||||
|
||||
@router.get("/tariffs", response_model=list[TariffPlanResponse])
|
||||
async def list_tariff_plans(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = TariffService(db)
|
||||
plans = await svc.list_plans(active_only=False)
|
||||
return [
|
||||
TariffPlanResponse(
|
||||
id=str(p.id),
|
||||
name=p.name,
|
||||
code=p.code,
|
||||
description=p.description,
|
||||
price_monthly=p.price_monthly,
|
||||
price_yearly=p.price_yearly,
|
||||
features=p.features,
|
||||
is_active=p.is_active,
|
||||
sort_order=p.sort_order,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in plans
|
||||
]
|
||||
|
||||
|
||||
@router.post("/tariffs", response_model=TariffPlanResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tariff_plan(
|
||||
body: TariffPlanCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = TariffService(db)
|
||||
plan = await svc.create_plan(
|
||||
name=body.name,
|
||||
code=body.code,
|
||||
description=body.description,
|
||||
price_monthly=body.price_monthly,
|
||||
price_yearly=body.price_yearly,
|
||||
features=body.features,
|
||||
is_active=body.is_active,
|
||||
sort_order=body.sort_order,
|
||||
)
|
||||
return TariffPlanResponse(
|
||||
id=str(plan.id),
|
||||
name=plan.name,
|
||||
code=plan.code,
|
||||
description=plan.description,
|
||||
price_monthly=plan.price_monthly,
|
||||
price_yearly=plan.price_yearly,
|
||||
features=plan.features,
|
||||
is_active=plan.is_active,
|
||||
sort_order=plan.sort_order,
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/tariffs/{plan_id}", response_model=TariffPlanResponse)
|
||||
async def update_tariff_plan(
|
||||
plan_id: str,
|
||||
body: TariffPlanUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = TariffService(db)
|
||||
plan = await svc.update_plan(UUID(plan_id), body.model_dump(exclude_none=True))
|
||||
if not plan:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
|
||||
return TariffPlanResponse(
|
||||
id=str(plan.id),
|
||||
name=plan.name,
|
||||
code=plan.code,
|
||||
description=plan.description,
|
||||
price_monthly=plan.price_monthly,
|
||||
price_yearly=plan.price_yearly,
|
||||
features=plan.features,
|
||||
is_active=plan.is_active,
|
||||
sort_order=plan.sort_order,
|
||||
created_at=plan.created_at,
|
||||
updated_at=plan.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tariffs/{plan_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tariff_plan(
|
||||
plan_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = TariffService(db)
|
||||
success = await svc.delete_plan(UUID(plan_id))
|
||||
if not success:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tariff plan not found")
|
||||
|
||||
|
||||
# ── Features (BacklogTask category=feature) ──
|
||||
|
||||
@router.get("/features", response_model=list[FeatureResponse])
|
||||
async def list_features(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(BacklogTask)
|
||||
.where(BacklogTask.category == "feature")
|
||||
.order_by(BacklogTask.created_at.desc())
|
||||
)
|
||||
return [_feature_to_response(t) for t in result.scalars().all()]
|
||||
|
||||
|
||||
@router.post("/features", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_feature(
|
||||
body: FeatureCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
task = BacklogTask(
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
priority=body.priority or "medium",
|
||||
status="pending",
|
||||
category="feature",
|
||||
)
|
||||
db.add(task)
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return _feature_to_response(task)
|
||||
|
||||
|
||||
@router.patch("/features/{feature_id}", response_model=FeatureResponse)
|
||||
async def update_feature(
|
||||
feature_id: str,
|
||||
body: FeatureUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(BacklogTask).where(
|
||||
BacklogTask.id == UUID(feature_id),
|
||||
BacklogTask.category == "feature",
|
||||
)
|
||||
)
|
||||
task = result.scalar_one_or_none()
|
||||
if not task:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature not found")
|
||||
|
||||
updates = body.model_dump(exclude_none=True)
|
||||
for key, value in updates.items():
|
||||
if hasattr(task, key) and key not in ("id", "category", "created_at"):
|
||||
setattr(task, key, value)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(task)
|
||||
return _feature_to_response(task)
|
||||
|
||||
|
||||
# ── Pipeline ──
|
||||
|
||||
@router.get("/pipeline", response_model=PipelineConfigResponse)
|
||||
async def get_pipeline_config(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PipelineService(db)
|
||||
return await svc.get_config()
|
||||
|
||||
|
||||
@router.patch("/pipeline", response_model=PipelineConfigResponse)
|
||||
async def update_pipeline_config(
|
||||
body: PipelineConfigUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PipelineService(db)
|
||||
return await svc.update_config(body.model_dump(exclude_none=True))
|
||||
|
||||
|
||||
@router.get("/pipeline/stats", response_model=list[PipelineStatsEntry])
|
||||
async def list_pipeline_stats(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
stage: Optional[str] = Query(None),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
):
|
||||
svc = PipelineService(db)
|
||||
stats = await svc.list_stats(stage=stage, skip=skip, limit=limit)
|
||||
return [
|
||||
PipelineStatsEntry(
|
||||
id=str(s.id),
|
||||
user_id=str(s.user_id) if s.user_id else None,
|
||||
stage=s.stage,
|
||||
passed=s.passed,
|
||||
reason=s.reason,
|
||||
duration_ms=s.duration_ms,
|
||||
created_at=s.created_at,
|
||||
)
|
||||
for s in stats
|
||||
]
|
||||
|
||||
|
||||
@router.get("/pipeline/stats/summary", response_model=PipelineStatsSummary)
|
||||
async def pipeline_stats_summary(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PipelineService(db)
|
||||
return await svc.get_summary()
|
||||
|
||||
|
||||
# ── Services (owner only) ──
|
||||
|
||||
SERVICE_MAP = {
|
||||
"api": {
|
||||
"name": "VoIdea API",
|
||||
"unit": "voidea-api",
|
||||
"description": "FastAPI приложение — основной веб-сервер для обработки запросов",
|
||||
},
|
||||
"worker": {
|
||||
"name": "VoIdea Worker",
|
||||
"unit": "voidea-worker",
|
||||
"description": "Celery worker — асинхронная обработка задач (анализ идей, AI запросы)",
|
||||
},
|
||||
"beat": {
|
||||
"name": "VoIdea Beat",
|
||||
"unit": "voidea-beat",
|
||||
"description": "Celery beat — планировщик периодических задач",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _systemctl(unit: str, action: str) -> tuple[bool, str]:
|
||||
"""Run systemctl command and return (success, message)."""
|
||||
cmd = ["systemctl", action, f"{unit}.service"]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
return True, result.stdout.strip() or f"{action.capitalize()} successful"
|
||||
return False, result.stderr.strip() or f"{action.capitalize()} failed"
|
||||
except FileNotFoundError:
|
||||
return False, "systemctl not found (not a systemd system)"
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Command timed out"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
@router.post("/services/restart", response_model=list[ServiceActionResult])
|
||||
async def restart_services(
|
||||
body: ServiceActionRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""Restart system services. Owner only. Only works in production."""
|
||||
if not user.is_owner:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Только владелец системы может управлять сервисами",
|
||||
)
|
||||
|
||||
results: list[ServiceActionResult] = []
|
||||
|
||||
if body.service == "all":
|
||||
targets = list(SERVICE_MAP.keys())
|
||||
elif body.service in SERVICE_MAP:
|
||||
targets = [body.service]
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unknown service: {body.service}. Available: {', '.join(SERVICE_MAP.keys())}, all",
|
||||
)
|
||||
|
||||
for svc in targets:
|
||||
info = SERVICE_MAP[svc]
|
||||
success, message = await _systemctl(info["unit"], body.action)
|
||||
results.append(ServiceActionResult(
|
||||
service=svc,
|
||||
action=body.action,
|
||||
success=success,
|
||||
message=message,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/services", response_model=list[ServiceStatusResponse])
|
||||
async def list_services(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
"""List all available services and their status. Owner only."""
|
||||
results: list[ServiceStatusResponse] = []
|
||||
for svc_key, info in SERVICE_MAP.items():
|
||||
success, status_text = await _systemctl(info["unit"], "is-active")
|
||||
is_running = success and status_text.strip() == "active"
|
||||
results.append(ServiceStatusResponse(
|
||||
service=svc_key,
|
||||
description=info["description"],
|
||||
status=status_text.strip() if status_text else "unknown",
|
||||
is_running=is_running,
|
||||
))
|
||||
return results
|
||||
|
||||
|
||||
# ── System ──
|
||||
|
||||
@router.get("/health", response_model=SystemHealth)
|
||||
async def health(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
db_status = "connected"
|
||||
redis_status = "unknown"
|
||||
|
||||
try:
|
||||
from redis import asyncio as aioredis
|
||||
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
|
||||
await r.ping()
|
||||
redis_status = "connected"
|
||||
await r.aclose()
|
||||
except Exception:
|
||||
redis_status = "disconnected"
|
||||
|
||||
return SystemHealth(
|
||||
status="healthy",
|
||||
database=db_status,
|
||||
redis=redis_status,
|
||||
version=settings.project_version,
|
||||
)
|
||||
|
||||
|
||||
# ── Bot Commands ──
|
||||
|
||||
@router.get("/bot", response_model=list[BotCommandResponse])
|
||||
async def list_bot_commands(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await db.execute(
|
||||
select(BotCommand).order_by(BotCommand.name)
|
||||
)
|
||||
commands = result.scalars().all()
|
||||
return [
|
||||
BotCommandResponse(
|
||||
id=str(c.id),
|
||||
name=c.name,
|
||||
description=c.description,
|
||||
enabled=c.enabled,
|
||||
requires_auth=c.requires_auth,
|
||||
created_at=c.created_at,
|
||||
updated_at=c.updated_at,
|
||||
)
|
||||
for c in commands
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/bot/{command_id}", response_model=BotCommandResponse)
|
||||
async def toggle_bot_command(
|
||||
command_id: str,
|
||||
body: BotCommandUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from uuid import UUID
|
||||
result = await db.execute(
|
||||
select(BotCommand).where(BotCommand.id == UUID(command_id))
|
||||
)
|
||||
cmd = result.scalar_one_or_none()
|
||||
if not cmd:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Command not found")
|
||||
|
||||
if body.enabled is not None:
|
||||
cmd.enabled = body.enabled
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(cmd)
|
||||
return BotCommandResponse(
|
||||
id=str(cmd.id),
|
||||
name=cmd.name,
|
||||
description=cmd.description,
|
||||
enabled=cmd.enabled,
|
||||
requires_auth=cmd.requires_auth,
|
||||
created_at=cmd.created_at,
|
||||
updated_at=cmd.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bot/sync")
|
||||
async def sync_bot_commands(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from app.integrations.telegram.sync_service import TelegramBotSyncService
|
||||
from app.integrations.telegram.client import TelegramBotClient
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
bot_client = TelegramBotClient(token=settings.telegram_bot_token)
|
||||
svc = TelegramBotSyncService(db, bot_client)
|
||||
result = await svc.full_sync()
|
||||
return result
|
||||
|
||||
|
||||
# ── System ──
|
||||
|
||||
@router.get("/system", response_model=SystemInfoResponse)
|
||||
async def system_info(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
db_status = "connected"
|
||||
redis_status = "unknown"
|
||||
try:
|
||||
from redis import asyncio as aioredis
|
||||
r = aioredis.from_url(settings.redis_url, socket_connect_timeout=1)
|
||||
await r.ping()
|
||||
redis_status = "connected"
|
||||
await r.aclose()
|
||||
except Exception:
|
||||
redis_status = "disconnected"
|
||||
|
||||
return SystemInfoResponse(
|
||||
version=settings.project_version,
|
||||
environment=settings.project_env,
|
||||
database_status=db_status,
|
||||
redis_status=redis_status,
|
||||
python_version=platform.python_version(),
|
||||
uptime_seconds=None,
|
||||
)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Agents API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from app.agents.registry import registry
|
||||
from app.core.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.agent import AgentRunRequest, AgentStatusResponse
|
||||
from app.services.agent_service import AgentService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_agent_service() -> AgentService:
|
||||
return AgentService(registry)
|
||||
|
||||
|
||||
@router.get("/", response_model=list[AgentStatusResponse])
|
||||
async def list_agents(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
service = get_agent_service()
|
||||
agents = service.list_agents()
|
||||
return [AgentStatusResponse(**a) for a in agents]
|
||||
|
||||
|
||||
@router.get("/{agent_name}", response_model=AgentStatusResponse)
|
||||
async def get_agent(
|
||||
agent_name: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
service = get_agent_service()
|
||||
agent = service.get_agent(agent_name)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Agent not found")
|
||||
return AgentStatusResponse(**agent)
|
||||
|
||||
|
||||
@router.post("/{agent_name}/run")
|
||||
async def run_agent(
|
||||
agent_name: str,
|
||||
body: AgentRunRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
service = get_agent_service()
|
||||
result = await service.run_agent(agent_name, body.context)
|
||||
if not result["success"] and "not found" in result.get("message", ""):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=result["message"],
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Auth API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.core.limiter import limiter
|
||||
from app.core.security import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
)
|
||||
from app.integrations.oauth.yandex import exchange_code, get_authorize_url, get_user_info
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
ForgotPasswordRequest,
|
||||
LoginRequest,
|
||||
OAuthCallbackRequest,
|
||||
OAuthUrlResponse,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
ResetPasswordRequest,
|
||||
TokenResponse,
|
||||
TwoFactorLoginRequest,
|
||||
TwoFactorLoginResponse,
|
||||
TwoFactorSetupResponse,
|
||||
TwoFactorVerifyRequest,
|
||||
)
|
||||
from app.schemas.user import ChangePasswordRequest
|
||||
from app.services.auth_service import AuthService
|
||||
from app.services.password_reset_service import reset_password, send_reset_email
|
||||
from app.services.two_factor_service import (
|
||||
generate_qr_base64,
|
||||
generate_totp_secret,
|
||||
get_totp_uri,
|
||||
get_user_secret,
|
||||
is_2fa_enabled,
|
||||
is_2fa_globally_enabled,
|
||||
set_2fa_enabled,
|
||||
set_user_secret,
|
||||
verify_totp,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=TokenResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("5/minute")
|
||||
async def register(request: Request, body: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||
service = AuthService(db)
|
||||
try:
|
||||
return await service.register(
|
||||
body.email, body.password, body.display_name,
|
||||
accepted_terms=body.accepted_terms,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
@limiter.limit("10/minute")
|
||||
async def login(request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(select(User).where(User.email == body.email))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not verify_password(body.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный email или пароль")
|
||||
|
||||
globally_enabled = await is_2fa_globally_enabled(db)
|
||||
if globally_enabled and is_2fa_enabled(user):
|
||||
from datetime import timedelta
|
||||
temp_token = create_access_token(
|
||||
data={"sub": str(user.id), "purpose": "2fa"},
|
||||
expires_delta=timedelta(minutes=5),
|
||||
)
|
||||
return {"temp_token": temp_token, "message": "Требуется 2FA код"}
|
||||
|
||||
service = AuthService(db)
|
||||
return await service.create_token_response(user)
|
||||
|
||||
|
||||
@router.post("/2fa/verify-login", response_model=TwoFactorLoginResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def verify_2fa_login(
|
||||
request: Request,
|
||||
body: TwoFactorLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from jose import jwt, JWTError
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
body.temp_token, settings.jwt_secret_key,
|
||||
algorithms=[settings.jwt_algorithm],
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid temp token")
|
||||
|
||||
if payload.get("purpose") != "2fa":
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token purpose")
|
||||
|
||||
from sqlalchemy import select
|
||||
result = await db.execute(select(User).where(User.id == payload.get("sub")))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
|
||||
|
||||
secret = get_user_secret(user)
|
||||
if not secret or not verify_totp(secret, body.totp_code):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Неверный 2FA код")
|
||||
|
||||
service = AuthService(db)
|
||||
return await service.create_token_response(user)
|
||||
|
||||
|
||||
@router.post("/2fa/setup", response_model=TwoFactorSetupResponse)
|
||||
async def setup_2fa(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if is_2fa_enabled(user):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA уже включена")
|
||||
|
||||
secret = generate_totp_secret()
|
||||
set_user_secret(user, secret)
|
||||
await db.commit()
|
||||
|
||||
uri = get_totp_uri(secret, user.email)
|
||||
qr = generate_qr_base64(uri)
|
||||
|
||||
return TwoFactorSetupResponse(secret=secret, uri=uri, qr_base64=qr)
|
||||
|
||||
|
||||
@router.post("/2fa/verify")
|
||||
async def verify_2fa(
|
||||
body: TwoFactorVerifyRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
secret = get_user_secret(user)
|
||||
if not secret:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="2FA не настроена")
|
||||
|
||||
if not verify_totp(secret, body.token):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
|
||||
|
||||
set_2fa_enabled(user, True)
|
||||
await db.commit()
|
||||
return {"message": "2FA успешно включена"}
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def disable_2fa(
|
||||
body: TwoFactorVerifyRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
secret = get_user_secret(user)
|
||||
if not secret or not verify_totp(secret, body.token):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Неверный код")
|
||||
|
||||
set_2fa_enabled(user, False)
|
||||
await db.commit()
|
||||
return {"message": "2FA отключена"}
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh(request: Request, body: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||
service = AuthService(db)
|
||||
try:
|
||||
return await service.refresh(body.refresh_token)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/oauth/yandex", response_model=OAuthUrlResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def oauth_yandex_url(request: Request):
|
||||
url = await get_authorize_url()
|
||||
return OAuthUrlResponse(url=url, provider="yandex")
|
||||
|
||||
|
||||
@router.post("/oauth/yandex/callback", response_model=TokenResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def oauth_yandex_callback(
|
||||
request: Request,
|
||||
body: OAuthCallbackRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
token_result = await exchange_code(body.code)
|
||||
if not token_result:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to exchange authorization code",
|
||||
)
|
||||
|
||||
user_info = await get_user_info(token_result.access_token)
|
||||
if not user_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Failed to get user info from Yandex",
|
||||
)
|
||||
|
||||
service = AuthService(db)
|
||||
try:
|
||||
return await service.oauth_or_register_login(
|
||||
email=user_info.email,
|
||||
oauth_provider="yandex",
|
||||
oauth_id=user_info.id,
|
||||
display_name=user_info.display_name,
|
||||
avatar_url=user_info.avatar_url,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/forgot-password")
|
||||
@limiter.limit("3/minute")
|
||||
async def forgot_password(
|
||||
request: Request,
|
||||
body: ForgotPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
success, message = await send_reset_email(db, body.email)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=message,
|
||||
)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.post("/reset-password")
|
||||
@limiter.limit("5/minute")
|
||||
async def reset_password_endpoint(
|
||||
request: Request,
|
||||
body: ResetPasswordRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
success, message = await reset_password(db, body.token, body.new_password)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=message,
|
||||
)
|
||||
return {"message": message}
|
||||
|
||||
|
||||
@router.post("/change-password")
|
||||
@limiter.limit("5/minute")
|
||||
async def change_password(
|
||||
request: Request,
|
||||
body: ChangePasswordRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not verify_password(body.current_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Неверный текущий пароль",
|
||||
)
|
||||
|
||||
user.password_hash = get_password_hash(body.new_password)
|
||||
await db.commit()
|
||||
return {"message": "Пароль успешно изменён"}
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Public config API routes for VoIdea."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.schemas.config import PublicConfigResponse
|
||||
|
||||
router = APIRouter()
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@router.get("/public", response_model=PublicConfigResponse)
|
||||
async def public_config():
|
||||
"""Return non-sensitive public configuration (slogan, social links, analytics IDs)."""
|
||||
return settings.public_config
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Feedback API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db, get_optional_user
|
||||
from app.core.limiter import limiter
|
||||
from app.models.user import User
|
||||
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
|
||||
from app.services.feedback_service import FeedbackService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("", response_model=FeedbackResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("5/minute")
|
||||
async def create_feedback(
|
||||
request: Request,
|
||||
body: FeedbackCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: Optional[User] = Depends(get_optional_user),
|
||||
):
|
||||
service = FeedbackService(db)
|
||||
fb = await service.create(
|
||||
user_id=user.id if user else None,
|
||||
text=body.text,
|
||||
page_url=body.page_url,
|
||||
)
|
||||
return FeedbackResponse(
|
||||
id=str(fb.id),
|
||||
user_id=str(fb.user_id) if fb.user_id else None,
|
||||
text=fb.text,
|
||||
page_url=fb.page_url,
|
||||
status=fb.status,
|
||||
created_at=fb.created_at,
|
||||
updated_at=fb.updated_at,
|
||||
)
|
||||
@@ -0,0 +1,240 @@
|
||||
"""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,
|
||||
IdeaAnalyzeResponse,
|
||||
IdeaCreate,
|
||||
IdeaResponse,
|
||||
IdeaUpdate,
|
||||
)
|
||||
from app.services.analysis_service import AnalysisService
|
||||
from app.services.export_service import export_content
|
||||
from app.services.idea_service import IdeaService
|
||||
|
||||
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}/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("/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)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Sync API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.sync import SyncPullRequest, SyncPushRequest, SyncResponse
|
||||
from app.services.sync_service import SyncService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/pull", response_model=SyncResponse)
|
||||
async def pull(
|
||||
body: SyncPullRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = SyncService(db)
|
||||
return await service.pull(user, last_sync=body.last_sync)
|
||||
|
||||
|
||||
@router.post("/push", response_model=SyncResponse)
|
||||
async def push(
|
||||
body: SyncPushRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = SyncService(db)
|
||||
return await service.push(user, device_id=body.device_id, changes=body.changes)
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Public tariff API routes for VoIdea."""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_db
|
||||
from app.schemas.tariff import TariffPlanResponse
|
||||
from app.services.tariff_service import TariffService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("", response_model=list[TariffPlanResponse])
|
||||
async def list_tariffs(db: AsyncSession = Depends(get_db)):
|
||||
"""Return all active tariff plans (public, no auth required)."""
|
||||
service = TariffService(db)
|
||||
plans = await service.list_plans(active_only=True)
|
||||
return [
|
||||
TariffPlanResponse(
|
||||
id=str(p.id),
|
||||
name=p.name,
|
||||
code=p.code,
|
||||
description=p.description,
|
||||
price_monthly=p.price_monthly,
|
||||
price_yearly=p.price_yearly,
|
||||
features=p.features,
|
||||
is_active=p.is_active,
|
||||
sort_order=p.sort_order,
|
||||
created_at=p.created_at,
|
||||
updated_at=p.updated_at,
|
||||
)
|
||||
for p in plans
|
||||
]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Users API routes for VoIdea."""
|
||||
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.user import SubscriptionInfo, UserResponse, UserUpdate, VoiceSettingsResponse, VoiceSettingsUpdate
|
||||
from app.services.tariff_service import TariffService
|
||||
from app.services.user_service import UserService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_to_response(user: User) -> UserResponse:
|
||||
return UserResponse(
|
||||
id=str(user.id),
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
avatar_url=user.avatar_url,
|
||||
is_active=user.is_active,
|
||||
is_superuser=user.is_superuser,
|
||||
oauth_provider=user.oauth_provider,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(user: Annotated[User, Depends(get_current_user)]):
|
||||
return _user_to_response(user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
body: UserUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
result = await service.update_profile(
|
||||
str(user.id),
|
||||
display_name=body.display_name,
|
||||
avatar_url=body.avatar_url,
|
||||
)
|
||||
if not result:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
||||
return _user_to_response(result)
|
||||
|
||||
|
||||
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_me(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
service = UserService(db)
|
||||
await service.delete(str(user.id))
|
||||
|
||||
|
||||
VOICE_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"standard": {
|
||||
"vad_noise_threshold": 0.3,
|
||||
"vad_silence_timeout_ms": 1500,
|
||||
"confidence_verified": 80,
|
||||
"confidence_warning": 50,
|
||||
"semantic_mode": "fast",
|
||||
"tts_enabled": True,
|
||||
"continuous_listening": False,
|
||||
},
|
||||
"precise": {
|
||||
"vad_noise_threshold": 0.2,
|
||||
"vad_silence_timeout_ms": 2000,
|
||||
"confidence_verified": 85,
|
||||
"confidence_warning": 60,
|
||||
"semantic_mode": "full",
|
||||
"tts_enabled": True,
|
||||
"continuous_listening": False,
|
||||
},
|
||||
"fast": {
|
||||
"vad_noise_threshold": 0.4,
|
||||
"vad_silence_timeout_ms": 1000,
|
||||
"confidence_verified": 70,
|
||||
"confidence_warning": 40,
|
||||
"semantic_mode": "fast",
|
||||
"tts_enabled": False,
|
||||
"continuous_listening": True,
|
||||
},
|
||||
"quiet": {
|
||||
"vad_noise_threshold": 0.6,
|
||||
"vad_silence_timeout_ms": 3000,
|
||||
"confidence_verified": 80,
|
||||
"confidence_warning": 50,
|
||||
"semantic_mode": "fast",
|
||||
"tts_enabled": True,
|
||||
"continuous_listening": False,
|
||||
},
|
||||
"expert": {
|
||||
"vad_noise_threshold": 0.15,
|
||||
"vad_silence_timeout_ms": 1000,
|
||||
"confidence_verified": 90,
|
||||
"confidence_warning": 70,
|
||||
"semantic_mode": "full",
|
||||
"tts_enabled": True,
|
||||
"continuous_listening": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/me/voice-settings", response_model=VoiceSettingsResponse)
|
||||
async def get_voice_settings(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
):
|
||||
tuning = user.pipeline_tuning or {}
|
||||
preset = tuning.get("preset", "standard")
|
||||
overrides = tuning.get("overrides", {})
|
||||
return VoiceSettingsResponse(
|
||||
preset=preset,
|
||||
overrides=overrides,
|
||||
available_presets=list(VOICE_PRESETS.keys()),
|
||||
preset_values=VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"]),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me/voice-settings", response_model=VoiceSettingsResponse)
|
||||
async def update_voice_settings(
|
||||
body: VoiceSettingsUpdate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from sqlalchemy import select
|
||||
|
||||
tuning = user.pipeline_tuning or {}
|
||||
|
||||
if body.reset:
|
||||
tuning = {"preset": "standard", "overrides": {}}
|
||||
else:
|
||||
if body.preset is not None:
|
||||
if body.preset not in VOICE_PRESETS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Unknown preset: {body.preset}. Available: {', '.join(VOICE_PRESETS.keys())}",
|
||||
)
|
||||
tuning["preset"] = body.preset
|
||||
|
||||
if body.overrides is not None:
|
||||
existing_overrides = tuning.get("overrides", {})
|
||||
existing_overrides.update(body.overrides)
|
||||
tuning["overrides"] = existing_overrides
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user.id))
|
||||
db_user = result.scalar_one()
|
||||
db_user.pipeline_tuning = tuning
|
||||
await db.commit()
|
||||
|
||||
preset = tuning.get("preset", "standard")
|
||||
preset_vals = VOICE_PRESETS.get(preset, VOICE_PRESETS["standard"])
|
||||
overrides = tuning.get("overrides", {})
|
||||
merged = {**preset_vals, **overrides}
|
||||
|
||||
return VoiceSettingsResponse(
|
||||
preset=preset,
|
||||
overrides=overrides,
|
||||
available_presets=list(VOICE_PRESETS.keys()),
|
||||
preset_values=merged,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me/subscription", response_model=SubscriptionInfo)
|
||||
async def get_my_subscription(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = TariffService(db)
|
||||
sub = await svc.get_user_subscription(user.id)
|
||||
if not sub:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Subscription not found"
|
||||
)
|
||||
plan = await svc.get_plan_by_id(sub.plan_id)
|
||||
return SubscriptionInfo(
|
||||
plan_name=plan.name if plan else "Бесплатно",
|
||||
plan_code=plan.code if plan else "free",
|
||||
status=sub.status,
|
||||
expires_at=sub.current_period_end,
|
||||
features=plan.features if plan else None,
|
||||
)
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Voice transcription and chat API routes for VoIdeaAI."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
import asyncio
|
||||
|
||||
from app.agents.conductor_agent import ConductorAgent
|
||||
from app.agents.conductor_storage import rate_interaction, get_session_history
|
||||
from app.agents.role_agents import ALL_ROLE_AGENTS
|
||||
from app.core.dependencies import get_current_user, get_db
|
||||
from app.models.conductor import ConductorInteraction
|
||||
from app.models.user import User
|
||||
from app.schemas.voice import (
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
CommandCreate,
|
||||
CommandResponse,
|
||||
CreateSessionRequest,
|
||||
RateRequest,
|
||||
SaveIdeaRequest,
|
||||
SaveIdeaResponse,
|
||||
SessionResponse,
|
||||
)
|
||||
from app.services.command_service import (
|
||||
create_command,
|
||||
delete_command,
|
||||
list_commands,
|
||||
)
|
||||
from app.services.idea_service import IdeaService
|
||||
from app.services.session_service import (
|
||||
create_session,
|
||||
delete_session,
|
||||
get_session,
|
||||
list_sessions,
|
||||
update_session_idea,
|
||||
)
|
||||
from app.services.punctuation_service import restore_punctuation
|
||||
from app.services.whisper_service import transcribe
|
||||
|
||||
router = APIRouter()
|
||||
_conductor = ConductorAgent()
|
||||
|
||||
|
||||
@router.get("/stream/{interaction_id}")
|
||||
async def stream_response(
|
||||
interaction_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""SSE endpoint that streams the response text for a given interaction in chunks."""
|
||||
from sqlalchemy import select
|
||||
|
||||
result = await db.execute(
|
||||
select(ConductorInteraction).where(ConductorInteraction.id == interaction_id)
|
||||
)
|
||||
interaction = result.scalar_one_or_none()
|
||||
if not interaction:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Interaction not found")
|
||||
if str(interaction.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
response_text = interaction.response_text or ""
|
||||
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
async def event_stream():
|
||||
chunk_size = 50
|
||||
for i in range(0, len(response_text), chunk_size):
|
||||
chunk = response_text[i:i + chunk_size]
|
||||
yield f"data: {chunk}\n\n"
|
||||
await asyncio.sleep(0.02)
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_stream(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/transcribe")
|
||||
async def transcribe_audio(file: UploadFile):
|
||||
audio_data = await file.read()
|
||||
if not audio_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Empty audio file",
|
||||
)
|
||||
|
||||
text = await transcribe(audio_data, file.filename or "audio.webm")
|
||||
if not text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Transcription failed. Check AI provider API key.",
|
||||
)
|
||||
|
||||
text = restore_punctuation(text)
|
||||
|
||||
return {"text": text}
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
async def chat(
|
||||
body: ChatRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await _conductor.process(
|
||||
user_input=body.text,
|
||||
db=db,
|
||||
user_id=str(user.id),
|
||||
session_id=body.session_id,
|
||||
vad_enabled=body.vad_enabled,
|
||||
wake_word_detected=body.wake_word_detected,
|
||||
audio_duration_ms=body.audio_duration_ms,
|
||||
pipeline_mode=body.pipeline_mode,
|
||||
)
|
||||
return ChatResponse(**result)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
async def list_user_sessions(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
status_filter: str | None = None,
|
||||
):
|
||||
sessions = await list_sessions(db, str(user.id), status=status_filter)
|
||||
return [
|
||||
SessionResponse(
|
||||
id=str(s.id),
|
||||
title=s.title,
|
||||
status=s.status,
|
||||
idea_id=str(s.idea_id) if s.idea_id else None,
|
||||
created_at=s.created_at,
|
||||
updated_at=s.updated_at,
|
||||
)
|
||||
for s in sessions
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=SessionResponse)
|
||||
async def get_session_detail(
|
||||
session_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
session = await get_session(db, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if str(session.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return SessionResponse(
|
||||
id=str(session.id),
|
||||
title=session.title,
|
||||
status=session.status,
|
||||
idea_id=str(session.idea_id) if session.idea_id else None,
|
||||
created_at=session.created_at,
|
||||
updated_at=session.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/history")
|
||||
async def get_session_history_endpoint(
|
||||
session_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
session = await get_session(db, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if str(session.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
return await get_session_history(db, session_id)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_user_session(
|
||||
session_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
session = await get_session(db, session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if str(session.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
ok = await delete_session(db, session_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/rate")
|
||||
async def rate(
|
||||
body: RateRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
success = await rate_interaction(db, body.interaction_id, body.rating)
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Interaction not found",
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/save-idea", response_model=SaveIdeaResponse)
|
||||
async def save_idea(
|
||||
body: SaveIdeaRequest,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
session = await get_session(db, body.session_id)
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if str(session.user_id) != str(user.id):
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
history = await get_session_history(db, body.session_id)
|
||||
title = session.title or "Новое обсуждение"
|
||||
|
||||
dialogue_lines = [
|
||||
f"Пользователь: {h['input']}\n{ h['agent']}: {h['response']}"
|
||||
for h in history
|
||||
]
|
||||
content = "\n\n".join(dialogue_lines) if dialogue_lines else title
|
||||
|
||||
idea_service = IdeaService(db)
|
||||
tags = ["voice", "ai-assisted"]
|
||||
idea = await idea_service.create(
|
||||
user_id=str(user.id),
|
||||
title=title[:255],
|
||||
content=content,
|
||||
tags=tags,
|
||||
is_public=False,
|
||||
)
|
||||
ok = await update_session_idea(db, body.session_id, str(idea.id))
|
||||
|
||||
return SaveIdeaResponse(
|
||||
idea_id=str(idea.id),
|
||||
title=idea.title,
|
||||
exported=False,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/commands", response_model=list[CommandResponse])
|
||||
async def list_user_commands(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await list_commands(db, str(user.id))
|
||||
|
||||
|
||||
@router.post("/commands", response_model=CommandResponse)
|
||||
async def create_user_command(
|
||||
body: CommandCreate,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
return await create_command(
|
||||
db, str(user.id), body.phrase, body.action, body.agent_name
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/commands/{command_id}")
|
||||
async def delete_user_command(
|
||||
command_id: str,
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
ok = await delete_command(db, command_id, str(user.id))
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Command not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/agents", response_model=list[dict])
|
||||
async def list_role_agents():
|
||||
return [
|
||||
{"name": a.name, "description": a.description}
|
||||
for a in ALL_ROLE_AGENTS
|
||||
]
|
||||
Reference in New Issue
Block a user