"""Маршруты административного API для просмотра логов.""" from __future__ import annotations import logging from datetime import UTC, datetime from pathlib import Path from typing import Any 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, SystemLogFullResponse, SystemLogPreviewResponse, ) 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, float | None]: 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: float | None) -> datetime | None: if timestamp is None: return None return datetime.fromtimestamp(timestamp, tz=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: str | None = 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: str | None = 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)