869 lines
27 KiB
Python
869 lines
27 KiB
Python
"""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.agents.base import AgentResult
|
|
from app.schemas.agent import AgentRunRequest
|
|
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,
|
|
config=a.config,
|
|
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
|
|
if body.config is not None:
|
|
agent.config = body.config
|
|
|
|
await db.commit()
|
|
await db.refresh(agent)
|
|
return _agent_to_response(agent)
|
|
|
|
|
|
@router.post("/agents/{agent_name}/run", response_model=AgentResult)
|
|
async def run_agent(
|
|
agent_name: str,
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
body: AgentRunRequest | None = None,
|
|
):
|
|
from app.agents.registry import registry
|
|
|
|
context = body.model_dump() if body else {}
|
|
result = await registry.run_agent(agent_name, context)
|
|
return result
|
|
|
|
|
|
@router.post("/agents/run-all", response_model=dict[str, AgentResult])
|
|
async def run_all_agents(
|
|
user: Annotated[User, Depends(get_current_user)],
|
|
body: AgentRunRequest | None = None,
|
|
):
|
|
from app.agents.registry import registry
|
|
|
|
context = body.model_dump() if body else {}
|
|
return await registry.run_all(context)
|
|
|
|
|
|
# ── 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,
|
|
)
|