Initial commit: VoIdeaAI - voice-first AI idea assistant

This commit is contained in:
2026-05-13 12:51:42 +03:00
commit 688d043dad
421 changed files with 47915 additions and 0 deletions
+839
View File
@@ -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,
)