From 9f140542d973999c9ffb7dca93be1ab79562d778 Mon Sep 17 00:00:00 2001 From: Vladless Date: Wed, 15 Apr 2026 17:42:12 +0000 Subject: [PATCH] Fix configurator price on tariff switch in renewal / Fix datetime naive vs aware across handlers / Redis connection stability --- bot.py | 10 +++++++- core/redis_cache.py | 4 ++++ handlers/admin/sender/sender_utils.py | 8 +++---- handlers/keys/key_renew.py | 23 ++++++++++++++----- handlers/keys/key_view.py | 6 ++--- .../notifications/general_notifications.py | 10 ++++---- handlers/tariffs/buy/key_tariffs.py | 10 +++++--- handlers/utils.py | 6 +++-- middlewares/ban_checker.py | 11 +++++---- services/gifts.py | 6 ++--- 10 files changed, 62 insertions(+), 32 deletions(-) diff --git a/bot.py b/bot.py index 3d37e029..8079ea4c 100644 --- a/bot.py +++ b/bot.py @@ -23,7 +23,15 @@ bot = Bot(token=API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTM RedisStorage = import_module("aiogram.fsm.storage.redis").RedisStorage redis_from_url = import_module("redis.asyncio").from_url -redis = redis_from_url(REDIS_URL, encoding="utf-8", decode_responses=True) +redis = redis_from_url( + REDIS_URL, + encoding="utf-8", + decode_responses=True, + health_check_interval=30, + socket_connect_timeout=5, + socket_timeout=5, + retry_on_timeout=True, +) storage = RedisStorage(redis=redis) dp = Dispatcher(bot=bot, storage=storage) diff --git a/core/redis_cache.py b/core/redis_cache.py index 73f12b53..4eb0e840 100644 --- a/core/redis_cache.py +++ b/core/redis_cache.py @@ -49,6 +49,10 @@ async def _get_redis() -> Any | None: encoding="utf-8", decode_responses=True, max_connections=64, + health_check_interval=30, + socket_connect_timeout=5, + socket_timeout=5, + retry_on_timeout=True, ) await client.ping() _REDIS_CLIENTS[client_key] = client diff --git a/handlers/admin/sender/sender_utils.py b/handlers/admin/sender/sender_utils.py index 76478f59..a925c8e4 100644 --- a/handlers/admin/sender/sender_utils.py +++ b/handlers/admin/sender/sender_utils.py @@ -1,7 +1,7 @@ import json import re -from datetime import datetime +from datetime import datetime, timezone from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from sqlalchemy import and_, distinct, exists, func, not_, select @@ -15,12 +15,12 @@ from logger import logger def _not_banned(user_id_col): return ~exists().where(BlockedUser.user_id == user_id_col) & ~exists().where( ManualBan.user_id == user_id_col, - (ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()), + (ManualBan.until.is_(None)) | (ManualBan.until > datetime.now(timezone.utc)), ) async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str | None = None) -> tuple[list[int], int]: - now_ms = int(datetime.utcnow().timestamp() * 1000) + now_ms = int(datetime.now(timezone.utc).timestamp() * 1000) query = None @@ -54,7 +54,7 @@ async def get_recipients(session: AsyncSession, send_to: str, cluster_name: str ~exists().where(BlockedUser.user_id == unsub_base.c.uid), ~exists().where( ManualBan.user_id == unsub_base.c.uid, - (ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()), + (ManualBan.until.is_(None)) | (ManualBan.until > datetime.now(timezone.utc)), ), ) ) diff --git a/handlers/keys/key_renew.py b/handlers/keys/key_renew.py index f5a8c0fd..e7fe2dba 100644 --- a/handlers/keys/key_renew.py +++ b/handlers/keys/key_renew.py @@ -1,5 +1,5 @@ from collections import defaultdict -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from math import ceil from typing import Any @@ -99,7 +99,7 @@ async def process_callback_renew_key(callback_query: CallbackQuery, state: FSMCo expiry_utc = datetime.utcfromtimestamp(expiry_time / 1000).replace(tzinfo=pytz.UTC) available_from_utc = expiry_utc - timedelta(days=RENEW_BUTTON_BEFORE_DAYS) - now_utc = datetime.utcnow().replace(tzinfo=pytz.UTC) + now_utc = datetime.now(timezone.utc) if now_utc < available_from_utc: dt_msk = available_from_utc.astimezone(moscow_tz).strftime("%d.%m.%Y %H:%M") @@ -434,7 +434,7 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC discount_info = await check_hot_lead_discount(session, tg_id) if tariff.get("group_code") in ["discounts", "discounts_max"]: - if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]: + if not discount_info.get("available") or datetime.now(timezone.utc) >= discount_info["expires_at"]: builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await callback_query.message.answer( @@ -455,7 +455,7 @@ async def process_callback_renew_plan(callback_query: CallbackQuery, state: FSMC email = record["email"] expiry_time_raw = record["expiry_time"] expiry_time = normalize_expiry_ms(expiry_time_raw) - current_time = int(datetime.utcnow().timestamp() * 1000) + current_time = int(datetime.now(timezone.utc).timestamp() * 1000) if expiry_time <= current_time: new_expiry_time = int(current_time + timedelta(days=duration_days).total_seconds() * 1000) @@ -596,12 +596,11 @@ async def handle_renew_config_confirm(callback_query: CallbackQuery, state: FSMC client_id = data.get("renew_client_id") email = data.get("renew_key_name") - new_expiry_time = data.get("renew_new_expiry_time") tariff_id = data.get("renew_tariff_id") selected_devices = data.get("config_selected_device_limit") selected_traffic_gb = data.get("config_selected_traffic_gb") - if not client_id or not email or not new_expiry_time or not tariff_id: + if not client_id or not email or not tariff_id: await callback_query.message.answer("❌ Данные для продления не найдены.") return @@ -612,6 +611,18 @@ async def handle_renew_config_confirm(callback_query: CallbackQuery, state: FSMC await callback_query.message.answer("❌ Тариф не найден или не поддерживает настройку.") return + duration_days = int(tariff.get("duration_days") or 30) + record = await get_key_details(session, email) + if not record: + await callback_query.message.answer("❌ Подписка не найдена.") + return + expiry_time = normalize_expiry_ms(record.get("expiry_time")) + current_time = int(datetime.now(timezone.utc).timestamp() * 1000) + if expiry_time <= current_time: + new_expiry_time = int(current_time + timedelta(days=duration_days).total_seconds() * 1000) + else: + new_expiry_time = int(expiry_time + timedelta(days=duration_days).total_seconds() * 1000) + final_price = calculate_config_price( tariff=tariff, selected_device_limit=int(selected_devices) if selected_devices is not None else None, diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py index c780c6f0..809ebcd9 100644 --- a/handlers/keys/key_view.py +++ b/handlers/keys/key_view.py @@ -3,7 +3,7 @@ import html import os import re -from datetime import datetime +from datetime import datetime, timezone import pytz @@ -328,8 +328,8 @@ async def build_key_view_payload(session: AsyncSession, tg_id: int, key_ref_or_e expiry_time = record["expiry_time"] server_name = record["server_id"] - expiry_date = datetime.utcfromtimestamp(expiry_time / 1000) - now = datetime.utcnow() + expiry_date = datetime.fromtimestamp(expiry_time / 1000, tz=timezone.utc) + now = datetime.now(timezone.utc) time_left = expiry_date - now if time_left.total_seconds() <= 0: diff --git a/handlers/notifications/general_notifications.py b/handlers/notifications/general_notifications.py index 18124677..a443c37c 100644 --- a/handlers/notifications/general_notifications.py +++ b/handlers/notifications/general_notifications.py @@ -1,7 +1,7 @@ import asyncio from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Optional import pytz @@ -118,7 +118,7 @@ async def preload_notification_data(session: AsyncSession) -> dict[str, Any]: ~exists().where(BlockedUser.tg_id == Key.tg_id), ~exists().where( ManualBan.tg_id == Key.tg_id, - or_(ManualBan.until.is_(None), ManualBan.until > datetime.utcnow()), + or_(ManualBan.until.is_(None), ManualBan.until > datetime.now(timezone.utc)), ), ) ) @@ -391,8 +391,8 @@ async def try_auto_renew(ctx: NotificationContext, key) -> tuple[bool, dict | No new_expiry_time = ( current_expiry - if current_expiry > datetime.utcnow().timestamp() * 1000 - else datetime.utcnow().timestamp() * 1000 + if current_expiry > datetime.now(timezone.utc).timestamp() * 1000 + else datetime.now(timezone.utc).timestamp() * 1000 ) + duration_days * 24 * 60 * 60 * 1000 logger.info( @@ -609,7 +609,7 @@ async def _get_blocked_expired_keys(session: AsyncSession, current_time: int) -> exists().where(BlockedUser.tg_id == Key.tg_id), exists().where( ManualBan.tg_id == Key.tg_id, - or_(ManualBan.until.is_(None), ManualBan.until > datetime.utcnow()), + or_(ManualBan.until.is_(None), ManualBan.until > datetime.now(timezone.utc)), ), ), ) diff --git a/handlers/tariffs/buy/key_tariffs.py b/handlers/tariffs/buy/key_tariffs.py index e0c30e0a..1305a062 100644 --- a/handlers/tariffs/buy/key_tariffs.py +++ b/handlers/tariffs/buy/key_tariffs.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from math import ceil from typing import Any @@ -606,7 +606,9 @@ async def start_user_tariff_configurator( "config_tariff_id": tariff["id"], "tariff_config": cfg_for_state, } - if renew_mode != "renew": + if renew_mode == "renew": + update_payload["renew_tariff_id"] = tariff["id"] + else: update_payload["config_selected_device_limit"] = None update_payload["config_selected_traffic_gb"] = None @@ -784,7 +786,7 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: discount_info = await check_hot_lead_discount(session, tg_id) if tariff.get("group_code") in ["discounts", "discounts_max"]: - if not discount_info.get("available") or datetime.utcnow() >= discount_info["expires_at"]: + if not discount_info.get("available") or datetime.now(timezone.utc) >= discount_info["expires_at"]: builder = InlineKeyboardBuilder() builder.row(InlineKeyboardButton(text=MAIN_MENU, callback_data="profile")) await edit_or_send_message( @@ -820,6 +822,8 @@ async def select_tariff_plan(callback_query: CallbackQuery, session: Any, state: ) return + await state.update_data(renew_mode=None) + if tariff.get("configurable"): logger.info(f"[TARIFF_CFG] select_tariff_plan configurable: tg_id={tg_id} tariff_id={tariff_id}") try: diff --git a/handlers/utils.py b/handlers/utils.py index 0f42c5f9..3093a8e6 100644 --- a/handlers/utils.py +++ b/handlers/utils.py @@ -4,7 +4,7 @@ import re import secrets import string -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import aiofiles @@ -456,7 +456,9 @@ def get_username(user) -> str: def format_discount_time_left(last_time: datetime, discount_hours: int) -> str: expires_at = last_time + timedelta(hours=discount_hours) - current_time = datetime.utcnow() + current_time = datetime.now(timezone.utc) + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=timezone.utc) time_left = expires_at - current_time if time_left.total_seconds() <= 0: diff --git a/middlewares/ban_checker.py b/middlewares/ban_checker.py index 8af892c0..3a7be01a 100644 --- a/middlewares/ban_checker.py +++ b/middlewares/ban_checker.py @@ -1,10 +1,11 @@ from collections.abc import Awaitable, Callable -from datetime import datetime +from datetime import datetime, timezone from typing import Any +import pytz + from aiogram import BaseMiddleware from aiogram.types import CallbackQuery, Message, TelegramObject, Update -from pytz import timezone from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -16,7 +17,7 @@ from database.models import ManualBan, User from logger import logger -TZ = timezone("Europe/Moscow") +TZ = pytz.timezone("Europe/Moscow") _BAN_CACHE_TTL = BAN_CACHE_TTL_SEC @@ -34,7 +35,7 @@ class BanCheckerMiddleware(BaseMiddleware): .join(User, ManualBan.user_id == User.id) .where( User.tg_id == tg_id, - (ManualBan.until.is_(None)) | (ManualBan.until > datetime.utcnow()), + (ManualBan.until.is_(None)) | (ManualBan.until > datetime.now(timezone.utc)), ) .limit(1) ) @@ -87,7 +88,7 @@ class BanCheckerMiddleware(BaseMiddleware): until_parsed = datetime.fromisoformat(until_raw) except ValueError: until_parsed = None - if until_parsed is not None and until_parsed < datetime.utcnow(): + if until_parsed is not None and until_parsed < datetime.now(timezone.utc): ban_info = None await cache_delete(cache_key("ban_status", tg_id)) else: diff --git a/services/gifts.py b/services/gifts.py index f5c99d00..f594f10e 100644 --- a/services/gifts.py +++ b/services/gifts.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING from pytz import timezone @@ -98,7 +98,7 @@ async def redeem_gift( if wu is None: raise NotFoundError("Пользователь не найден") - if gift_info.expiry_time and gift_info.expiry_time < datetime.utcnow(): + if gift_info.expiry_time and gift_info.expiry_time < datetime.now(timezone.utc): raise ValidationError("Срок действия подарка истёк") if gift_info.sender_user_id == wu.id: @@ -209,7 +209,7 @@ async def create_gift( await update_balance(session, sender_user_ref, -price_to_charge) duration_days = int(tariff["duration_days"] or 0) - expiry_time = datetime.utcnow() + timedelta(days=duration_days) + expiry_time = datetime.now(timezone.utc) + timedelta(days=duration_days) gift_id = uuid.uuid4().hex gift_link = get_gift_link(sender_user_ref, gift_id) site_gift_link = get_site_gift_link(gift_id)