Files
Fringg eb18994b7d fix: complete datetime.utcnow() → datetime.now(UTC) migration
- Migrate 660+ datetime.utcnow() across 153 files to datetime.now(UTC)
- Migrate 30+ datetime.now() without UTC to datetime.now(UTC)
- Convert all 170 DateTime columns to DateTime(timezone=True)
- Add migrate_datetime_to_timestamptz() in universal_migration with SET LOCAL timezone='UTC' safety
- Remove 70+ .replace(tzinfo=None) workarounds
- Fix utcfromtimestamp → fromtimestamp(..., tz=UTC)
- Fix fromtimestamp() without tz= (system_logs, backup_service, referral_diagnostics)
- Fix fromisoformat/isoparse to ensure aware output (platega, yookassa, wata, miniapp, nalogo)
- Fix strptime() to add .replace(tzinfo=UTC) (backup_service, referral_diagnostics)
- Fix datetime.combine() to include tzinfo=UTC (remnawave_sync, traffic_monitoring)
- Fix datetime.max/datetime.min sentinels with .replace(tzinfo=UTC)
- Rename panel_datetime_to_naive_utc → panel_datetime_to_utc
- Remove DTZ003 from ruff ignore list
2026-02-17 04:45:40 +03:00

280 lines
10 KiB
Python

import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import UTC, datetime, time, timedelta
from typing import Any
import structlog
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.server_squad import sync_with_remnawave
from app.database.database import AsyncSessionLocal
from app.services.remnawave_service import (
RemnaWaveConfigurationError,
RemnaWaveService,
)
from app.utils.cache import cache
logger = structlog.get_logger(__name__)
@dataclass(frozen=True)
class RemnaWaveAutoSyncStatus:
enabled: bool
times: list[time]
next_run: datetime | None
last_run_started_at: datetime | None
last_run_finished_at: datetime | None
last_run_success: bool | None
last_run_reason: str | None
last_run_error: str | None
last_user_stats: dict[str, Any] | None
last_server_stats: dict[str, Any] | None
is_running: bool
class RemnaWaveAutoSyncService:
def __init__(
self,
service_factory: Callable[[], RemnaWaveService] = RemnaWaveService,
) -> None:
self._scheduler_task: asyncio.Task | None = None
self._scheduler_lock = asyncio.Lock()
self._sync_lock = asyncio.Lock()
self._service_factory = service_factory
self._service = self._service_factory()
self._loop: asyncio.AbstractEventLoop | None = None
self._initialized = False
self._pending_refresh = False
self._pending_run_immediately = False
self._next_run: datetime | None = None
self._last_run_started_at: datetime | None = None
self._last_run_finished_at: datetime | None = None
self._last_run_success: bool | None = None
self._last_run_reason: str | None = None
self._last_run_error: str | None = None
self._last_user_stats: dict[str, Any] | None = None
self._last_server_stats: dict[str, Any] | None = None
async def initialize(self) -> None:
self._loop = asyncio.get_running_loop()
self._initialized = True
run_immediately = self._pending_run_immediately
if self._pending_refresh:
self._pending_refresh = False
self._pending_run_immediately = False
await self.refresh_schedule(run_immediately=run_immediately)
else:
await self.refresh_schedule()
def refresh_configuration(self) -> None:
"""Rebuild the RemnaWave service instance using current settings."""
self._refresh_service()
async def refresh_schedule(self, *, run_immediately: bool = False) -> None:
async with self._scheduler_lock:
if self._scheduler_task and not self._scheduler_task.done():
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
finally:
self._scheduler_task = None
if not settings.REMNAWAVE_AUTO_SYNC_ENABLED:
self._next_run = None
return
times = settings.get_remnawave_auto_sync_times()
if not times:
logger.warning('⚠️ Автосинхронизация включена, но расписание пустое. Укажите время запуска.')
self._next_run = None
return
self._scheduler_task = asyncio.create_task(self._run_scheduler(times))
if run_immediately:
asyncio.create_task(self.run_sync_now(reason='immediate'))
def schedule_refresh(self, *, run_immediately: bool = False) -> None:
if not self._initialized:
self._pending_refresh = True
if run_immediately:
self._pending_run_immediately = True
return
loop = self._loop or asyncio.get_running_loop()
loop.create_task(self.refresh_schedule(run_immediately=run_immediately))
async def stop(self) -> None:
async with self._scheduler_lock:
if self._scheduler_task and not self._scheduler_task.done():
self._scheduler_task.cancel()
try:
await self._scheduler_task
except asyncio.CancelledError:
pass
self._scheduler_task = None
self._next_run = None
async def run_sync_now(self, *, reason: str = 'manual') -> dict[str, Any]:
if self._sync_lock.locked():
return {'started': False, 'reason': 'already_running'}
async with self._sync_lock:
self._last_run_started_at = datetime.now(UTC)
self._last_run_finished_at = None
self._last_run_reason = reason
self._last_run_error = None
self._last_run_success = None
try:
user_stats, server_stats = await self._perform_sync()
except RemnaWaveConfigurationError as error:
message = str(error)
self._last_run_error = message
self._last_run_success = False
self._last_user_stats = None
self._last_server_stats = None
self._last_run_finished_at = datetime.now(UTC)
logger.error('❌ Автосинхронизация RemnaWave', message=message)
return {
'started': True,
'success': False,
'error': message,
'user_stats': None,
'server_stats': None,
}
except Exception as error:
message = str(error)
self._last_run_error = message
self._last_run_success = False
self._last_user_stats = None
self._last_server_stats = None
self._last_run_finished_at = datetime.now(UTC)
logger.exception('❌ Ошибка автосинхронизации RemnaWave', error=error)
return {
'started': True,
'success': False,
'error': message,
'user_stats': None,
'server_stats': None,
}
self._last_run_success = True
self._last_run_error = None
self._last_user_stats = user_stats
self._last_server_stats = server_stats
self._last_run_finished_at = datetime.now(UTC)
return {
'started': True,
'success': True,
'error': None,
'user_stats': user_stats,
'server_stats': server_stats,
}
def get_status(self) -> RemnaWaveAutoSyncStatus:
times = settings.get_remnawave_auto_sync_times()
enabled = settings.REMNAWAVE_AUTO_SYNC_ENABLED and bool(times)
return RemnaWaveAutoSyncStatus(
enabled=enabled,
times=times,
next_run=self._next_run,
last_run_started_at=self._last_run_started_at,
last_run_finished_at=self._last_run_finished_at,
last_run_success=self._last_run_success,
last_run_reason=self._last_run_reason,
last_run_error=self._last_run_error,
last_user_stats=self._last_user_stats,
last_server_stats=self._last_server_stats,
is_running=self._sync_lock.locked(),
)
async def _run_scheduler(self, times: list[time]) -> None:
try:
while True:
next_run = self._calculate_next_run(times)
self._next_run = next_run
delay = (next_run - datetime.now(UTC)).total_seconds()
if delay > 0:
await asyncio.sleep(delay)
await self.run_sync_now(reason='auto')
except asyncio.CancelledError:
raise
finally:
self._next_run = None
def _refresh_service(self) -> RemnaWaveService:
self._service = self._service_factory()
return self._service
async def _perform_sync(self) -> tuple[dict[str, Any], dict[str, Any]]:
service = self._refresh_service()
if not service.is_configured:
raise RemnaWaveConfigurationError(service.configuration_error or 'RemnaWave API не настроен')
async with AsyncSessionLocal() as session:
user_stats = await service.sync_users_from_panel(session, 'all')
server_stats = await self._sync_servers(session, service)
return user_stats, server_stats
async def _sync_servers(
self,
session: AsyncSession,
service: RemnaWaveService,
) -> dict[str, Any]:
squads = await service.get_all_squads()
if not squads:
logger.warning('⚠️ Не удалось получить сквады из RemnaWave для автосинхронизации')
return {'created': 0, 'updated': 0, 'removed': 0, 'total': 0}
created, updated, removed = await sync_with_remnawave(session, squads)
try:
await cache.delete_pattern('available_countries*')
except Exception as error:
logger.warning('⚠️ Не удалось очистить кеш стран после автосинхронизации', error=error)
return {
'created': created,
'updated': updated,
'removed': removed,
'total': len(squads),
}
@staticmethod
def _calculate_next_run(times: list[time]) -> datetime:
now = datetime.now(UTC)
today = now.date()
for scheduled in sorted(times):
candidate = datetime.combine(today, scheduled, tzinfo=UTC)
if candidate > now:
return candidate
first_time = sorted(times)[0]
next_day = today + timedelta(days=1)
return datetime.combine(next_day, first_time, tzinfo=UTC)
def _create_service() -> RemnaWaveAutoSyncService:
service = RemnaWaveAutoSyncService()
return service
remnawave_sync_service = _create_service()