"""Маршруты административного API для просмотра логов.""" from __future__ import annotations import logging from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security from fastapi.concurrency import run_in_threadpool from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.ticket import TicketCRUD from app.services.monitoring_service import monitoring_service from ..dependencies import get_db_session, require_api_token from ..schemas.logs import ( MonitoringLogEntry, MonitoringLogListResponse, MonitoringLogTypeListResponse, SupportAuditActionsResponse, SupportAuditLogEntry, SupportAuditLogListResponse, SystemLogPreviewResponse, SystemLogFullResponse, ) router = APIRouter() logger = logging.getLogger(__name__) SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT = 4000 SYSTEM_LOG_PREVIEW_LIMIT_MAX = 20000 def _resolve_system_log_path() -> Path: path = Path(settings.LOG_FILE) if not path.is_absolute(): path = Path.cwd() / path return path async def _read_system_log(path: Path) -> tuple[str, int, Optional[float]]: def _read() -> tuple[str, int, float]: content = path.read_text(encoding="utf-8", errors="ignore") stats = path.stat() return content, stats.st_size, stats.st_mtime return await run_in_threadpool(_read) def _format_timestamp(timestamp: Optional[float]) -> Optional[datetime]: if timestamp is None: return None return datetime.fromtimestamp(timestamp, tz=timezone.utc) @router.get("/system", response_model=SystemLogPreviewResponse) async def get_system_log_preview( _: Any = Security(require_api_token), preview_limit: int = Query( SYSTEM_LOG_PREVIEW_LIMIT_DEFAULT, ge=500, le=SYSTEM_LOG_PREVIEW_LIMIT_MAX, description="Количество символов предпросмотра от конца файла", ), ) -> SystemLogPreviewResponse: """Получить предпросмотр системного лог-файла бота.""" log_path = _resolve_system_log_path() if not log_path.exists() or not log_path.is_file(): return SystemLogPreviewResponse( path=str(log_path), exists=False, updated_at=None, size_bytes=0, size_chars=0, preview="", preview_chars=0, preview_truncated=False, download_url="/logs/system/download", ) try: content, size_bytes, mtime = await _read_system_log(log_path) except FileNotFoundError: logger.warning("Лог-файл %s исчез во время чтения", log_path) return SystemLogPreviewResponse( path=str(log_path), exists=False, updated_at=None, size_bytes=0, size_chars=0, preview="", preview_chars=0, preview_truncated=False, download_url="/logs/system/download", ) except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error preview_text = content[-preview_limit:] if preview_limit > 0 else "" truncated = len(content) > len(preview_text) return SystemLogPreviewResponse( path=str(log_path), exists=True, updated_at=_format_timestamp(mtime), size_bytes=size_bytes, size_chars=len(content), preview=preview_text, preview_chars=len(preview_text), preview_truncated=truncated, download_url="/logs/system/download", ) @router.get("/system/download") async def download_system_log( _: Any = Security(require_api_token), ) -> FileResponse: """Скачать полный лог-файл бота.""" log_path = _resolve_system_log_path() if not log_path.exists() or not log_path.is_file(): raise HTTPException(status_code=404, detail="Лог-файл не найден") try: return FileResponse( log_path, media_type="text/plain", filename=log_path.name, ) except Exception as error: # pragma: no cover - защита от неожиданных ошибок отдачи файла logger.error("Ошибка отправки лог-файла %s: %s", log_path, error) raise HTTPException(status_code=500, detail="Не удалось отправить лог-файл") from error @router.get("/system/full", response_model=SystemLogFullResponse) async def get_system_log_full( _: Any = Security(require_api_token), ) -> SystemLogFullResponse: """Получить полный системный лог-файл бота.""" log_path = _resolve_system_log_path() if not log_path.exists() or not log_path.is_file(): raise HTTPException(status_code=404, detail="Лог-файл не найден") try: content, size_bytes, mtime = await _read_system_log(log_path) except Exception as error: # pragma: no cover - защита от неожиданных ошибок чтения logger.error("Ошибка чтения лог-файла %s: %s", log_path, error) raise HTTPException(status_code=500, detail="Не удалось прочитать лог-файл") from error return SystemLogFullResponse( path=str(log_path), exists=True, updated_at=_format_timestamp(mtime), size_bytes=size_bytes, size_chars=len(content), content=content, ) @router.get("/monitoring", response_model=MonitoringLogListResponse) async def list_monitoring_logs( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), offset: int = Query(0, ge=0, description="Смещение от начала списка"), event_type: Optional[str] = Query( default=None, max_length=100, description="Фильтр по типу события", ), ) -> MonitoringLogListResponse: """Получить список логов мониторинга с пагинацией.""" per_page = limit page = (offset // per_page) + 1 raw_logs = await monitoring_service.get_monitoring_logs( db, event_type=event_type, page=page, per_page=per_page, ) total = await monitoring_service.get_monitoring_logs_count(db, event_type=event_type) return MonitoringLogListResponse( total=total, limit=limit, offset=offset, items=[MonitoringLogEntry(**entry) for entry in raw_logs], ) @router.get("/monitoring/event-types", response_model=MonitoringLogTypeListResponse) async def list_monitoring_event_types( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> MonitoringLogTypeListResponse: """Получить список доступных типов событий мониторинга.""" event_types = await monitoring_service.get_monitoring_event_types(db) return MonitoringLogTypeListResponse(items=event_types) @router.get("/support", response_model=SupportAuditLogListResponse) async def list_support_audit_logs( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), limit: int = Query(50, ge=1, le=200, description="Количество записей на странице"), offset: int = Query(0, ge=0, description="Смещение от начала списка"), action: Optional[str] = Query( default=None, max_length=50, description="Фильтр по типу действия модератора", ), ) -> SupportAuditLogListResponse: """Получить список аудита действий модераторов поддержки.""" logs = await TicketCRUD.list_support_audit( db, limit=limit, offset=offset, action=action, ) total = await TicketCRUD.count_support_audit(db, action=action) return SupportAuditLogListResponse( total=total, limit=limit, offset=offset, items=[ SupportAuditLogEntry( id=log.id, actor_user_id=log.actor_user_id, actor_telegram_id=log.actor_telegram_id, is_moderator=log.is_moderator, action=log.action, ticket_id=log.ticket_id, target_user_id=log.target_user_id, details=log.details, created_at=log.created_at, ) for log in logs ], ) @router.get("/support/actions", response_model=SupportAuditActionsResponse) async def list_support_audit_actions( _: Any = Security(require_api_token), db: AsyncSession = Depends(get_db_session), ) -> SupportAuditActionsResponse: """Получить список действий, доступных в аудите поддержки.""" actions = await TicketCRUD.list_support_audit_actions(db) return SupportAuditActionsResponse(items=actions)