Initial commit: VoIdeaAI - voice-first AI idea assistant
This commit is contained in:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user