Merge branch 'main' of https://github.com/Gy9vin/remnawave-bedolaga-telegram-bot
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Admin routes for managing users in cabinet."""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
@@ -32,6 +32,7 @@ from app.database.models import (
|
||||
User,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_admin_user
|
||||
from ..schemas.users import (
|
||||
@@ -1372,12 +1373,8 @@ async def get_user_sync_status(
|
||||
bot_end_utc = (
|
||||
bot_sub_end_date.replace(tzinfo=None) if bot_sub_end_date.tzinfo else bot_sub_end_date
|
||||
)
|
||||
# Panel dates might be timezone-aware, convert to UTC first
|
||||
if panel_expire_at.tzinfo:
|
||||
panel_end_utc = panel_expire_at.astimezone(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
# Panel might return naive datetime in MSK (UTC+3), try both interpretations
|
||||
panel_end_utc = panel_expire_at
|
||||
# Panel returns local time with misleading +00:00 offset
|
||||
panel_end_utc = panel_datetime_to_naive_utc(panel_expire_at)
|
||||
|
||||
diff_seconds = abs((bot_end_utc - panel_end_utc).total_seconds())
|
||||
# Allow for timezone offset (3 hours = MSK) and small sync delays
|
||||
@@ -1508,7 +1505,7 @@ async def sync_user_from_panel(
|
||||
short_uuid=panel_user.short_uuid,
|
||||
username=panel_user.username,
|
||||
status=panel_user.status.value if panel_user.status else None,
|
||||
expire_at=panel_user.expire_at,
|
||||
expire_at=panel_datetime_to_naive_utc(panel_user.expire_at) if panel_user.expire_at else None,
|
||||
traffic_limit_gb=panel_user.traffic_limit_bytes / (1024**3) if panel_user.traffic_limit_bytes else 0,
|
||||
traffic_used_gb=panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes else 0,
|
||||
device_limit=panel_user.hwid_device_limit or 1,
|
||||
@@ -1527,11 +1524,8 @@ async def sync_user_from_panel(
|
||||
|
||||
# Update end date (normalize timezone)
|
||||
if panel_user.expire_at:
|
||||
# Convert panel expire_at to naive UTC for storage
|
||||
if panel_user.expire_at.tzinfo:
|
||||
panel_expire_utc = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
panel_expire_utc = panel_user.expire_at
|
||||
# Panel returns local time with misleading +00:00 offset
|
||||
panel_expire_utc = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
|
||||
sub_end_naive = (
|
||||
sub.end_date.replace(tzinfo=None) if sub.end_date and sub.end_date.tzinfo else sub.end_date
|
||||
@@ -1602,11 +1596,8 @@ async def sync_user_from_panel(
|
||||
panel_traffic_limit = (
|
||||
int(panel_user.traffic_limit_bytes / (1024**3)) if panel_user.traffic_limit_bytes else 100
|
||||
)
|
||||
# Normalize panel expire date for calculation
|
||||
if panel_user.expire_at.tzinfo:
|
||||
panel_expire_naive = panel_user.expire_at.astimezone(UTC).replace(tzinfo=None)
|
||||
else:
|
||||
panel_expire_naive = panel_user.expire_at
|
||||
# Panel returns local time with misleading +00:00 offset
|
||||
panel_expire_naive = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
days_remaining = max(1, (panel_expire_naive - datetime.utcnow()).days)
|
||||
|
||||
new_sub = await create_paid_subscription(
|
||||
|
||||
+10
-17
@@ -19,6 +19,7 @@ from app.database.crud.user import (
|
||||
)
|
||||
from app.database.models import CabinetRefreshToken, User
|
||||
from app.services.referral_service import process_referral_registration
|
||||
from app.utils.timezone import panel_datetime_to_naive_utc
|
||||
|
||||
from ..auth import (
|
||||
create_access_token,
|
||||
@@ -162,8 +163,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
|
||||
existing_sub = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
# Parse panel data
|
||||
expire_at = panel_user.expire_at
|
||||
# Parse panel data — panel returns local time with misleading +00:00 offset
|
||||
expire_at = panel_datetime_to_naive_utc(panel_user.expire_at)
|
||||
traffic_limit_gb = panel_user.traffic_limit_bytes // (1024**3) if panel_user.traffic_limit_bytes > 0 else 0
|
||||
traffic_used_gb = panel_user.used_traffic_bytes / (1024**3) if panel_user.used_traffic_bytes > 0 else 0
|
||||
|
||||
@@ -173,11 +174,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
# Device limit from panel
|
||||
device_limit = panel_user.hwid_device_limit or 1
|
||||
|
||||
# Determine status - use timezone-aware datetime for comparison
|
||||
current_time = datetime.now(UTC)
|
||||
# Make expire_at timezone-aware if it's naive
|
||||
if expire_at.tzinfo is None:
|
||||
expire_at = expire_at.replace(tzinfo=UTC)
|
||||
# Determine status — expire_at is now naive UTC
|
||||
current_time = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
if panel_user.status.value == 'ACTIVE' and expire_at > current_time:
|
||||
sub_status = SubscriptionStatus.ACTIVE
|
||||
@@ -187,10 +185,8 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
sub_status = SubscriptionStatus.DISABLED
|
||||
|
||||
if existing_sub:
|
||||
# Update existing subscription
|
||||
# Convert to naive datetime for database storage
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
existing_sub.end_date = end_date_naive
|
||||
# Update existing subscription (expire_at already naive UTC)
|
||||
existing_sub.end_date = expire_at
|
||||
existing_sub.traffic_limit_gb = traffic_limit_gb
|
||||
existing_sub.traffic_used_gb = traffic_used_gb
|
||||
existing_sub.status = sub_status.value
|
||||
@@ -204,14 +200,11 @@ async def _sync_subscription_from_panel_by_email(db: AsyncSession, user: User) -
|
||||
f'Updated subscription for email user {user.email}, squads: {connected_squads}, devices: {device_limit}'
|
||||
)
|
||||
else:
|
||||
# Create new subscription
|
||||
# Convert current_time to naive for database storage if needed
|
||||
start_date_naive = current_time.replace(tzinfo=None)
|
||||
end_date_naive = expire_at.replace(tzinfo=None) if expire_at.tzinfo else expire_at
|
||||
# Create new subscription (expire_at and current_time already naive UTC)
|
||||
new_sub = Subscription(
|
||||
user_id=user.id,
|
||||
start_date=start_date_naive,
|
||||
end_date=end_date_naive,
|
||||
start_date=current_time,
|
||||
end_date=expire_at,
|
||||
traffic_limit_gb=traffic_limit_gb,
|
||||
traffic_used_gb=traffic_used_gb,
|
||||
status=sub_status.value,
|
||||
|
||||
@@ -754,6 +754,7 @@ async def create_topup(
|
||||
user_id=user.id,
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
email=getattr(user, 'email', None),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,6 +109,13 @@ def _get_method_defaults() -> dict:
|
||||
{'id': 'sbp', 'name': 'СБП'},
|
||||
],
|
||||
},
|
||||
'kassa_ai': {
|
||||
'default_display_name': settings.get_kassa_ai_display_name(),
|
||||
'is_configured': settings.is_kassa_ai_enabled(),
|
||||
'default_min': settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
|
||||
'default_max': settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
|
||||
'available_sub_options': None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +153,7 @@ DEFAULT_METHOD_ORDER = [
|
||||
'wata',
|
||||
'freekassa',
|
||||
'cloudpayments',
|
||||
'kassa_ai',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -253,10 +253,9 @@ class RemnaWaveService:
|
||||
|
||||
parsed_date = datetime.fromisoformat(cleaned_date)
|
||||
|
||||
if parsed_date.tzinfo is not None:
|
||||
localized = parsed_date.astimezone(self._panel_timezone)
|
||||
else:
|
||||
localized = parsed_date.replace(tzinfo=self._panel_timezone)
|
||||
# Убираем tzinfo и интерпретируем время как локальное время панели
|
||||
naive_date = parsed_date.replace(tzinfo=None)
|
||||
localized = naive_date.replace(tzinfo=self._panel_timezone)
|
||||
|
||||
utc_normalized = localized.astimezone(self._utc_timezone).replace(tzinfo=None)
|
||||
|
||||
@@ -268,27 +267,34 @@ class RemnaWaveService:
|
||||
return self._now_utc() + timedelta(days=30)
|
||||
|
||||
def _safe_expire_at_for_panel(self, expire_at: datetime | None) -> datetime:
|
||||
"""Гарантирует, что дата окончания не в прошлом для панели."""
|
||||
"""Гарантирует, что дата окончания не в прошлом для панели.
|
||||
|
||||
Принимает naive UTC datetime, возвращает naive datetime в таймзоне панели.
|
||||
"""
|
||||
|
||||
now = self._now_utc()
|
||||
minimum_expire = now + timedelta(minutes=1)
|
||||
|
||||
if not expire_at:
|
||||
return minimum_expire
|
||||
result = minimum_expire
|
||||
else:
|
||||
normalized_expire = expire_at
|
||||
if normalized_expire.tzinfo is not None:
|
||||
normalized_expire = normalized_expire.replace(tzinfo=None)
|
||||
|
||||
normalized_expire = expire_at
|
||||
if normalized_expire.tzinfo is not None:
|
||||
normalized_expire = normalized_expire.replace(tzinfo=None)
|
||||
if normalized_expire < minimum_expire:
|
||||
logger.debug(
|
||||
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
|
||||
normalized_expire,
|
||||
minimum_expire,
|
||||
)
|
||||
result = minimum_expire
|
||||
else:
|
||||
result = normalized_expire
|
||||
|
||||
if normalized_expire < minimum_expire:
|
||||
logger.debug(
|
||||
'⚙️ Коррекция даты истечения (%s) до минимально допустимой (%s) для панели',
|
||||
normalized_expire,
|
||||
minimum_expire,
|
||||
)
|
||||
return minimum_expire
|
||||
|
||||
return normalized_expire
|
||||
# Конвертируем из naive UTC в локальное время панели (naive)
|
||||
utc_aware = result.replace(tzinfo=self._utc_timezone)
|
||||
return utc_aware.astimezone(self._panel_timezone).replace(tzinfo=None)
|
||||
|
||||
def _safe_panel_expire_date(self, panel_user: dict[str, Any]) -> datetime:
|
||||
"""Парсит дату окончания подписки пользователя панели для сравнения."""
|
||||
@@ -1139,7 +1145,7 @@ class RemnaWaveService:
|
||||
'status': user_obj.status.value,
|
||||
'telegramId': user_obj.telegram_id,
|
||||
'email': user_obj.email, # Email для синхронизации email-only пользователей
|
||||
'expireAt': user_obj.expire_at.isoformat() + 'Z',
|
||||
'expireAt': user_obj.expire_at.replace(tzinfo=None).isoformat(),
|
||||
'trafficLimitBytes': user_obj.traffic_limit_bytes,
|
||||
'usedTrafficBytes': user_obj.used_traffic_bytes,
|
||||
'hwidDeviceLimit': user_obj.hwid_device_limit,
|
||||
@@ -1283,20 +1289,24 @@ class RemnaWaveService:
|
||||
logger.info(f'🔄 Обновлены поля {updated_fields} для пользователя {telegram_id}')
|
||||
await db.flush() # Сохраняем изменения без коммита
|
||||
|
||||
# Проверяем, есть ли у пользователя подписка, загруженная с пользователем
|
||||
if hasattr(db_user, 'subscription') and db_user.subscription:
|
||||
# Используем уже загруженную подписку
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
# Если подписки нет, создаем новую
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
# Обновляем UUID ДО операций с подпиской, чтобы избежать
|
||||
# greenlet_spawn ошибки при доступе к атрибутам после flush
|
||||
_, uuid_mutation = self._ensure_user_remnawave_uuid(
|
||||
db_user,
|
||||
panel_user.get('uuid'),
|
||||
bot_users_by_uuid,
|
||||
)
|
||||
|
||||
# Используем async запрос вместо доступа к relationship,
|
||||
# чтобы избежать lazy-load в async контексте
|
||||
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub
|
||||
|
||||
existing_sub = await _get_sub(db, db_user.id)
|
||||
if existing_sub:
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
|
||||
stats['updated'] += 1
|
||||
logger.debug(f'✅ Обновлён пользователь {telegram_id}')
|
||||
|
||||
@@ -1369,8 +1379,12 @@ class RemnaWaveService:
|
||||
if panel_uuid and not db_user.remnawave_uuid:
|
||||
db_user.remnawave_uuid = panel_uuid
|
||||
|
||||
# Обновляем или создаем подписку
|
||||
if hasattr(db_user, 'subscription') and db_user.subscription:
|
||||
# Используем async запрос вместо доступа к relationship,
|
||||
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
|
||||
from app.database.crud.subscription import get_subscription_by_user_id as _get_sub_email
|
||||
|
||||
existing_sub = await _get_sub_email(db, db_user.id)
|
||||
if existing_sub:
|
||||
await self._update_subscription_from_panel_data(db, db_user, panel_user)
|
||||
else:
|
||||
await self._create_subscription_from_panel_data(db, db_user, panel_user)
|
||||
@@ -1637,18 +1651,9 @@ class RemnaWaveService:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Сначала пытаемся использовать уже загруженную подписку, если она есть
|
||||
subscription = None
|
||||
try:
|
||||
# Проверяем, что подписка уже загружена (была загружена через selectinload)
|
||||
if hasattr(user, 'subscription') and user.subscription:
|
||||
subscription = user.subscription
|
||||
else:
|
||||
# В противном случае, получаем подписку через CRUD метод
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
except:
|
||||
# Если не удалось получить подписку через ленивую загрузку
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
# Всегда используем async CRUD запрос для получения подписки,
|
||||
# чтобы избежать lazy-load (greenlet_spawn) в async контексте
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
|
||||
if not subscription:
|
||||
await self._create_subscription_from_panel_data(db, user, panel_user)
|
||||
|
||||
@@ -34,6 +34,18 @@ def get_local_timezone() -> ZoneInfo:
|
||||
return ZoneInfo('UTC')
|
||||
|
||||
|
||||
def panel_datetime_to_naive_utc(dt: datetime) -> datetime:
|
||||
"""Convert a panel datetime to naive UTC.
|
||||
|
||||
Panel API returns local time with a misleading UTC offset (+00:00 / Z).
|
||||
This strips the offset, interprets the raw value as panel-local time,
|
||||
then converts to naive UTC for database storage.
|
||||
"""
|
||||
naive = dt.replace(tzinfo=None)
|
||||
localized = naive.replace(tzinfo=get_local_timezone())
|
||||
return localized.astimezone(ZoneInfo('UTC')).replace(tzinfo=None)
|
||||
|
||||
|
||||
def to_local_datetime(dt: datetime | None) -> datetime | None:
|
||||
"""Convert a datetime value to the configured local timezone."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user